Compare commits
29 Commits
c8ddaa4dcc
...
codex/anal
| Author | SHA1 | Date | |
|---|---|---|---|
| e61e244c39 | |||
| 2c90404637 | |||
| 3508e7de19 | |||
| adc67e428d | |||
| dee947ce3e | |||
| 4d940e2cd4 | |||
| 8371b2a76b | |||
| e1fb43d57b | |||
| 5825783f8b | |||
| e884fc5bc0 | |||
| cf0861f8f6 | |||
| 4885cf6778 | |||
| 654a2a66d9 | |||
| 0118dd2e15 | |||
| 6888454727 | |||
| 91d91ebd08 | |||
| 37c5cfe5a9 | |||
| 2de43d5fbb | |||
| 434dbc15e9 | |||
| d5b2e529ff | |||
| d7b7ebfef9 | |||
| 8f3b2fd6e2 | |||
| 8a537e80e3 | |||
| 216a8e118d | |||
| b45896a9dd | |||
| 4a9961df70 | |||
| 18ac660ab2 | |||
| a6621f975c | |||
| 83e6ed2412 |
86
README.md
86
README.md
@@ -4,7 +4,7 @@ _这是一个毕业设计项目,旨在尝试实现一个完整的电动汽车
|
|||||||
|
|
||||||
## 📋 项目概述
|
## 📋 项目概述
|
||||||
|
|
||||||
Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含后端 CSMS(充电管理系统)和前端 Web 云平台应用。并设计了一个基于 ESP32,演示用的充电桩终端固件和 PCB 设计。
|
Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含 CSMS(充电管理系统)的前端应用和后端服务。并设计了一个基于 ESP32 的,可演示用的充电桩终端固件和 PCB 设计。
|
||||||
|
|
||||||
## 🏗️ 项目结构
|
## 🏗️ 项目结构
|
||||||
|
|
||||||
@@ -15,19 +15,18 @@ helios-evcs/
|
|||||||
│ └── web/ # 前端云平台应用(Next.js 15 + React 19)[submodule]
|
│ └── web/ # 前端云平台应用(Next.js 15 + React 19)[submodule]
|
||||||
├── hardware/ # 硬件和固件工程
|
├── hardware/ # 硬件和固件工程
|
||||||
│ ├── firmware/ # 充电桩固件
|
│ ├── firmware/ # 充电桩固件
|
||||||
│ └── pcb-kicad/ # PCB 设计文件(KiCAD)
|
│ └── pcb/ # PCB 设计文件(KiCAD)
|
||||||
├── package.json
|
├── package.json
|
||||||
├── pnpm-workspace.yaml
|
├── pnpm-workspace.yaml
|
||||||
├── pnpm-lock.yaml
|
└── pnpm-lock.yaml
|
||||||
└── .gitmodules
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 工作区包
|
## 📦 工作区包
|
||||||
|
|
||||||
### `apps/csms` - 充电管理系统后端
|
### `apps/csms` - CSMS 后端
|
||||||
|
|
||||||
- **技术栈**:Node.js + Hono + TypeScript
|
- **技术栈**:Node.js + Hono + TypeScript
|
||||||
- **端口**:3001(默认)
|
- **端口**:3001
|
||||||
- **职责**:
|
- **职责**:
|
||||||
- OCPP 协议实现
|
- OCPP 协议实现
|
||||||
- 充电设备管理
|
- 充电设备管理
|
||||||
@@ -35,16 +34,17 @@ helios-evcs/
|
|||||||
- 实时数据处理
|
- 实时数据处理
|
||||||
- RESTful API 接口
|
- RESTful API 接口
|
||||||
|
|
||||||
### `apps/web` - 前端管理界面(子模块)
|
### `apps/web` - CSMS 前端
|
||||||
|
|
||||||
- **技术栈**:Next.js 15 + React 19 + Tailwind CSS
|
- **技术栈**:Next.js 16 + React 19 + Tailwind CSS
|
||||||
- **端口**:3000(默认)
|
- **端口**:3000
|
||||||
- **源仓库**:https://github.com/HoshinoSuzumi/helios
|
|
||||||
- **职责**:
|
- **职责**:
|
||||||
- 充电站管理界面
|
- 充电桩管理
|
||||||
- 实时监控面板
|
- 概览监控面板
|
||||||
- 数据可视化
|
- 储值卡管理
|
||||||
- 用户管理
|
- 用户管理
|
||||||
|
- 充电会话历史
|
||||||
|
- 远程启动/停止充电
|
||||||
|
|
||||||
### `hardware/` - 硬件和固件工程
|
### `hardware/` - 硬件和固件工程
|
||||||
|
|
||||||
@@ -52,17 +52,18 @@ helios-evcs/
|
|||||||
- 设备驱动实现
|
- 设备驱动实现
|
||||||
- 通信协议栈
|
- 通信协议栈
|
||||||
- 实时控制逻辑
|
- 实时控制逻辑
|
||||||
- **pcb-kicad/** - PCB 电路板设计
|
- **pcb/** - PCB 电路板设计
|
||||||
- KiCAD 工程文件
|
- KiCAD 工程文件
|
||||||
- 电路原理图
|
- 电路原理图
|
||||||
- PCB 布局设计
|
- PCB 布局设计
|
||||||
- BOM 物料清单
|
- BOM 物料清单
|
||||||
|
- 制造文件(Gerber)
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 前置要求
|
### 前置要求
|
||||||
|
|
||||||
- Node.js >= 18
|
- Node.js >= 20
|
||||||
- pnpm >= 10.18.2
|
- pnpm >= 10.18.2
|
||||||
|
|
||||||
### 安装依赖
|
### 安装依赖
|
||||||
@@ -78,6 +79,8 @@ pnpm install
|
|||||||
# 启动所有开发服务器(CSMS + Web)
|
# 启动所有开发服务器(CSMS + Web)
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
|
||||||
|
# 迁移数据库
|
||||||
|
pnpm --filter csms run db:migrate
|
||||||
# 仅启动后端
|
# 仅启动后端
|
||||||
pnpm dev:csms
|
pnpm dev:csms
|
||||||
|
|
||||||
@@ -111,21 +114,11 @@ pnpm start:csms
|
|||||||
|
|
||||||
# 启动前端
|
# 启动前端
|
||||||
pnpm start:web
|
pnpm start:web
|
||||||
|
|
||||||
|
# 生产环境使用 docker compose
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Scripts 说明
|
|
||||||
|
|
||||||
| 命令 | 描述 |
|
|
||||||
| ----------------- | ---------------------- |
|
|
||||||
| `pnpm dev` | 启动所有服务的开发模式 |
|
|
||||||
| `pnpm dev:csms` | 启动后端开发服务器 |
|
|
||||||
| `pnpm dev:web` | 启动前端开发服务器 |
|
|
||||||
| `pnpm build` | 构建所有包 |
|
|
||||||
| `pnpm build:csms` | 构建后端 |
|
|
||||||
| `pnpm build:web` | 构建前端 |
|
|
||||||
| `pnpm start:csms` | 生产环境启动后端 |
|
|
||||||
| `pnpm start:web` | 生产环境启动前端 |
|
|
||||||
|
|
||||||
## 🔧 工作区管理
|
## 🔧 工作区管理
|
||||||
|
|
||||||
### 使用 pnpm filter 运行特定包命令
|
### 使用 pnpm filter 运行特定包命令
|
||||||
@@ -133,7 +126,7 @@ pnpm start:web
|
|||||||
```bash
|
```bash
|
||||||
# 在指定包中运行命令
|
# 在指定包中运行命令
|
||||||
pnpm --filter csms <command>
|
pnpm --filter csms <command>
|
||||||
pnpm --filter helios-web <command>
|
pnpm --filter web <command>
|
||||||
|
|
||||||
# 示例:在 csms 中运行测试
|
# 示例:在 csms 中运行测试
|
||||||
pnpm --filter csms run test
|
pnpm --filter csms run test
|
||||||
@@ -143,42 +136,11 @@ pnpm --filter csms run test
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在根安装(所有包共用)
|
# 在根安装(所有包共用)
|
||||||
pnpm add <package>
|
pnpm add <package> -w
|
||||||
|
|
||||||
# 在特定包中安装
|
# 在特定包中安装
|
||||||
pnpm --filter csms add <package>
|
pnpm --filter csms add <package>
|
||||||
pnpm --filter helios-web add <package>
|
pnpm --filter web add <package>
|
||||||
```
|
|
||||||
|
|
||||||
## 📁 Git 子模块管理
|
|
||||||
|
|
||||||
`apps/web` 作为 Git 子模块管理:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 初始化并更新子模块
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
# 更新子模块到最新版本
|
|
||||||
git submodule update --remote
|
|
||||||
|
|
||||||
# 克隆包含子模块的仓库
|
|
||||||
git clone --recurse-submodules <repo-url>
|
|
||||||
```
|
|
||||||
|
|
||||||
在子模块内修改后的工作流:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 在 apps/web 目录内提交更改
|
|
||||||
cd apps/web
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: xxx"
|
|
||||||
git push
|
|
||||||
|
|
||||||
# 返回主仓库,更新子模块引用
|
|
||||||
cd ..
|
|
||||||
git add apps/web
|
|
||||||
git commit -m "chore: update helios submodule"
|
|
||||||
git push
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 技术文档
|
## 📚 技术文档
|
||||||
|
|||||||
1
apps/csms/drizzle/0004_nervous_frog_thor.sql
Normal file
1
apps/csms/drizzle/0004_nervous_frog_thor.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "charge_point" ADD COLUMN "device_name" varchar(100);
|
||||||
1
apps/csms/drizzle/0005_peaceful_anthem.sql
Normal file
1
apps/csms/drizzle/0005_peaceful_anthem.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "charge_point" ADD COLUMN "password_hash" varchar(255);
|
||||||
6
apps/csms/drizzle/0006_spooky_skin.sql
Normal file
6
apps/csms/drizzle/0006_spooky_skin.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE "system_setting" (
|
||||||
|
"key" varchar(64) PRIMARY KEY NOT NULL,
|
||||||
|
"value" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal file
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "charge_point" ADD COLUMN "last_ws_connected_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "charge_point" ADD COLUMN "last_ws_disconnected_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "charge_point" ADD COLUMN "connection_session_id" varchar(64);--> statement-breakpoint
|
||||||
|
ALTER TABLE "charge_point" ADD COLUMN "transport_status" varchar DEFAULT 'offline' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "charge_point" ADD COLUMN "last_command_status" varchar;--> statement-breakpoint
|
||||||
|
ALTER TABLE "charge_point" ADD COLUMN "last_command_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN "remote_stop_requested_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN "remote_stop_request_id" varchar(64);--> statement-breakpoint
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN "remote_stop_status" varchar;
|
||||||
1942
apps/csms/drizzle/meta/0004_snapshot.json
Normal file
1942
apps/csms/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1948
apps/csms/drizzle/meta/0005_snapshot.json
Normal file
1948
apps/csms/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1987
apps/csms/drizzle/meta/0006_snapshot.json
Normal file
1987
apps/csms/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,34 @@
|
|||||||
"when": 1773307380017,
|
"when": 1773307380017,
|
||||||
"tag": "0003_milky_supreme_intelligence",
|
"tag": "0003_milky_supreme_intelligence",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773639782622,
|
||||||
|
"tag": "0004_nervous_frog_thor",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773678571220,
|
||||||
|
"tag": "0005_peaceful_anthem",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773682931777,
|
||||||
|
"tag": "0006_spooky_skin",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773819865056,
|
||||||
|
"tag": "0007_unusual_squadron_supreme",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,29 @@ export const chargePoint = pgTable('charge_point', {
|
|||||||
heartbeatInterval: integer('heartbeat_interval').default(60),
|
heartbeatInterval: integer('heartbeat_interval').default(60),
|
||||||
/** 最后一次收到 Heartbeat.req 的时间(UTC) */
|
/** 最后一次收到 Heartbeat.req 的时间(UTC) */
|
||||||
lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }),
|
lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }),
|
||||||
|
/** 最近一次 WebSocket 连接建立时间(UTC) */
|
||||||
|
lastWsConnectedAt: timestamp('last_ws_connected_at', { withTimezone: true }),
|
||||||
|
/** 最近一次 WebSocket 连接关闭时间(UTC) */
|
||||||
|
lastWsDisconnectedAt: timestamp('last_ws_disconnected_at', { withTimezone: true }),
|
||||||
|
/** 最近一次活跃 WebSocket 会话 ID(用于区分重连代次) */
|
||||||
|
connectionSessionId: varchar('connection_session_id', { length: 64 }),
|
||||||
|
/**
|
||||||
|
* OCPP 传输通道状态:
|
||||||
|
* online = 当前实例持有活动 WebSocket
|
||||||
|
* unavailable = 最近有心跳,但当前无可用下行通道
|
||||||
|
* offline = 无活动通道
|
||||||
|
*/
|
||||||
|
transportStatus: varchar('transport_status', {
|
||||||
|
enum: ['online', 'unavailable', 'offline'],
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default('offline'),
|
||||||
|
/** 最近一次下行命令确认结果 */
|
||||||
|
lastCommandStatus: varchar('last_command_status', {
|
||||||
|
enum: ['Accepted', 'Rejected', 'Error', 'Timeout'],
|
||||||
|
}),
|
||||||
|
/** 最近一次下行命令确认时间(UTC) */
|
||||||
|
lastCommandAt: timestamp('last_command_at', { withTimezone: true }),
|
||||||
/** 最后一次收到 BootNotification.req 的时间(UTC) */
|
/** 最后一次收到 BootNotification.req 的时间(UTC) */
|
||||||
lastBootNotificationAt: timestamp('last_boot_notification_at', {
|
lastBootNotificationAt: timestamp('last_boot_notification_at', {
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
@@ -71,6 +94,11 @@ export const chargePoint = pgTable('charge_point', {
|
|||||||
* 交易结束时按实际用电量从储值卡扣费:fee = ceil(energyWh * feePerKwh / 1000)
|
* 交易结束时按实际用电量从储值卡扣费:fee = ceil(energyWh * feePerKwh / 1000)
|
||||||
* 默认为 0,即不计费。仅在 pricingMode = 'fixed' 时生效。
|
* 默认为 0,即不计费。仅在 pricingMode = 'fixed' 时生效。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 设备名称(系统内部维护,不会被设备上报信息覆盖)
|
||||||
|
* 供运营人员标记,例如"1号楼A区01号桩"
|
||||||
|
*/
|
||||||
|
deviceName: varchar('device_name', { length: 100 }),
|
||||||
feePerKwh: integer('fee_per_kwh').notNull().default(0),
|
feePerKwh: integer('fee_per_kwh').notNull().default(0),
|
||||||
/**
|
/**
|
||||||
* 计费模式
|
* 计费模式
|
||||||
@@ -80,6 +108,12 @@ export const chargePoint = pgTable('charge_point', {
|
|||||||
pricingMode: varchar('pricing_mode', { enum: ['fixed', 'tou'] })
|
pricingMode: varchar('pricing_mode', { enum: ['fixed', 'tou'] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default('fixed'),
|
.default('fixed'),
|
||||||
|
/**
|
||||||
|
* OCPP Security Profile 1/2: HTTP Basic Auth 密码哈希(scrypt)
|
||||||
|
* 格式:<salt>:<hash>,均为 hex 编码
|
||||||
|
* 首次创建充电桩时自动生成,明文密码仅在创建/重置时返回一次
|
||||||
|
*/
|
||||||
|
passwordHash: varchar('password_hash', { length: 255 }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true })
|
createdAt: timestamp('created_at', { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
@@ -387,6 +421,16 @@ export const transaction = pgTable(
|
|||||||
'DeAuthorized',
|
'DeAuthorized',
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
/** 管理端发起远程停止的请求时间 */
|
||||||
|
remoteStopRequestedAt: timestamp('remote_stop_requested_at', {
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
/** 管理端发起远程停止的 OCPP uniqueId */
|
||||||
|
remoteStopRequestId: varchar('remote_stop_request_id', { length: 64 }),
|
||||||
|
/** 最近一次远程停止请求的结果 */
|
||||||
|
remoteStopStatus: varchar('remote_stop_status', {
|
||||||
|
enum: ['Requested', 'Accepted', 'Rejected', 'Error', 'Timeout'],
|
||||||
|
}),
|
||||||
/**
|
/**
|
||||||
* 本次充电扣费金额(单位:分)
|
* 本次充电扣费金额(单位:分)
|
||||||
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入
|
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export * from './auth-schema.ts'
|
export * from './auth-schema.ts'
|
||||||
export * from './ocpp-schema.ts'
|
export * from './ocpp-schema.ts'
|
||||||
export * from './tariff-schema.ts'
|
export * from './tariff-schema.ts'
|
||||||
export * from './tariff-schema.ts'
|
export * from './settings-schema.ts'
|
||||||
|
|||||||
15
apps/csms/src/db/settings-schema.ts
Normal file
15
apps/csms/src/db/settings-schema.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { jsonb, pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统参数配置(按模块 key 存储)
|
||||||
|
* 例如:key=ocpp16j, value={ heartbeatInterval: 60 }
|
||||||
|
*/
|
||||||
|
export const systemSetting = pgTable("system_setting", {
|
||||||
|
key: varchar("key", { length: 64 }).primaryKey(),
|
||||||
|
value: jsonb("value").notNull().$type<Record<string, unknown>>(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
});
|
||||||
@@ -8,6 +8,10 @@ import { logger } from 'hono/logger'
|
|||||||
import { showRoutes } from 'hono/dev'
|
import { showRoutes } from 'hono/dev'
|
||||||
import { auth } from './lib/auth.ts'
|
import { auth } from './lib/auth.ts'
|
||||||
import { createOcppHandler } from './ocpp/handler.ts'
|
import { createOcppHandler } from './ocpp/handler.ts'
|
||||||
|
import { verifyOcppPassword } from './lib/ocpp-auth.ts'
|
||||||
|
import { useDrizzle } from './lib/db.ts'
|
||||||
|
import { chargePoint } from './db/schema.ts'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
import statsRoutes from './routes/stats.ts'
|
import statsRoutes from './routes/stats.ts'
|
||||||
import statsChartRoutes from './routes/stats-chart.ts'
|
import statsChartRoutes from './routes/stats-chart.ts'
|
||||||
import chargePointRoutes from './routes/charge-points.ts'
|
import chargePointRoutes from './routes/charge-points.ts'
|
||||||
@@ -16,6 +20,7 @@ import idTagRoutes from './routes/id-tags.ts'
|
|||||||
import userRoutes from './routes/users.ts'
|
import userRoutes from './routes/users.ts'
|
||||||
import setupRoutes from './routes/setup.ts'
|
import setupRoutes from './routes/setup.ts'
|
||||||
import tariffRoutes from './routes/tariff.ts'
|
import tariffRoutes from './routes/tariff.ts'
|
||||||
|
import settingsRoutes from './routes/settings.ts'
|
||||||
|
|
||||||
import type { HonoEnv } from './types/hono.ts'
|
import type { HonoEnv } from './types/hono.ts'
|
||||||
|
|
||||||
@@ -60,6 +65,7 @@ app.route('/api/id-tags', idTagRoutes)
|
|||||||
app.route('/api/users', userRoutes)
|
app.route('/api/users', userRoutes)
|
||||||
app.route('/api/setup', setupRoutes)
|
app.route('/api/setup', setupRoutes)
|
||||||
app.route('/api/tariff', tariffRoutes)
|
app.route('/api/tariff', tariffRoutes)
|
||||||
|
app.route('/api/settings', settingsRoutes)
|
||||||
|
|
||||||
app.get('/api', (c) => {
|
app.get('/api', (c) => {
|
||||||
const user = c.get('user')
|
const user = c.get('user')
|
||||||
@@ -83,6 +89,43 @@ app.get('/api', (c) => {
|
|||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
'/ocpp/:chargePointId',
|
'/ocpp/:chargePointId',
|
||||||
|
async (c, next) => {
|
||||||
|
const chargePointId = c.req.param('chargePointId')
|
||||||
|
const authHeader = c.req.header('Authorization')
|
||||||
|
|
||||||
|
if (!authHeader?.startsWith('Basic ')) {
|
||||||
|
c.header('WWW-Authenticate', 'Basic realm="OCPP"')
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: string, password: string
|
||||||
|
try {
|
||||||
|
const decoded = atob(authHeader.slice(6))
|
||||||
|
const colonIdx = decoded.indexOf(':')
|
||||||
|
if (colonIdx === -1) throw new Error('Invalid format')
|
||||||
|
id = decoded.slice(0, colonIdx)
|
||||||
|
password = decoded.slice(colonIdx + 1)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Invalid Authorization header' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id !== chargePointId) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDrizzle()
|
||||||
|
const [cp] = await db
|
||||||
|
.select({ passwordHash: chargePoint.passwordHash })
|
||||||
|
.from(chargePoint)
|
||||||
|
.where(eq(chargePoint.chargePointIdentifier, chargePointId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!cp?.passwordHash || !(await verifyOcppPassword(password, cp.passwordHash))) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
await next()
|
||||||
|
},
|
||||||
upgradeWebSocket((c) => {
|
upgradeWebSocket((c) => {
|
||||||
const chargePointId = c.req.param('chargePointId')
|
const chargePointId = c.req.param('chargePointId')
|
||||||
const connInfo = getConnInfo(c)
|
const connInfo = getConnInfo(c)
|
||||||
|
|||||||
32
apps/csms/src/lib/ocpp-auth.ts
Normal file
32
apps/csms/src/lib/ocpp-auth.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
|
||||||
|
const scryptAsync = promisify(scrypt)
|
||||||
|
|
||||||
|
const SALT_LEN = 16
|
||||||
|
const KEY_LEN = 64
|
||||||
|
|
||||||
|
/** 生成随机明文密码(24 位 hex 字符串) */
|
||||||
|
export function generateOcppPassword(): string {
|
||||||
|
return randomBytes(12).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将明文密码哈希为存储格式 `<salt_hex>:<hash_hex>` */
|
||||||
|
export async function hashOcppPassword(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(SALT_LEN)
|
||||||
|
const hash = (await scryptAsync(password, salt, KEY_LEN)) as Buffer
|
||||||
|
return `${salt.toString('hex')}:${hash.toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证明文密码是否与存储的哈希匹配 */
|
||||||
|
export async function verifyOcppPassword(
|
||||||
|
password: string,
|
||||||
|
stored: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [saltHex, hashHex] = stored.split(':')
|
||||||
|
if (!saltHex || !hashHex) return false
|
||||||
|
const salt = Buffer.from(saltHex, 'hex')
|
||||||
|
const expectedHash = Buffer.from(hashHex, 'hex')
|
||||||
|
const actualHash = (await scryptAsync(password, salt, KEY_LEN)) as Buffer
|
||||||
|
return timingSafeEqual(expectedHash, actualHash)
|
||||||
|
}
|
||||||
45
apps/csms/src/lib/system-settings.ts
Normal file
45
apps/csms/src/lib/system-settings.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { systemSetting } from "@/db/schema.js";
|
||||||
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
|
|
||||||
|
export const SETTINGS_KEY_OCPP16J = "ocpp16j";
|
||||||
|
const DEFAULT_HEARTBEAT_INTERVAL = 60;
|
||||||
|
const MIN_HEARTBEAT_INTERVAL = 10;
|
||||||
|
const MAX_HEARTBEAT_INTERVAL = 86400;
|
||||||
|
|
||||||
|
export type Ocpp16jSettings = {
|
||||||
|
heartbeatInterval: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingsPayload = {
|
||||||
|
ocpp16j: Ocpp16jSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sanitizeHeartbeatInterval(raw: unknown): number {
|
||||||
|
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||||
|
return DEFAULT_HEARTBEAT_INTERVAL;
|
||||||
|
}
|
||||||
|
const n = Math.round(raw);
|
||||||
|
if (n < MIN_HEARTBEAT_INTERVAL) return MIN_HEARTBEAT_INTERVAL;
|
||||||
|
if (n > MAX_HEARTBEAT_INTERVAL) return MAX_HEARTBEAT_INTERVAL;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultHeartbeatInterval(): number {
|
||||||
|
return DEFAULT_HEARTBEAT_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOcpp16jSettings(): Promise<Ocpp16jSettings> {
|
||||||
|
const db = useDrizzle();
|
||||||
|
const [ocpp16jRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSetting)
|
||||||
|
.where(eq(systemSetting.key, SETTINGS_KEY_OCPP16J))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const ocpp16jRaw = (ocpp16jRow?.value ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
heartbeatInterval: sanitizeHeartbeatInterval(ocpp16jRaw.heartbeatInterval),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { idTag } from "@/db/schema.js";
|
import { idTag, transaction } from "@/db/schema.js";
|
||||||
import type {
|
import type {
|
||||||
AuthorizeRequest,
|
AuthorizeRequest,
|
||||||
AuthorizeResponse,
|
AuthorizeResponse,
|
||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
export async function resolveIdTagInfo(
|
export async function resolveIdTagInfo(
|
||||||
idTagValue: string,
|
idTagValue: string,
|
||||||
checkBalance = true,
|
checkBalance = true,
|
||||||
|
checkConcurrent = true,
|
||||||
): Promise<IdTagInfo> {
|
): Promise<IdTagInfo> {
|
||||||
const db = useDrizzle();
|
const db = useDrizzle();
|
||||||
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
|
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
|
||||||
@@ -31,6 +32,17 @@ export async function resolveIdTagInfo(
|
|||||||
if (tag.status !== "Accepted") {
|
if (tag.status !== "Accepted") {
|
||||||
return { status: tag.status as IdTagInfo["status"] };
|
return { status: tag.status as IdTagInfo["status"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce single active transaction per idTag.
|
||||||
|
if (checkConcurrent) {
|
||||||
|
const [activeTx] = await db
|
||||||
|
.select({ id: transaction.id })
|
||||||
|
.from(transaction)
|
||||||
|
.where(and(eq(transaction.idTag, idTagValue), isNull(transaction.stopTimestamp)))
|
||||||
|
.limit(1);
|
||||||
|
if (activeTx) return { status: "ConcurrentTx" };
|
||||||
|
}
|
||||||
|
|
||||||
// Reject if balance is zero or negative
|
// Reject if balance is zero or negative
|
||||||
if (checkBalance && tag.balance <= 0) {
|
if (checkBalance && tag.balance <= 0) {
|
||||||
return { status: "Blocked" };
|
return { status: "Blocked" };
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { useDrizzle } from '@/lib/db.js'
|
import { useDrizzle } from '@/lib/db.js'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { chargePoint } from '@/db/schema.js'
|
import { chargePoint } from '@/db/schema.js'
|
||||||
|
import { getOcpp16jSettings } from '@/lib/system-settings.js'
|
||||||
import type {
|
import type {
|
||||||
BootNotificationRequest,
|
BootNotificationRequest,
|
||||||
BootNotificationResponse,
|
BootNotificationResponse,
|
||||||
OcppConnectionContext,
|
OcppConnectionContext,
|
||||||
} from '../types.ts'
|
} from '../types.ts'
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_INTERVAL = 60
|
|
||||||
|
|
||||||
export async function handleBootNotification(
|
export async function handleBootNotification(
|
||||||
payload: BootNotificationRequest,
|
payload: BootNotificationRequest,
|
||||||
ctx: OcppConnectionContext,
|
ctx: OcppConnectionContext,
|
||||||
): Promise<BootNotificationResponse> {
|
): Promise<BootNotificationResponse> {
|
||||||
const db = useDrizzle()
|
const db = useDrizzle()
|
||||||
|
const { heartbeatInterval } = await getOcpp16jSettings()
|
||||||
|
|
||||||
const [cp] = await db
|
const [cp] = await db
|
||||||
.insert(chargePoint)
|
.insert(chargePoint)
|
||||||
@@ -30,8 +30,9 @@ export async function handleBootNotification(
|
|||||||
meterSerialNumber: payload.meterSerialNumber ?? null,
|
meterSerialNumber: payload.meterSerialNumber ?? null,
|
||||||
// New, unknown devices start as Pending — admin must manually accept them
|
// New, unknown devices start as Pending — admin must manually accept them
|
||||||
registrationStatus: 'Pending',
|
registrationStatus: 'Pending',
|
||||||
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
heartbeatInterval,
|
||||||
lastBootNotificationAt: dayjs().toDate(),
|
lastBootNotificationAt: dayjs().toDate(),
|
||||||
|
transportStatus: 'online',
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: chargePoint.chargePointIdentifier,
|
target: chargePoint.chargePointIdentifier,
|
||||||
@@ -45,8 +46,9 @@ export async function handleBootNotification(
|
|||||||
meterType: payload.meterType ?? null,
|
meterType: payload.meterType ?? null,
|
||||||
meterSerialNumber: payload.meterSerialNumber ?? null,
|
meterSerialNumber: payload.meterSerialNumber ?? null,
|
||||||
// Do NOT override registrationStatus — preserve whatever the admin set
|
// Do NOT override registrationStatus — preserve whatever the admin set
|
||||||
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
heartbeatInterval,
|
||||||
lastBootNotificationAt: dayjs().toDate(),
|
lastBootNotificationAt: dayjs().toDate(),
|
||||||
|
transportStatus: 'online',
|
||||||
updatedAt: dayjs().toDate(),
|
updatedAt: dayjs().toDate(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -59,7 +61,7 @@ export async function handleBootNotification(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
currentTime: dayjs().toISOString(),
|
currentTime: dayjs().toISOString(),
|
||||||
interval: DEFAULT_HEARTBEAT_INTERVAL,
|
interval: heartbeatInterval,
|
||||||
status,
|
status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export async function handleHeartbeat(
|
|||||||
|
|
||||||
await db
|
await db
|
||||||
.update(chargePoint)
|
.update(chargePoint)
|
||||||
.set({ lastHeartbeatAt: dayjs().toDate() })
|
.set({
|
||||||
|
lastHeartbeatAt: dayjs().toDate(),
|
||||||
|
transportStatus: 'online',
|
||||||
|
updatedAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
|
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export async function handleStartTransaction(
|
|||||||
if (rejected) {
|
if (rejected) {
|
||||||
await db
|
await db
|
||||||
.update(connector)
|
.update(connector)
|
||||||
.set({ status: "Available", updatedAt: now })
|
.set({ status: "Available", updatedAt: now.toDate() })
|
||||||
.where(eq(connector.id, conn.id));
|
.where(eq(connector.id, conn.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export async function handleStopTransaction(
|
|||||||
stopMeterValue: payload.meterStop,
|
stopMeterValue: payload.meterStop,
|
||||||
stopIdTag: payload.idTag ?? null,
|
stopIdTag: payload.idTag ?? null,
|
||||||
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
|
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
|
||||||
|
remoteStopStatus: sql`case
|
||||||
|
when ${transaction.remoteStopRequestedAt} is not null and coalesce(${transaction.remoteStopStatus}, 'Requested') in ('Requested', 'Accepted')
|
||||||
|
then 'Accepted'
|
||||||
|
else ${transaction.remoteStopStatus}
|
||||||
|
end`,
|
||||||
updatedAt: dayjs().toDate(),
|
updatedAt: dayjs().toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(transaction.id, payload.transactionId))
|
.where(eq(transaction.id, payload.transactionId))
|
||||||
@@ -183,7 +188,9 @@ export async function handleStopTransaction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
|
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
|
||||||
const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined;
|
const idTagInfo = payload.idTag
|
||||||
|
? await resolveIdTagInfo(payload.idTag, false, false)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return { idTagInfo };
|
return { idTagInfo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import type { WSContext } from 'hono/ws'
|
import type { WSContext } from 'hono/ws'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
import { isSupportedOCPP } from '@/constants.js'
|
import { isSupportedOCPP } from '@/constants.js'
|
||||||
|
import { useDrizzle } from '@/lib/db.js'
|
||||||
|
import { chargePoint } from '@/db/schema.js'
|
||||||
import {
|
import {
|
||||||
OCPP_MESSAGE_TYPE,
|
OCPP_MESSAGE_TYPE,
|
||||||
type OcppCall,
|
type OcppCall,
|
||||||
|
type OcppCallErrorMessage,
|
||||||
|
type OcppCallResultMessage,
|
||||||
type OcppErrorCode,
|
type OcppErrorCode,
|
||||||
type OcppMessage,
|
type OcppMessage,
|
||||||
type OcppConnectionContext,
|
type OcppConnectionContext,
|
||||||
|
type CommandChannelStatus,
|
||||||
type AuthorizeRequest,
|
type AuthorizeRequest,
|
||||||
type AuthorizeResponse,
|
type AuthorizeResponse,
|
||||||
type BootNotificationRequest,
|
type BootNotificationRequest,
|
||||||
@@ -24,9 +31,26 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Global registry of active OCPP WebSocket connections.
|
* Global registry of active OCPP WebSocket connections.
|
||||||
* Key = chargePointIdentifier, Value = WSContext
|
* Key = chargePointIdentifier, Value = connection entry
|
||||||
*/
|
*/
|
||||||
export const ocppConnections = new Map<string, WSContext>()
|
export type OcppConnectionEntry = {
|
||||||
|
ws: WSContext
|
||||||
|
sessionId: string
|
||||||
|
openedAt: Date
|
||||||
|
lastMessageAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingCall =
|
||||||
|
| {
|
||||||
|
chargePointIdentifier: string
|
||||||
|
action: string
|
||||||
|
resolve: (payload: unknown) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timeout: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ocppConnections = new Map<string, OcppConnectionEntry>()
|
||||||
|
const pendingCalls = new Map<string, PendingCall>()
|
||||||
import { handleAuthorize } from './actions/authorize.ts'
|
import { handleAuthorize } from './actions/authorize.ts'
|
||||||
import { handleBootNotification } from './actions/boot-notification.ts'
|
import { handleBootNotification } from './actions/boot-notification.ts'
|
||||||
import { handleHeartbeat } from './actions/heartbeat.ts'
|
import { handleHeartbeat } from './actions/heartbeat.ts'
|
||||||
@@ -92,6 +116,80 @@ function sendCallError(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateTransportState(
|
||||||
|
chargePointIdentifier: string,
|
||||||
|
values: Partial<typeof chargePoint.$inferInsert>,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = useDrizzle()
|
||||||
|
await db
|
||||||
|
.update(chargePoint)
|
||||||
|
.set({
|
||||||
|
...values,
|
||||||
|
updatedAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
|
.where(eq(chargePoint.chargePointIdentifier, chargePointIdentifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommandChannelStatus(chargePointIdentifier: string): CommandChannelStatus {
|
||||||
|
return ocppConnections.has(chargePointIdentifier) ? 'online' : 'unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendOcppCall<TPayload extends Record<string, unknown>, TResult = unknown>(
|
||||||
|
chargePointIdentifier: string,
|
||||||
|
action: string,
|
||||||
|
payload: TPayload,
|
||||||
|
timeoutOrOptions: number | { timeoutMs?: number; uniqueId?: string } = 15000,
|
||||||
|
): Promise<TResult> {
|
||||||
|
const entry = ocppConnections.get(chargePointIdentifier)
|
||||||
|
if (!entry) {
|
||||||
|
await updateTransportState(chargePointIdentifier, { transportStatus: 'unavailable' })
|
||||||
|
throw new Error('TransportUnavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs =
|
||||||
|
typeof timeoutOrOptions === 'number' ? timeoutOrOptions : (timeoutOrOptions.timeoutMs ?? 15000)
|
||||||
|
const uniqueId =
|
||||||
|
typeof timeoutOrOptions === 'number' ? crypto.randomUUID() : (timeoutOrOptions.uniqueId ?? crypto.randomUUID())
|
||||||
|
|
||||||
|
const resultPromise = new Promise<TResult>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
pendingCalls.delete(uniqueId)
|
||||||
|
await updateTransportState(chargePointIdentifier, {
|
||||||
|
transportStatus: getCommandChannelStatus(chargePointIdentifier),
|
||||||
|
lastCommandStatus: 'Timeout',
|
||||||
|
lastCommandAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
|
reject(new Error('CommandTimeout'))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
pendingCalls.set(uniqueId, {
|
||||||
|
chargePointIdentifier,
|
||||||
|
action,
|
||||||
|
resolve: (response) => resolve(response as TResult),
|
||||||
|
reject,
|
||||||
|
timeout,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
entry.ws.send(JSON.stringify([OCPP_MESSAGE_TYPE.CALL, uniqueId, action, payload]))
|
||||||
|
} catch (error) {
|
||||||
|
const pending = pendingCalls.get(uniqueId)
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout)
|
||||||
|
pendingCalls.delete(uniqueId)
|
||||||
|
}
|
||||||
|
await updateTransportState(chargePointIdentifier, {
|
||||||
|
transportStatus: 'unavailable',
|
||||||
|
lastCommandStatus: 'Error',
|
||||||
|
lastCommandAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
|
throw error instanceof Error ? error : new Error('CommandSendFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultPromise
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory that produces a hono-ws event handler object for a single
|
* Factory that produces a hono-ws event handler object for a single
|
||||||
* OCPP WebSocket connection.
|
* OCPP WebSocket connection.
|
||||||
@@ -104,15 +202,26 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
|
|||||||
chargePointIdentifier,
|
chargePointIdentifier,
|
||||||
isRegistered: false,
|
isRegistered: false,
|
||||||
}
|
}
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onOpen(_evt: Event, ws: WSContext) {
|
async onOpen(_evt: Event, ws: WSContext) {
|
||||||
const subProtocol = ws.protocol ?? 'unknown'
|
const subProtocol = ws.protocol ?? 'unknown'
|
||||||
if (!isSupportedOCPP(subProtocol)) {
|
if (!isSupportedOCPP(subProtocol)) {
|
||||||
ws.close(1002, 'Unsupported subprotocol')
|
ws.close(1002, 'Unsupported subprotocol')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ocppConnections.set(chargePointIdentifier, ws)
|
ocppConnections.set(chargePointIdentifier, {
|
||||||
|
ws,
|
||||||
|
sessionId,
|
||||||
|
openedAt: new Date(),
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
})
|
||||||
|
await updateTransportState(chargePointIdentifier, {
|
||||||
|
transportStatus: 'online',
|
||||||
|
connectionSessionId: sessionId,
|
||||||
|
lastWsConnectedAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
console.log(
|
console.log(
|
||||||
`[OCPP] ${chargePointIdentifier} connected` +
|
`[OCPP] ${chargePointIdentifier} connected` +
|
||||||
(remoteAddr ? ` from ${remoteAddr}` : ''),
|
(remoteAddr ? ` from ${remoteAddr}` : ''),
|
||||||
@@ -122,6 +231,11 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
|
|||||||
async onMessage(evt: MessageEvent, ws: WSContext) {
|
async onMessage(evt: MessageEvent, ws: WSContext) {
|
||||||
let uniqueId = '(unknown)'
|
let uniqueId = '(unknown)'
|
||||||
try {
|
try {
|
||||||
|
const current = ocppConnections.get(chargePointIdentifier)
|
||||||
|
if (current) {
|
||||||
|
current.lastMessageAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
const raw = evt.data
|
const raw = evt.data
|
||||||
if (typeof raw !== 'string') return
|
if (typeof raw !== 'string') return
|
||||||
|
|
||||||
@@ -141,7 +255,36 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
|
|||||||
const [messageType, msgUniqueId] = message
|
const [messageType, msgUniqueId] = message
|
||||||
uniqueId = String(msgUniqueId)
|
uniqueId = String(msgUniqueId)
|
||||||
|
|
||||||
// CSMS only handles CALL messages from the charge point
|
if (messageType === OCPP_MESSAGE_TYPE.CALLRESULT) {
|
||||||
|
const [, responseUniqueId, payload] = message as OcppCallResultMessage
|
||||||
|
const pending = pendingCalls.get(responseUniqueId)
|
||||||
|
if (!pending) return
|
||||||
|
clearTimeout(pending.timeout)
|
||||||
|
pendingCalls.delete(responseUniqueId)
|
||||||
|
await updateTransportState(pending.chargePointIdentifier, {
|
||||||
|
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
|
||||||
|
lastCommandStatus: 'Accepted',
|
||||||
|
lastCommandAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
|
pending.resolve(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === OCPP_MESSAGE_TYPE.CALLERROR) {
|
||||||
|
const [, responseUniqueId, errorCode, errorDescription] = message as OcppCallErrorMessage
|
||||||
|
const pending = pendingCalls.get(responseUniqueId)
|
||||||
|
if (!pending) return
|
||||||
|
clearTimeout(pending.timeout)
|
||||||
|
pendingCalls.delete(responseUniqueId)
|
||||||
|
await updateTransportState(pending.chargePointIdentifier, {
|
||||||
|
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
|
||||||
|
lastCommandStatus: errorCode === 'InternalError' ? 'Error' : 'Rejected',
|
||||||
|
lastCommandAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
|
pending.reject(new Error(`${errorCode}:${errorDescription}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (messageType !== OCPP_MESSAGE_TYPE.CALL) return
|
if (messageType !== OCPP_MESSAGE_TYPE.CALL) return
|
||||||
|
|
||||||
const [, , action, payload] = message as OcppCall
|
const [, , action, payload] = message as OcppCall
|
||||||
@@ -174,8 +317,15 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onClose(evt: CloseEvent, _ws: WSContext) {
|
async onClose(evt: CloseEvent, _ws: WSContext) {
|
||||||
ocppConnections.delete(chargePointIdentifier)
|
const current = ocppConnections.get(chargePointIdentifier)
|
||||||
|
if (current?.sessionId === sessionId) {
|
||||||
|
ocppConnections.delete(chargePointIdentifier)
|
||||||
|
await updateTransportState(chargePointIdentifier, {
|
||||||
|
transportStatus: 'offline',
|
||||||
|
lastWsDisconnectedAt: dayjs().toDate(),
|
||||||
|
})
|
||||||
|
}
|
||||||
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
|
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export type OcppConnectionContext = {
|
|||||||
isRegistered: boolean
|
isRegistered: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OcppCallResultMessage = [3, string, Record<string, unknown>]
|
||||||
|
export type OcppCallErrorMessage = [4, string, OcppErrorCode, string, Record<string, unknown>]
|
||||||
|
|
||||||
|
export type CommandChannelStatus = 'online' | 'unavailable' | 'offline'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Action payload types (OCPP 1.6-J Section 4.x)
|
// Action payload types (OCPP 1.6-J Section 4.x)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { desc, eq, sql } from "drizzle-orm";
|
import { desc, eq, sql, inArray } from "drizzle-orm";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { chargePoint, connector } from "@/db/schema.js";
|
import { chargePoint, connector } from "@/db/schema.js";
|
||||||
|
import { ocppConnections } from "@/ocpp/handler.js";
|
||||||
|
import { generateOcppPassword, hashOcppPassword } from "@/lib/ocpp-auth.js";
|
||||||
import type { HonoEnv } from "@/types/hono.ts";
|
import type { HonoEnv } from "@/types/hono.ts";
|
||||||
|
|
||||||
const app = new Hono<HonoEnv>();
|
const app = new Hono<HonoEnv>();
|
||||||
@@ -70,6 +72,7 @@ app.post("/", async (c) => {
|
|||||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||||
feePerKwh?: number;
|
feePerKwh?: number;
|
||||||
pricingMode?: "fixed" | "tou";
|
pricingMode?: "fixed" | "tou";
|
||||||
|
deviceName?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
if (!body.chargePointIdentifier?.trim()) {
|
if (!body.chargePointIdentifier?.trim()) {
|
||||||
@@ -82,6 +85,9 @@ app.post("/", async (c) => {
|
|||||||
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plainPassword = generateOcppPassword()
|
||||||
|
const passwordHash = await hashOcppPassword(plainPassword)
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(chargePoint)
|
.insert(chargePoint)
|
||||||
.values({
|
.values({
|
||||||
@@ -92,6 +98,8 @@ app.post("/", async (c) => {
|
|||||||
registrationStatus: body.registrationStatus ?? "Pending",
|
registrationStatus: body.registrationStatus ?? "Pending",
|
||||||
feePerKwh: body.feePerKwh ?? 0,
|
feePerKwh: body.feePerKwh ?? 0,
|
||||||
pricingMode: body.pricingMode ?? "fixed",
|
pricingMode: body.pricingMode ?? "fixed",
|
||||||
|
deviceName: body.deviceName?.trim() || null,
|
||||||
|
passwordHash,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
@@ -99,7 +107,15 @@ app.post("/", async (c) => {
|
|||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ ...created, connectors: [] }, 201);
|
// 明文密码仅在创建时返回一次,之后不可再查
|
||||||
|
return c.json({ ...created, passwordHash: undefined, plainPassword, connectors: [] }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/charge-points/connections — list currently active OCPP WebSocket connections */
|
||||||
|
app.get("/connections", (c) => {
|
||||||
|
return c.json({
|
||||||
|
connectedIdentifiers: Array.from(ocppConnections.keys()),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** GET /api/charge-points/:id — single charge point */
|
/** GET /api/charge-points/:id — single charge point */
|
||||||
@@ -134,6 +150,7 @@ app.patch("/:id", async (c) => {
|
|||||||
registrationStatus?: string;
|
registrationStatus?: string;
|
||||||
chargePointVendor?: string;
|
chargePointVendor?: string;
|
||||||
chargePointModel?: string;
|
chargePointModel?: string;
|
||||||
|
deviceName?: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const set: {
|
const set: {
|
||||||
@@ -142,6 +159,7 @@ app.patch("/:id", async (c) => {
|
|||||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||||
chargePointVendor?: string;
|
chargePointVendor?: string;
|
||||||
chargePointModel?: string;
|
chargePointModel?: string;
|
||||||
|
deviceName?: string | null;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} = { updatedAt: dayjs().toDate() };
|
} = { updatedAt: dayjs().toDate() };
|
||||||
|
|
||||||
@@ -159,6 +177,7 @@ app.patch("/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown";
|
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown";
|
||||||
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown";
|
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown";
|
||||||
|
if ("deviceName" in body) set.deviceName = body.deviceName?.trim() || null;
|
||||||
if (body.pricingMode !== undefined) {
|
if (body.pricingMode !== undefined) {
|
||||||
if (!['fixed', 'tou'].includes(body.pricingMode)) {
|
if (!['fixed', 'tou'].includes(body.pricingMode)) {
|
||||||
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
||||||
@@ -202,4 +221,25 @@ app.delete("/:id", async (c) => {
|
|||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** POST /api/charge-points/:id/reset-password — regenerate OCPP Basic Auth password */
|
||||||
|
app.post("/:id/reset-password", async (c) => {
|
||||||
|
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
|
||||||
|
const db = useDrizzle();
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const plainPassword = generateOcppPassword();
|
||||||
|
const passwordHash = await hashOcppPassword(plainPassword);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(chargePoint)
|
||||||
|
.set({ passwordHash })
|
||||||
|
.where(eq(chargePoint.id, id))
|
||||||
|
.returning({ id: chargePoint.id, chargePointIdentifier: chargePoint.chargePointIdentifier });
|
||||||
|
|
||||||
|
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
// 明文密码仅返回一次
|
||||||
|
return c.json({ ...updated, plainPassword });
|
||||||
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
65
apps/csms/src/routes/settings.ts
Normal file
65
apps/csms/src/routes/settings.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
|
import { systemSetting } from "@/db/schema.js";
|
||||||
|
import type { HonoEnv } from "@/types/hono.ts";
|
||||||
|
import {
|
||||||
|
SETTINGS_KEY_OCPP16J,
|
||||||
|
getOcpp16jSettings,
|
||||||
|
sanitizeHeartbeatInterval,
|
||||||
|
type SettingsPayload,
|
||||||
|
} from "@/lib/system-settings.js";
|
||||||
|
|
||||||
|
const app = new Hono<HonoEnv>();
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
ocpp16j: await getOcpp16jSettings(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/", async (c) => {
|
||||||
|
const currentUser = c.get("user");
|
||||||
|
if (currentUser?.role !== "admin") {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Partial<SettingsPayload>;
|
||||||
|
try {
|
||||||
|
body = await c.req.json<Partial<SettingsPayload>>();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid JSON" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ocpp16j) {
|
||||||
|
return c.json({ error: "Missing ocpp16j settings" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeatInterval = sanitizeHeartbeatInterval(body.ocpp16j.heartbeatInterval);
|
||||||
|
|
||||||
|
const db = useDrizzle();
|
||||||
|
await db
|
||||||
|
.insert(systemSetting)
|
||||||
|
.values({
|
||||||
|
key: SETTINGS_KEY_OCPP16J,
|
||||||
|
value: { heartbeatInterval },
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: systemSetting.key,
|
||||||
|
set: {
|
||||||
|
value: { heartbeatInterval },
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
ocpp16j: {
|
||||||
|
heartbeatInterval,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -26,7 +26,9 @@ app.get("/", async (c) => {
|
|||||||
db
|
db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(chargePoint)
|
.from(chargePoint)
|
||||||
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
|
.where(
|
||||||
|
sql`${chargePoint.transportStatus} = 'online' and ${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`,
|
||||||
|
),
|
||||||
db
|
db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(transaction)
|
.from(transaction)
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { Hono } from "hono";
|
|||||||
import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
|
import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { transaction, chargePoint, connector, idTag, meterValue } from "@/db/schema.js";
|
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
|
||||||
import type { SampledValue } from "@/db/schema.js";
|
import type { SampledValue } from "@/db/schema.js";
|
||||||
import { user } from "@/db/auth-schema.js";
|
import { user } from "@/db/auth-schema.js";
|
||||||
import { ocppConnections } from "@/ocpp/handler.js";
|
import { sendOcppCall } from "@/ocpp/handler.js";
|
||||||
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
|
import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js";
|
||||||
import type { HonoEnv } from "@/types/hono.ts";
|
import type { HonoEnv } from "@/types/hono.ts";
|
||||||
|
|
||||||
const app = new Hono<HonoEnv>();
|
const app = new Hono<HonoEnv>();
|
||||||
@@ -42,15 +42,33 @@ app.post("/remote-start", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-admin: verify idTag belongs to current user and is Accepted
|
// Non-admin: verify idTag belongs to current user
|
||||||
if (currentUser.role !== "admin") {
|
if (currentUser.role !== "admin") {
|
||||||
const [tag] = await db
|
const [tag] = await db
|
||||||
.select({ status: idTag.status })
|
.select({ idTag: idTag.idTag })
|
||||||
.from(idTag)
|
.from(idTag)
|
||||||
.where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id)))
|
.where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403);
|
if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403);
|
||||||
if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400);
|
}
|
||||||
|
|
||||||
|
// Reuse the same authorization logic as Authorize/StartTransaction.
|
||||||
|
const tagInfo = await resolveIdTagInfo(body.idTag.trim());
|
||||||
|
if (tagInfo.status !== "Accepted") {
|
||||||
|
if (tagInfo.status === "ConcurrentTx") {
|
||||||
|
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
|
||||||
|
}
|
||||||
|
return c.json({ error: `idTag rejected: ${tagInfo.status}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One idTag can only have one active transaction at a time.
|
||||||
|
const [activeTx] = await db
|
||||||
|
.select({ id: transaction.id })
|
||||||
|
.from(transaction)
|
||||||
|
.where(and(eq(transaction.idTag, body.idTag.trim()), isNull(transaction.stopTimestamp)))
|
||||||
|
.limit(1);
|
||||||
|
if (activeTx) {
|
||||||
|
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify charge point exists and is Accepted
|
// Verify charge point exists and is Accepted
|
||||||
@@ -65,19 +83,28 @@ app.post("/remote-start", async (c) => {
|
|||||||
return c.json({ error: "ChargePoint is not accepted" }, 400);
|
return c.json({ error: "ChargePoint is not accepted" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require the charge point to be online
|
try {
|
||||||
const ws = ocppConnections.get(body.chargePointIdentifier.trim());
|
const response = await sendOcppCall<
|
||||||
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503);
|
{ connectorId: number; idTag: string },
|
||||||
|
{ status?: string }
|
||||||
|
>(body.chargePointIdentifier.trim(), "RemoteStartTransaction", {
|
||||||
|
connectorId: body.connectorId,
|
||||||
|
idTag: body.idTag.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
const uniqueId = crypto.randomUUID();
|
if (response?.status && response.status !== "Accepted") {
|
||||||
ws.send(
|
return c.json({ error: `RemoteStartTransaction ${response.status}` }, 409)
|
||||||
JSON.stringify([
|
}
|
||||||
OCPP_MESSAGE_TYPE.CALL,
|
} catch (error) {
|
||||||
uniqueId,
|
const message = error instanceof Error ? error.message : "CommandSendFailed"
|
||||||
"RemoteStartTransaction",
|
if (message === "TransportUnavailable") {
|
||||||
{ connectorId: body.connectorId, idTag: body.idTag.trim() },
|
return c.json({ error: "ChargePoint command channel is unavailable" }, 503)
|
||||||
]),
|
}
|
||||||
);
|
if (message === "CommandTimeout") {
|
||||||
|
return c.json({ error: "ChargePoint did not confirm RemoteStartTransaction in time" }, 504)
|
||||||
|
}
|
||||||
|
return c.json({ error: `RemoteStartTransaction failed: ${message}` }, 502)
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
|
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
|
||||||
@@ -125,6 +152,7 @@ app.get("/", async (c) => {
|
|||||||
.select({
|
.select({
|
||||||
transaction,
|
transaction,
|
||||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||||
|
chargePointDeviceName: chargePoint.deviceName,
|
||||||
feePerKwh: chargePoint.feePerKwh,
|
feePerKwh: chargePoint.feePerKwh,
|
||||||
pricingMode: chargePoint.pricingMode,
|
pricingMode: chargePoint.pricingMode,
|
||||||
connectorNumber: connector.connectorId,
|
connectorNumber: connector.connectorId,
|
||||||
@@ -198,6 +226,7 @@ app.get("/", async (c) => {
|
|||||||
return {
|
return {
|
||||||
...r.transaction,
|
...r.transaction,
|
||||||
chargePointIdentifier: r.chargePointIdentifier,
|
chargePointIdentifier: r.chargePointIdentifier,
|
||||||
|
chargePointDeviceName: r.chargePointDeviceName,
|
||||||
connectorNumber: r.connectorNumber,
|
connectorNumber: r.connectorNumber,
|
||||||
idTagUserId: r.idTagUserId,
|
idTagUserId: r.idTagUserId,
|
||||||
idTagUserName: r.idTagUserName,
|
idTagUserName: r.idTagUserName,
|
||||||
@@ -224,30 +253,74 @@ app.get("/:id", async (c) => {
|
|||||||
.select({
|
.select({
|
||||||
transaction,
|
transaction,
|
||||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||||
|
chargePointDeviceName: chargePoint.deviceName,
|
||||||
|
connectorNumber: connector.connectorId,
|
||||||
|
feePerKwh: chargePoint.feePerKwh,
|
||||||
|
pricingMode: chargePoint.pricingMode,
|
||||||
})
|
})
|
||||||
.from(transaction)
|
.from(transaction)
|
||||||
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
||||||
|
.leftJoin(connector, eq(transaction.connectorId, connector.id))
|
||||||
.where(eq(transaction.id, id))
|
.where(eq(transaction.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
let liveEnergyWh: number | null = null;
|
||||||
|
let estimatedCost: number | null = null;
|
||||||
|
|
||||||
|
// For active transactions, return live estimated energy/cost like the list endpoint.
|
||||||
|
if (!row.transaction.stopTimestamp) {
|
||||||
|
const latestRows = await db.execute<{
|
||||||
|
sampled_values: SampledValue[];
|
||||||
|
}>(sql`
|
||||||
|
SELECT sampled_values
|
||||||
|
FROM meter_value
|
||||||
|
WHERE transaction_id = ${id}
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const latest = latestRows.rows[0];
|
||||||
|
if (latest) {
|
||||||
|
const svList = latest.sampled_values as SampledValue[];
|
||||||
|
const energySv = svList.find(
|
||||||
|
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (energySv != null) {
|
||||||
|
const raw = parseFloat(energySv.value);
|
||||||
|
if (!Number.isNaN(raw) && row.transaction.startMeterValue != null) {
|
||||||
|
const latestMeterWh = energySv.unit === "kWh" ? raw * 1000 : raw;
|
||||||
|
liveEnergyWh = latestMeterWh - row.transaction.startMeterValue;
|
||||||
|
|
||||||
|
if (liveEnergyWh > 0 && row.pricingMode === "fixed" && (row.feePerKwh ?? 0) > 0) {
|
||||||
|
estimatedCost = Math.ceil((liveEnergyWh * (row.feePerKwh ?? 0)) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
...row.transaction,
|
...row.transaction,
|
||||||
chargePointIdentifier: row.chargePointIdentifier,
|
chargePointIdentifier: row.chargePointIdentifier,
|
||||||
|
chargePointDeviceName: row.chargePointDeviceName,
|
||||||
|
connectorNumber: row.connectorNumber,
|
||||||
energyWh:
|
energyWh:
|
||||||
row.transaction.stopMeterValue != null
|
row.transaction.stopMeterValue != null
|
||||||
? row.transaction.stopMeterValue - row.transaction.startMeterValue
|
? row.transaction.stopMeterValue - row.transaction.startMeterValue
|
||||||
: null,
|
: null,
|
||||||
|
liveEnergyWh,
|
||||||
|
estimatedCost,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/transactions/:id/stop
|
* POST /api/transactions/:id/stop
|
||||||
* Manually stop an active transaction.
|
* Manually stop an active transaction.
|
||||||
* 1. If the charge point is connected, send OCPP RemoteStopTransaction.
|
* 1. If the charge point is connected, send OCPP RemoteStopTransaction and wait for confirmation.
|
||||||
* 2. In either case (online or offline), settle the transaction in the DB immediately
|
* 2. Record the stop request state in DB; final settlement still happens on StopTransaction.
|
||||||
* so the record is always finalised from the admin side.
|
|
||||||
*/
|
*/
|
||||||
app.post("/:id/stop", async (c) => {
|
app.post("/:id/stop", async (c) => {
|
||||||
const db = useDrizzle();
|
const db = useDrizzle();
|
||||||
@@ -258,7 +331,6 @@ app.post("/:id/stop", async (c) => {
|
|||||||
.select({
|
.select({
|
||||||
transaction,
|
transaction,
|
||||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||||
feePerKwh: chargePoint.feePerKwh,
|
|
||||||
})
|
})
|
||||||
.from(transaction)
|
.from(transaction)
|
||||||
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
||||||
@@ -270,55 +342,74 @@ app.post("/:id/stop", async (c) => {
|
|||||||
|
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
|
|
||||||
// Try to send RemoteStopTransaction via OCPP if the charge point is online
|
let remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" = "Requested";
|
||||||
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
|
let remoteStopRequestId: string | null = null;
|
||||||
if (ws) {
|
let online = false;
|
||||||
const uniqueId = crypto.randomUUID();
|
|
||||||
ws.send(
|
if (row.chargePointIdentifier) {
|
||||||
JSON.stringify([
|
remoteStopRequestId = crypto.randomUUID();
|
||||||
OCPP_MESSAGE_TYPE.CALL,
|
try {
|
||||||
uniqueId,
|
const response = await sendOcppCall<{ transactionId: number }, { status?: string }>(
|
||||||
|
row.chargePointIdentifier,
|
||||||
"RemoteStopTransaction",
|
"RemoteStopTransaction",
|
||||||
{ transactionId: row.transaction.id },
|
{ transactionId: row.transaction.id },
|
||||||
]),
|
{ uniqueId: remoteStopRequestId },
|
||||||
);
|
)
|
||||||
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
|
online = true;
|
||||||
|
remoteStopStatus = response?.status === "Accepted" ? "Accepted" : "Rejected";
|
||||||
|
console.log(`[OCPP] RemoteStopTransaction txId=${id} status=${response?.status ?? "unknown"} to ${row.chargePointIdentifier}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "CommandSendFailed";
|
||||||
|
remoteStopStatus = message === "CommandTimeout" ? "Timeout" : "Error";
|
||||||
|
online = message !== "TransportUnavailable";
|
||||||
|
console.warn(`[OCPP] RemoteStopTransaction txId=${id} failed for ${row.chargePointIdentifier}: ${message}`);
|
||||||
|
if (message === "TransportUnavailable") {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: "ChargePoint command channel is unavailable",
|
||||||
|
online: false,
|
||||||
|
remoteStopStatus,
|
||||||
|
},
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (message === "CommandTimeout") {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: "ChargePoint did not confirm RemoteStopTransaction in time",
|
||||||
|
online: true,
|
||||||
|
remoteStopStatus,
|
||||||
|
},
|
||||||
|
504,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: `RemoteStopTransaction failed: ${message}`,
|
||||||
|
online,
|
||||||
|
remoteStopStatus,
|
||||||
|
},
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settle in DB regardless (charge point may be offline or slow to respond)
|
|
||||||
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
|
|
||||||
const stopMeterValue = row.transaction.startMeterValue;
|
|
||||||
const energyWh = 0; // cannot know actual energy without stop meter value
|
|
||||||
const feePerKwh = row.feePerKwh ?? 0;
|
|
||||||
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(transaction)
|
.update(transaction)
|
||||||
.set({
|
.set({
|
||||||
stopTimestamp: now.toDate(),
|
remoteStopRequestedAt: now.toDate(),
|
||||||
stopMeterValue,
|
remoteStopRequestId,
|
||||||
stopReason: "Remote",
|
remoteStopStatus,
|
||||||
chargeAmount: feeFen,
|
|
||||||
updatedAt: now.toDate(),
|
updatedAt: now.toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(transaction.id, id))
|
.where(eq(transaction.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (feeFen > 0) {
|
|
||||||
await db
|
|
||||||
.update(idTag)
|
|
||||||
.set({
|
|
||||||
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
|
|
||||||
updatedAt: now.toDate(),
|
|
||||||
})
|
|
||||||
.where(eq(idTag.idTag, row.transaction.idTag));
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
...updated,
|
...updated,
|
||||||
chargePointIdentifier: row.chargePointIdentifier,
|
chargePointIdentifier: row.chargePointIdentifier,
|
||||||
online: !!ws,
|
online,
|
||||||
energyWh,
|
remoteStopStatus,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Table,
|
Table,
|
||||||
TextField,
|
TextField,
|
||||||
|
Tooltip,
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
|
import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons";
|
||||||
import { api } from "@/lib/api";
|
import { api, type ChargePointPasswordReset } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import dayjs from "@/lib/dayjs";
|
import dayjs from "@/lib/dayjs";
|
||||||
|
import InfoSection from "@/components/info-section";
|
||||||
import { Plug } from "lucide-react";
|
import { Plug } from "lucide-react";
|
||||||
|
|
||||||
// ── Status maps ────────────────────────────────────────────────────────────
|
// ── Status maps ────────────────────────────────────────────────────────────
|
||||||
@@ -76,6 +78,7 @@ function relativeTime(iso: string): string {
|
|||||||
// ── Edit form type ─────────────────────────────────────────────────────────
|
// ── Edit form type ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type EditForm = {
|
type EditForm = {
|
||||||
|
deviceName: string;
|
||||||
chargePointVendor: string;
|
chargePointVendor: string;
|
||||||
chargePointModel: string;
|
chargePointModel: string;
|
||||||
registrationStatus: "Accepted" | "Pending" | "Rejected";
|
registrationStatus: "Accepted" | "Pending" | "Rejected";
|
||||||
@@ -95,6 +98,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editBusy, setEditBusy] = useState(false);
|
const [editBusy, setEditBusy] = useState(false);
|
||||||
const [editForm, setEditForm] = useState<EditForm>({
|
const [editForm, setEditForm] = useState<EditForm>({
|
||||||
|
deviceName: "",
|
||||||
chargePointVendor: "",
|
chargePointVendor: "",
|
||||||
chargePointModel: "",
|
chargePointModel: "",
|
||||||
registrationStatus: "Pending",
|
registrationStatus: "Pending",
|
||||||
@@ -102,6 +106,29 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
feePerKwh: "0",
|
feePerKwh: "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// reset password
|
||||||
|
const [resetBusy, setResetBusy] = useState(false);
|
||||||
|
const [resetResult, setResetResult] = useState<ChargePointPasswordReset | null>(null);
|
||||||
|
const [resetCopied, setResetCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleResetPassword = async () => {
|
||||||
|
if (!cp) return;
|
||||||
|
setResetBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await api.chargePoints.resetPassword(cp.id);
|
||||||
|
setResetResult(result);
|
||||||
|
} finally {
|
||||||
|
setResetBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyResetPassword = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setResetCopied(true);
|
||||||
|
setTimeout(() => setResetCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const { isFetching: refreshing, ...cpQuery } = useQuery({
|
const { isFetching: refreshing, ...cpQuery } = useQuery({
|
||||||
queryKey: ["chargePoint", id],
|
queryKey: ["chargePoint", id],
|
||||||
queryFn: () => api.chargePoints.get(id),
|
queryFn: () => api.chargePoints.get(id),
|
||||||
@@ -121,6 +148,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
const openEdit = () => {
|
const openEdit = () => {
|
||||||
if (!cp) return;
|
if (!cp) return;
|
||||||
setEditForm({
|
setEditForm({
|
||||||
|
deviceName: cp.deviceName ?? "",
|
||||||
chargePointVendor: cp.chargePointVendor ?? "",
|
chargePointVendor: cp.chargePointVendor ?? "",
|
||||||
chargePointModel: cp.chargePointModel ?? "",
|
chargePointModel: cp.chargePointModel ?? "",
|
||||||
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
|
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
|
||||||
@@ -141,6 +169,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
registrationStatus: editForm.registrationStatus,
|
registrationStatus: editForm.registrationStatus,
|
||||||
pricingMode: editForm.pricingMode,
|
pricingMode: editForm.pricingMode,
|
||||||
feePerKwh: editForm.pricingMode === "fixed" ? fee : 0,
|
feePerKwh: editForm.pricingMode === "fixed" ? fee : 0,
|
||||||
|
deviceName: editForm.deviceName.trim() || null,
|
||||||
});
|
});
|
||||||
await cpQuery.refetch();
|
await cpQuery.refetch();
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
@@ -151,8 +180,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
|
|
||||||
// Online if last heartbeat within 3× interval
|
// Online if last heartbeat within 3× interval
|
||||||
const isOnline =
|
const isOnline =
|
||||||
|
cp?.transportStatus === "online" &&
|
||||||
cp?.lastHeartbeatAt != null &&
|
cp?.lastHeartbeatAt != null &&
|
||||||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
|
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
|
||||||
|
const commandChannelUnavailable = cp?.transportStatus === "unavailable";
|
||||||
|
const statusLabel = isOnline ? "在线" : commandChannelUnavailable ? "通道异常" : "离线";
|
||||||
|
const statusDotClass = isOnline
|
||||||
|
? "bg-success animate-pulse"
|
||||||
|
: commandChannelUnavailable
|
||||||
|
? "bg-warning"
|
||||||
|
: "bg-muted";
|
||||||
|
|
||||||
const { data: sessionData } = useSession();
|
const { data: sessionData } = useSession();
|
||||||
const isAdmin = sessionData?.user?.role === "admin";
|
const isAdmin = sessionData?.user?.role === "admin";
|
||||||
@@ -201,9 +238,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="font-mono text-2xl font-semibold text-foreground">
|
<h1 className="text-2xl font-semibold text-foreground">
|
||||||
{cp.chargePointIdentifier}
|
{cp.deviceName ?? <span className="font-mono">{cp.chargePointIdentifier}</span>}
|
||||||
</h1>
|
</h1>
|
||||||
|
{isAdmin && cp.deviceName && (
|
||||||
|
<span className="font-mono text-sm text-muted">{cp.chargePointIdentifier}</span>
|
||||||
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
|
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -213,9 +253,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
</Chip>
|
</Chip>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
|
className={`size-2 rounded-full ${statusDotClass}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span>
|
<span className="text-xs text-muted">{statusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(cp.chargePointVendor || cp.chargePointModel) && (
|
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||||
@@ -229,10 +269,21 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button size="sm" variant="secondary" onPress={openEdit}>
|
<>
|
||||||
<Pencil className="size-4" />
|
<Tooltip>
|
||||||
编辑
|
<Tooltip.Content>重置 OCPP 认证密钥</Tooltip.Content>
|
||||||
</Button>
|
<Tooltip.Trigger>
|
||||||
|
<Button size="sm" variant="ghost" isDisabled={resetBusy} onPress={handleResetPassword}>
|
||||||
|
{resetBusy ? <Spinner size="sm" /> : <Key className="size-4" />}
|
||||||
|
重置密钥
|
||||||
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
</Tooltip>
|
||||||
|
<Button size="sm" variant="secondary" onPress={openEdit}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,8 +321,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* Device info — admin only */}
|
{/* Device info — admin only */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="rounded-xl border border-border bg-surface p-4">
|
<InfoSection title="设备信息">
|
||||||
<h2 className="mb-3 text-sm font-semibold text-foreground">设备信息</h2>
|
|
||||||
<dl className="divide-y divide-border">
|
<dl className="divide-y divide-border">
|
||||||
{[
|
{[
|
||||||
{ label: "品牌", value: cp.chargePointVendor },
|
{ label: "品牌", value: cp.chargePointVendor },
|
||||||
@@ -291,13 +341,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</InfoSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Operation info — admin only */}
|
{/* Operation info — admin only */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="rounded-xl border border-border bg-surface p-4">
|
<InfoSection title="运行配置">
|
||||||
<h2 className="mb-3 text-sm font-semibold text-foreground">运行配置</h2>
|
|
||||||
<dl className="divide-y divide-border">
|
<dl className="divide-y divide-border">
|
||||||
<div className="flex items-center justify-between gap-4 py-2">
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
<dt className="shrink-0 text-sm text-muted">注册状态</dt>
|
<dt className="shrink-0 text-sm text-muted">注册状态</dt>
|
||||||
@@ -369,13 +418,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</InfoSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fee info — user only */}
|
{/* Fee info — user only */}
|
||||||
{!isAdmin && (
|
{!isAdmin && (
|
||||||
<div className="rounded-xl border border-border bg-surface p-4">
|
<InfoSection title="电价信息">
|
||||||
<h2 className="mb-3 text-sm font-semibold text-foreground">电价信息</h2>
|
|
||||||
<dl className="divide-y divide-border">
|
<dl className="divide-y divide-border">
|
||||||
<div className="flex items-center justify-between gap-4 py-2">
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
<dt className="shrink-0 text-sm text-muted">单位电价</dt>
|
<dt className="shrink-0 text-sm text-muted">单位电价</dt>
|
||||||
@@ -397,14 +445,14 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
<dd>
|
<dd>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
|
className={`size-2 rounded-full ${statusDotClass}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
|
<span className="text-sm text-foreground">{statusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</InfoSection>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -547,6 +595,57 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reset password result modal */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Modal
|
||||||
|
isOpen={resetResult !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) { setResetResult(null); setResetCopied(false); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal.Backdrop>
|
||||||
|
<Modal.Container scroll="outside">
|
||||||
|
<Modal.Dialog className="sm:max-w-md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Heading>密钥已重置</Modal.Heading>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body className="space-y-4">
|
||||||
|
<p className="text-sm text-warning font-medium">
|
||||||
|
此密钥仅显示一次。旧密钥已立即失效,请更新固件配置。
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-muted font-medium">新 OCPP Basic Auth 密钥</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 rounded-md bg-surface-secondary px-3 py-2 text-sm font-mono text-foreground select-all">
|
||||||
|
{resetResult?.plainPassword}
|
||||||
|
</code>
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Content>{resetCopied ? "已复制" : "复制密钥"}</Tooltip.Content>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => resetResult && handleCopyResetPassword(resetResult.plainPassword)}
|
||||||
|
>
|
||||||
|
{resetCopied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer className="flex justify-end">
|
||||||
|
<Button onPress={() => { setResetResult(null); setResetCopied(false); }}>
|
||||||
|
我已保存密钥
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Dialog>
|
||||||
|
</Modal.Container>
|
||||||
|
</Modal.Backdrop>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Edit modal */}
|
{/* Edit modal */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -563,6 +662,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
<Modal.Heading>编辑充电桩</Modal.Heading>
|
<Modal.Heading>编辑充电桩</Modal.Heading>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="space-y-3">
|
<Modal.Body className="space-y-3">
|
||||||
|
<TextField fullWidth>
|
||||||
|
<Label className="text-sm font-medium">设备名称</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="1号楼A区01号桩"
|
||||||
|
value={editForm.deviceName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((f) => ({ ...f, deviceName: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<TextField fullWidth>
|
<TextField fullWidth>
|
||||||
<Label className="text-sm font-medium">品牌</Label>
|
<Label className="text-sm font-medium">品牌</Label>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Input,
|
Input,
|
||||||
|
InputGroup,
|
||||||
Label,
|
Label,
|
||||||
ListBox,
|
ListBox,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -22,11 +23,13 @@ import {
|
|||||||
TrashBin,
|
TrashBin,
|
||||||
ArrowRotateRight,
|
ArrowRotateRight,
|
||||||
QrCode,
|
QrCode,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
} from "@gravity-ui/icons";
|
} from "@gravity-ui/icons";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ScrollFade } from "@/components/scroll-fade";
|
import { ScrollFade } from "@/components/scroll-fade";
|
||||||
import { api, type ChargePoint } from "@/lib/api";
|
import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import dayjs from "@/lib/dayjs";
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
@@ -97,6 +100,7 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
|
|||||||
|
|
||||||
type FormData = {
|
type FormData = {
|
||||||
chargePointIdentifier: string;
|
chargePointIdentifier: string;
|
||||||
|
deviceName: string;
|
||||||
chargePointVendor: string;
|
chargePointVendor: string;
|
||||||
chargePointModel: string;
|
chargePointModel: string;
|
||||||
registrationStatus: "Accepted" | "Pending" | "Rejected";
|
registrationStatus: "Accepted" | "Pending" | "Rejected";
|
||||||
@@ -106,6 +110,7 @@ type FormData = {
|
|||||||
|
|
||||||
const EMPTY_FORM: FormData = {
|
const EMPTY_FORM: FormData = {
|
||||||
chargePointIdentifier: "",
|
chargePointIdentifier: "",
|
||||||
|
deviceName: "",
|
||||||
chargePointVendor: "",
|
chargePointVendor: "",
|
||||||
chargePointModel: "",
|
chargePointModel: "",
|
||||||
registrationStatus: "Pending",
|
registrationStatus: "Pending",
|
||||||
@@ -121,6 +126,8 @@ export default function ChargePointsPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
|
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
|
||||||
|
const [createdCp, setCreatedCp] = useState<ChargePointCreated | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const {
|
const {
|
||||||
data: chargePoints = [],
|
data: chargePoints = [],
|
||||||
refetch: refetchList,
|
refetch: refetchList,
|
||||||
@@ -141,6 +148,7 @@ export default function ChargePointsPage() {
|
|||||||
setFormTarget(cp);
|
setFormTarget(cp);
|
||||||
setFormData({
|
setFormData({
|
||||||
chargePointIdentifier: cp.chargePointIdentifier,
|
chargePointIdentifier: cp.chargePointIdentifier,
|
||||||
|
deviceName: cp.deviceName ?? "",
|
||||||
chargePointVendor: cp.chargePointVendor ?? "",
|
chargePointVendor: cp.chargePointVendor ?? "",
|
||||||
chargePointModel: cp.chargePointModel ?? "",
|
chargePointModel: cp.chargePointModel ?? "",
|
||||||
registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
|
registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
|
||||||
@@ -163,20 +171,25 @@ export default function ChargePointsPage() {
|
|||||||
registrationStatus: formData.registrationStatus,
|
registrationStatus: formData.registrationStatus,
|
||||||
pricingMode: formData.pricingMode,
|
pricingMode: formData.pricingMode,
|
||||||
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
||||||
|
deviceName: formData.deviceName.trim() || null,
|
||||||
});
|
});
|
||||||
|
await refetchList();
|
||||||
|
setFormOpen(false);
|
||||||
} else {
|
} else {
|
||||||
// Create
|
// Create — capture plainPassword for one-time display
|
||||||
await api.chargePoints.create({
|
const created = await api.chargePoints.create({
|
||||||
chargePointIdentifier: formData.chargePointIdentifier.trim(),
|
chargePointIdentifier: formData.chargePointIdentifier.trim(),
|
||||||
chargePointVendor: formData.chargePointVendor.trim() || undefined,
|
chargePointVendor: formData.chargePointVendor.trim() || undefined,
|
||||||
chargePointModel: formData.chargePointModel.trim() || undefined,
|
chargePointModel: formData.chargePointModel.trim() || undefined,
|
||||||
registrationStatus: formData.registrationStatus,
|
registrationStatus: formData.registrationStatus,
|
||||||
pricingMode: formData.pricingMode,
|
pricingMode: formData.pricingMode,
|
||||||
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
||||||
|
deviceName: formData.deviceName.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
await refetchList();
|
||||||
|
setFormOpen(false);
|
||||||
|
setCreatedCp(created);
|
||||||
}
|
}
|
||||||
await refetchList();
|
|
||||||
setFormOpen(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
setFormBusy(false);
|
setFormBusy(false);
|
||||||
}
|
}
|
||||||
@@ -196,6 +209,13 @@ export default function ChargePointsPage() {
|
|||||||
|
|
||||||
const isEdit = formTarget !== null;
|
const isEdit = formTarget !== null;
|
||||||
|
|
||||||
|
const handleCopyPassword = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const { data: sessionData } = useSession();
|
const { data: sessionData } = useSession();
|
||||||
const isAdmin = sessionData?.user?.role === "admin";
|
const isAdmin = sessionData?.user?.role === "admin";
|
||||||
|
|
||||||
@@ -258,8 +278,16 @@ export default function ChargePointsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<TextField fullWidth>
|
||||||
|
<Label className="text-sm font-medium">设备名称</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="1号楼A区01号桩"
|
||||||
|
value={formData.deviceName}
|
||||||
|
onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<TextField fullWidth>
|
<TextField fullWidth isReadOnly={isEdit}>
|
||||||
<Label className="text-sm font-medium">品牌</Label>
|
<Label className="text-sm font-medium">品牌</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="ABB"
|
placeholder="ABB"
|
||||||
@@ -269,7 +297,7 @@ export default function ChargePointsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField fullWidth>
|
<TextField fullWidth isReadOnly={isEdit}>
|
||||||
<Label className="text-sm font-medium">型号</Label>
|
<Label className="text-sm font-medium">型号</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Terra AC"
|
placeholder="Terra AC"
|
||||||
@@ -381,16 +409,18 @@ export default function ChargePointsPage() {
|
|||||||
<Modal.Dialog className="sm:max-w-lg">
|
<Modal.Dialog className="sm:max-w-lg">
|
||||||
<Modal.CloseTrigger />
|
<Modal.CloseTrigger />
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Heading>{qrTarget?.chargePointIdentifier} — 充电二维码</Modal.Heading>
|
<Modal.Heading>
|
||||||
|
{qrTarget?.deviceName ?? qrTarget?.chargePointIdentifier} 固定二维码
|
||||||
|
</Modal.Heading>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="space-y-4">
|
<Modal.Body className="space-y-4">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
将以下二维码张贴在对应充电口上,用户扫码后可直接选卡启动充电。
|
将二维码张贴在对应充电口上,用户扫码后可直接选卡启动充电。
|
||||||
</p>
|
</p>
|
||||||
{qrTarget &&
|
{qrTarget &&
|
||||||
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
|
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
该充电桩暂无接口信息,请等待设备上线后再尝试。
|
该充电桩暂无接口信息,请等待设备首次上线后再尝试。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3">
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3">
|
||||||
@@ -398,7 +428,7 @@ export default function ChargePointsPage() {
|
|||||||
.filter((c) => c.connectorId > 0)
|
.filter((c) => c.connectorId > 0)
|
||||||
.sort((a, b) => a.connectorId - b.connectorId)
|
.sort((a, b) => a.connectorId - b.connectorId)
|
||||||
.map((conn) => {
|
.map((conn) => {
|
||||||
const url = `${qrOrigin}/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
|
const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={conn.id}
|
key={conn.id}
|
||||||
@@ -427,11 +457,100 @@ export default function ChargePointsPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* OCPP Password Modal — shown once after creation */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Modal
|
||||||
|
isOpen={createdCp !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCreatedCp(null);
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal.Backdrop>
|
||||||
|
<Modal.Container scroll="outside">
|
||||||
|
<Modal.Dialog className="sm:max-w-md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Heading>充电桩已创建</Modal.Heading>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body className="space-y-4">
|
||||||
|
<p className="text-sm text-warning font-medium">
|
||||||
|
此认证密钥只显示一次,请立即烧录或妥善保存
|
||||||
|
</p>
|
||||||
|
<TextField fullWidth isReadOnly>
|
||||||
|
<Label className="text-sm font-medium">充电桩标识符</Label>
|
||||||
|
<Input value={createdCp?.chargePointIdentifier ?? ""} className="font-mono" />
|
||||||
|
</TextField>
|
||||||
|
<TextField fullWidth isReadOnly>
|
||||||
|
<Label className="text-sm font-medium">OCPP Basic Auth 密钥</Label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroup.Input
|
||||||
|
value={createdCp?.plainPassword ?? ""}
|
||||||
|
className="font-mono select-all"
|
||||||
|
/>
|
||||||
|
<InputGroup.Suffix>
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Content>{copied ? "已复制" : "复制密钥"}</Tooltip.Content>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() =>
|
||||||
|
createdCp && handleCopyPassword(createdCp.plainPassword)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
</Tooltip>
|
||||||
|
</InputGroup.Suffix>
|
||||||
|
</InputGroup>
|
||||||
|
</TextField>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<TextField fullWidth isReadOnly>
|
||||||
|
<Label className="text-sm font-medium">固件 WebSocket 连接地址</Label>
|
||||||
|
<Input
|
||||||
|
value={`wss://<your-server>/ocpp/${createdCp?.chargePointIdentifier ?? ""}`}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
设备连接时需设置 HTTP 头:
|
||||||
|
<br />
|
||||||
|
<code className="text-foreground">
|
||||||
|
Authorization: Basic <base64({createdCp?.chargePointIdentifier}
|
||||||
|
:<password>)>
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setCreatedCp(null);
|
||||||
|
setCopied(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
我已保存密钥
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Dialog>
|
||||||
|
</Modal.Container>
|
||||||
|
</Modal.Backdrop>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
<Table.ScrollContainer>
|
<Table.ScrollContainer>
|
||||||
<Table.Content aria-label="充电桩列表" className="min-w-200">
|
<Table.Content aria-label="充电桩列表" className="min-w-200">
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Column isRowHeader>标识符</Table.Column>
|
<Table.Column isRowHeader>设备名称</Table.Column>
|
||||||
{isAdmin && <Table.Column>品牌 / 型号</Table.Column>}
|
{isAdmin && <Table.Column>品牌 / 型号</Table.Column>}
|
||||||
{isAdmin && <Table.Column>注册状态</Table.Column>}
|
{isAdmin && <Table.Column>注册状态</Table.Column>}
|
||||||
<Table.Column>计费模式</Table.Column>
|
<Table.Column>计费模式</Table.Column>
|
||||||
@@ -455,34 +574,41 @@ export default function ChargePointsPage() {
|
|||||||
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
)}
|
)}
|
||||||
{chargePoints.map((cp) => (
|
{chargePoints.map((cp) => {
|
||||||
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
|
const online =
|
||||||
|
cp.transportStatus === "online" &&
|
||||||
|
!!cp.lastHeartbeatAt &&
|
||||||
|
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||||
|
const commandChannelUnavailable = cp.transportStatus === "unavailable";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Tooltip delay={0}>
|
<Tooltip delay={0}>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`size-2 shrink-0 rounded-full ${
|
className={`size-2 shrink-0 rounded-full ${
|
||||||
cp.lastHeartbeatAt &&
|
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-gray-300"
|
||||||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
|
|
||||||
? "bg-success"
|
|
||||||
: "bg-gray-300"
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<Link
|
<div className="flex flex-col">
|
||||||
href={`/dashboard/charge-points/${cp.id}`}
|
<Link
|
||||||
className="font-medium text-accent"
|
href={`/dashboard/charge-points/${cp.id}`}
|
||||||
>
|
className="font-medium text-accent"
|
||||||
{cp.chargePointIdentifier}
|
>
|
||||||
</Link>
|
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||||
|
</Link>
|
||||||
|
{isAdmin && cp.deviceName && (
|
||||||
|
<span className="font-mono text-xs text-muted">
|
||||||
|
{cp.chargePointIdentifier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content placement="start">
|
<Tooltip.Content placement="start">
|
||||||
{cp.lastHeartbeatAt
|
{online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"}
|
||||||
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
|
|
||||||
? "在线"
|
|
||||||
: "离线"
|
|
||||||
: "从未连接"}
|
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -594,9 +720,14 @@ export default function ChargePointsPage() {
|
|||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
将删除充电桩{" "}
|
将删除充电桩{" "}
|
||||||
<span className="font-mono font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{cp.chargePointIdentifier}
|
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||||
</span>
|
</span>
|
||||||
|
{cp.deviceName && (
|
||||||
|
<span className="font-mono ml-1 text-xs text-muted">
|
||||||
|
({cp.chargePointIdentifier})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
及其所有连接器和充电记录,此操作不可恢复。
|
及其所有连接器和充电记录,此操作不可恢复。
|
||||||
</p>
|
</p>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
@@ -620,8 +751,9 @@ export default function ChargePointsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
)}
|
)}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Content>
|
</Table.Content>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, Fragment, Suspense } from "react";
|
import { useState, useEffect, useRef, Fragment, Suspense, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { Button, Modal, Spinner } from "@heroui/react";
|
import { Alert, AlertDialog, Button, Modal, Spinner } from "@heroui/react";
|
||||||
import { ThunderboltFill, CreditCard, Check, QrCode, Xmark } from "@gravity-ui/icons";
|
import { ThunderboltFill, CreditCard, Check, QrCode, Xmark } from "@gravity-ui/icons";
|
||||||
import jsQR from "jsqr";
|
import jsQR from "jsqr";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
import dayjs from "@/lib/dayjs";
|
import dayjs from "@/lib/dayjs";
|
||||||
import { EvCharger, Plug } from "lucide-react";
|
import { BanknoteArrowUp, EvCharger, Plug } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { IdTagCard } from "@/components/id-tag-card";
|
import { IdTagCard } from "@/components/id-tag-card";
|
||||||
import router from "next/router";
|
|
||||||
|
|
||||||
// ── Status maps (same as charge-points page) ────────────────────────────────
|
// ── Status maps (same as charge-points page) ────────────────────────────────
|
||||||
|
|
||||||
@@ -221,6 +221,8 @@ function QrScanner({ onResult, onClose }: ScannerProps) {
|
|||||||
|
|
||||||
function ChargePageContent() {
|
function ChargePageContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { data: sessionData } = useSession();
|
||||||
|
const isAdmin = sessionData?.user?.role === "admin";
|
||||||
|
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
|
const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
|
||||||
@@ -230,6 +232,13 @@ function ChargePageContent() {
|
|||||||
const [scanError, setScanError] = useState<string | null>(null);
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
const [startResult, setStartResult] = useState<"success" | "error" | null>(null);
|
const [startResult, setStartResult] = useState<"success" | "error" | null>(null);
|
||||||
const [startError, setStartError] = useState<string | null>(null);
|
const [startError, setStartError] = useState<string | null>(null);
|
||||||
|
const [startSnapshot, setStartSnapshot] = useState<{
|
||||||
|
cpId: string;
|
||||||
|
chargePointIdentifier: string;
|
||||||
|
deviceName: string | null;
|
||||||
|
connectorId: number;
|
||||||
|
idTag: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Detect mobile
|
// Detect mobile
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
@@ -263,8 +272,67 @@ function ChargePageContent() {
|
|||||||
queryFn: () => api.idTags.list().catch(() => []),
|
queryFn: () => api.idTags.list().catch(() => []),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: activeTransactions = [] } = useQuery({
|
||||||
|
queryKey: ["transactions", "active", "idTagLock"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.transactions.list({ page: 1, limit: 200, status: "active" });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 3_000,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedCp = chargePoints.find((cp) => cp.id === selectedCpId) ?? null;
|
const selectedCp = chargePoints.find((cp) => cp.id === selectedCpId) ?? null;
|
||||||
const myTags = idTags?.filter((t) => t.status === "Accepted") ?? [];
|
const myTags = idTags?.filter((t) => t.status === "Accepted") ?? [];
|
||||||
|
const activeTransactionsForCurrentUser = useMemo(() => {
|
||||||
|
const currentUserId = sessionData?.user?.id;
|
||||||
|
if (!currentUserId) return activeTransactions;
|
||||||
|
return activeTransactions.filter((tx) => tx.idTagUserId === currentUserId);
|
||||||
|
}, [activeTransactions, sessionData?.user?.id]);
|
||||||
|
|
||||||
|
const activeCount = activeTransactionsForCurrentUser.length;
|
||||||
|
const activeDetailHref =
|
||||||
|
activeCount === 1
|
||||||
|
? `/dashboard/transactions/${activeTransactionsForCurrentUser[0].id}`
|
||||||
|
: "/dashboard/transactions?status=active";
|
||||||
|
const activeIdTagSet = useMemo(
|
||||||
|
() => new Set(activeTransactionsForCurrentUser.map((tx) => tx.idTag)),
|
||||||
|
[activeTransactionsForCurrentUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startResult !== "success" && selectedIdTag && activeIdTagSet.has(selectedIdTag)) {
|
||||||
|
setSelectedIdTag(null);
|
||||||
|
}
|
||||||
|
}, [selectedIdTag, activeIdTagSet, startResult]);
|
||||||
|
|
||||||
|
const { data: startedTransactionId } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"latestStartedTx",
|
||||||
|
startSnapshot?.cpId,
|
||||||
|
startSnapshot?.connectorId,
|
||||||
|
startSnapshot?.idTag,
|
||||||
|
startResult,
|
||||||
|
],
|
||||||
|
enabled: startResult === "success" && !!startSnapshot?.cpId && !!startSnapshot?.idTag,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!startSnapshot?.cpId || !startSnapshot?.idTag) return null;
|
||||||
|
const res = await api.transactions.list({
|
||||||
|
page: 1,
|
||||||
|
limit: 30,
|
||||||
|
status: "active",
|
||||||
|
chargePointId: startSnapshot.cpId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matched = res.data.find(
|
||||||
|
(tx) =>
|
||||||
|
tx.idTag === startSnapshot.idTag && tx.connectorNumber === startSnapshot.connectorId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return matched?.id ?? null;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => (query.state.data ? false : 1_500),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -277,28 +345,49 @@ function ChargePageContent() {
|
|||||||
idTag: selectedIdTag,
|
idTag: selectedIdTag,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onMutate: () => {
|
||||||
|
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) return;
|
||||||
|
setStartSnapshot({
|
||||||
|
cpId: selectedCp.id,
|
||||||
|
chargePointIdentifier: selectedCp.chargePointIdentifier,
|
||||||
|
deviceName: selectedCp.deviceName,
|
||||||
|
connectorId: selectedConnectorId,
|
||||||
|
idTag: selectedIdTag,
|
||||||
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setStartResult("success");
|
setStartResult("success");
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
setStartResult("error");
|
setStartResult("error");
|
||||||
const msg = err.message ?? "";
|
const msg = err.message ?? "";
|
||||||
if (msg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试");
|
const lowerMsg = msg.toLowerCase();
|
||||||
else if (msg.includes("not accepted")) setStartError("充电桩未启用,请联系管理员");
|
|
||||||
else if (msg.includes("idTag")) setStartError("储值卡无效或无权使用");
|
if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
|
||||||
else setStartError("启动失败:" + msg);
|
setStartError("充电桩下行通道不可用,请稍后再试");
|
||||||
|
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
|
||||||
|
setStartError("充电桩未及时确认启动指令,请稍后重试");
|
||||||
|
} else if (
|
||||||
|
lowerMsg.includes("chargepoint is not accepted") ||
|
||||||
|
lowerMsg.includes("not accepted")
|
||||||
|
) {
|
||||||
|
setStartError("充电桩未启用,请联系管理员");
|
||||||
|
} else if (msg.includes("ConcurrentTx") || lowerMsg.includes("concurrent")) {
|
||||||
|
setStartError("该储值卡已有进行中的充电订单,请先结束后再发起");
|
||||||
|
} else if (lowerMsg.includes("rejected: blocked")) {
|
||||||
|
setStartError("储值卡不可用或余额不足,请更换储值卡或先充值");
|
||||||
|
} else if (lowerMsg.includes("rejected: expired")) {
|
||||||
|
setStartError("储值卡已过期,请联系管理员处理");
|
||||||
|
} else if (lowerMsg.includes("rejected: invalid")) {
|
||||||
|
setStartError("储值卡无效,请确认卡号后重试");
|
||||||
|
} else if (lowerMsg.includes("not found or not authorized")) {
|
||||||
|
setStartError("该储值卡无权使用或不存在");
|
||||||
|
} else if (msg.includes("idTag") || lowerMsg.includes("idtag")) {
|
||||||
|
setStartError("储值卡状态不可用,无法启动充电");
|
||||||
|
} else setStartError("启动失败:" + msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function resetAll() {
|
|
||||||
setStep(1);
|
|
||||||
setSelectedCpId(null);
|
|
||||||
setSelectedConnectorId(null);
|
|
||||||
setSelectedIdTag(null);
|
|
||||||
setStartResult(null);
|
|
||||||
setStartError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScanResult(raw: string) {
|
function handleScanResult(raw: string) {
|
||||||
setShowScanner(false);
|
setShowScanner(false);
|
||||||
setScanError(null);
|
setScanError(null);
|
||||||
@@ -334,29 +423,52 @@ function ChargePageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-2xl font-bold text-foreground">正在启动中</h2>
|
<h2 className="text-2xl font-bold text-foreground">已发起充电</h2>
|
||||||
<p className="text-sm text-muted leading-relaxed">充电桩正在响应,稍候将自动开始</p>
|
<p className="text-sm text-muted leading-relaxed">充电桩正在响应,稍候将自动开始</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full max-w-xs rounded-2xl border border-border bg-surface p-4 text-left space-y-2">
|
<div className="w-full max-w-xs rounded-2xl border border-border bg-surface p-4 text-left space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted">充电桩</span>
|
<span className="text-muted">充电桩</span>
|
||||||
<span className="font-medium text-foreground">{selectedCp?.chargePointIdentifier}</span>
|
<span className="font-medium text-foreground">
|
||||||
|
{startSnapshot?.deviceName ?? startSnapshot?.chargePointIdentifier ?? selectedCp?.deviceName ?? selectedCp?.chargePointIdentifier}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted">接口</span>
|
<span className="text-muted">接口</span>
|
||||||
<span className="font-medium text-foreground">#{selectedConnectorId}</span>
|
<span className="font-medium text-foreground">
|
||||||
|
#{startSnapshot?.connectorId ?? selectedConnectorId}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted">储值卡</span>
|
<span className="text-muted">储值卡</span>
|
||||||
<span className="font-mono font-medium text-foreground">{selectedIdTag}</span>
|
<span className="font-mono font-medium text-foreground">
|
||||||
|
{startSnapshot?.idTag ?? selectedIdTag}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/transactions" className="w-full max-w-xs">
|
{startedTransactionId ? (
|
||||||
<Button size="lg" className="w-full">
|
<Link
|
||||||
<ThunderboltFill className="size-4" />
|
href={`/dashboard/transactions/${startedTransactionId}`}
|
||||||
充电记录
|
className="w-full max-w-xs"
|
||||||
</Button>
|
>
|
||||||
</Link>
|
<Button size="lg" className="w-full">
|
||||||
|
<BanknoteArrowUp className="size-4" />
|
||||||
|
查看订单
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="w-full max-w-xs space-y-2">
|
||||||
|
<Link href="/dashboard/transactions?status=active" className="block">
|
||||||
|
<Button size="lg" variant="secondary" className="w-full">
|
||||||
|
<BanknoteArrowUp className="size-4" />
|
||||||
|
查看进行中订单
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-muted text-center inline-flex w-full items-center justify-center gap-1.5">
|
||||||
|
正在生成订单,可先前往列表查看
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -404,12 +516,67 @@ function ChargePageContent() {
|
|||||||
</Modal.Backdrop>
|
</Modal.Backdrop>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
isOpen={startResult === "error" && !!startError}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setStartResult(null);
|
||||||
|
setStartError(null);
|
||||||
|
setStartSnapshot(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialog.Backdrop variant="blur">
|
||||||
|
<AlertDialog.Container size="sm">
|
||||||
|
<AlertDialog.Dialog>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.CloseTrigger />
|
||||||
|
<AlertDialog.Icon status="danger" />
|
||||||
|
<AlertDialog.Heading>启动失败</AlertDialog.Heading>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Body className="overflow-hidden">
|
||||||
|
<p>{startError ?? "启动失败,请稍后重试"}</p>
|
||||||
|
</AlertDialog.Body>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<Button
|
||||||
|
slot="close"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => {
|
||||||
|
setStartResult(null);
|
||||||
|
setStartError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
我知道了
|
||||||
|
</Button>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Dialog>
|
||||||
|
</AlertDialog.Container>
|
||||||
|
</AlertDialog.Backdrop>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{scanError && (
|
{scanError && (
|
||||||
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
|
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
|
||||||
{scanError}
|
{scanError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<Alert status="accent">
|
||||||
|
<Alert.Indicator />
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>当前有 {activeCount} 笔进行中的充电</Alert.Title>
|
||||||
|
<Alert.Description>
|
||||||
|
同一张储值卡无法发起多笔充电订单,若要结束进行中的订单,请{" "}
|
||||||
|
<Link href={activeDetailHref} className="underline">
|
||||||
|
<Button variant="secondary" size="sm" className="px-1.5 h-6 rounded-xl">
|
||||||
|
点击查看
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Step bar */}
|
{/* Step bar */}
|
||||||
<StepBar step={step} onGoBack={(t) => setStep(t)} />
|
<StepBar step={step} onGoBack={(t) => setStep(t)} />
|
||||||
|
|
||||||
@@ -432,7 +599,10 @@ function ChargePageContent() {
|
|||||||
.filter((cp) => cp.registrationStatus === "Accepted")
|
.filter((cp) => cp.registrationStatus === "Accepted")
|
||||||
.map((cp) => {
|
.map((cp) => {
|
||||||
const online =
|
const online =
|
||||||
!!cp.lastHeartbeatAt && dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
cp.transportStatus === "online" &&
|
||||||
|
!!cp.lastHeartbeatAt &&
|
||||||
|
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||||
|
const commandChannelUnavailable = cp.transportStatus === "unavailable";
|
||||||
const availableCount = cp.connectors.filter(
|
const availableCount = cp.connectors.filter(
|
||||||
(c) => c.status === "Available",
|
(c) => c.status === "Available",
|
||||||
).length;
|
).length;
|
||||||
@@ -456,12 +626,17 @@ function ChargePageContent() {
|
|||||||
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
|
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{/* Top row: identifier + status */}
|
{/* Top row: name + status */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<span className="font-semibold text-foreground truncate leading-tight">
|
<span className="font-semibold text-foreground truncate leading-tight">
|
||||||
{cp.chargePointIdentifier}
|
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||||
</span>
|
</span>
|
||||||
|
{isAdmin && cp.deviceName && (
|
||||||
|
<span className="font-mono text-xs text-muted truncate">
|
||||||
|
{cp.chargePointIdentifier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(cp.chargePointVendor || cp.chargePointModel) && (
|
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||||
<span className="text-xs text-muted truncate">
|
<span className="text-xs text-muted truncate">
|
||||||
{[cp.chargePointVendor, cp.chargePointModel]
|
{[cp.chargePointVendor, cp.chargePointModel]
|
||||||
@@ -475,13 +650,17 @@ function ChargePageContent() {
|
|||||||
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
|
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||||
online
|
online
|
||||||
? "bg-success/12 text-success"
|
? "bg-success/12 text-success"
|
||||||
|
: commandChannelUnavailable
|
||||||
|
? "bg-warning/12 text-warning"
|
||||||
: "bg-surface-tertiary text-muted",
|
: "bg-surface-tertiary text-muted",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`size-1.5 rounded-full ${online ? "bg-success" : "bg-muted"}`}
|
className={`size-1.5 rounded-full ${
|
||||||
|
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-muted"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
{online ? "在线" : "离线"}
|
{online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Bottom row: connectors + fee */}
|
{/* Bottom row: connectors + fee */}
|
||||||
@@ -525,7 +704,7 @@ function ChargePageContent() {
|
|||||||
<EvCharger className="size-3.5 text-muted" />
|
<EvCharger className="size-3.5 text-muted" />
|
||||||
<span className="text-muted">充电桩</span>
|
<span className="text-muted">充电桩</span>
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{selectedCp.chargePointIdentifier}
|
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -612,7 +791,7 @@ function ChargePageContent() {
|
|||||||
<EvCharger className="size-3.5 text-muted" />
|
<EvCharger className="size-3.5 text-muted" />
|
||||||
<span className="text-muted">充电桩</span>
|
<span className="text-muted">充电桩</span>
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{selectedCp.chargePointIdentifier}
|
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -645,23 +824,29 @@ function ChargePageContent() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{myTags.map((tag) => (
|
{myTags.map((tag) => {
|
||||||
<IdTagCard
|
const locked = activeIdTagSet.has(tag.idTag);
|
||||||
key={tag.idTag}
|
return (
|
||||||
idTag={tag.idTag}
|
<div key={tag.idTag} className="space-y-2">
|
||||||
balance={tag.balance}
|
<IdTagCard
|
||||||
layout={tag.cardLayout ?? undefined}
|
idTag={tag.idTag}
|
||||||
skin={tag.cardSkin ?? undefined}
|
balance={tag.balance}
|
||||||
isSelected={selectedIdTag === tag.idTag}
|
layout={tag.cardLayout ?? undefined}
|
||||||
onClick={() => setSelectedIdTag(tag.idTag)}
|
skin={tag.cardSkin ?? undefined}
|
||||||
/>
|
isSelected={selectedIdTag === tag.idTag}
|
||||||
))}
|
isDisabled={locked}
|
||||||
</div>
|
onClick={() => {
|
||||||
)}
|
if (!locked) setSelectedIdTag(tag.idTag);
|
||||||
|
}}
|
||||||
{startResult === "error" && (
|
/>
|
||||||
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
|
{locked && (
|
||||||
{startError ?? "启动失败,请重试"}
|
<p className="px-1 text-xs font-medium text-warning">
|
||||||
|
该卡有进行中的订单,暂不可再次启动
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -673,6 +858,7 @@ function ChargePageContent() {
|
|||||||
setStep(2);
|
setStep(2);
|
||||||
setStartResult(null);
|
setStartResult(null);
|
||||||
setStartError(null);
|
setStartError(null);
|
||||||
|
setStartSnapshot(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
上一步
|
上一步
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button, Card, Spinner } from "@heroui/react";
|
import { Button, Spinner } from "@heroui/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Thunderbolt,
|
Thunderbolt,
|
||||||
PlugConnection,
|
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
TagDollar,
|
TagDollar,
|
||||||
Person,
|
|
||||||
ArrowRotateRight,
|
ArrowRotateRight,
|
||||||
TriangleExclamation,
|
TriangleExclamation,
|
||||||
} from "@gravity-ui/icons";
|
} from "@gravity-ui/icons";
|
||||||
@@ -27,6 +25,7 @@ import {
|
|||||||
type ChartDataPoint,
|
type ChartDataPoint,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { BanknoteArrowDown, EvCharger, Plug, Users } from "lucide-react";
|
import { BanknoteArrowDown, EvCharger, Plug, Users } from "lucide-react";
|
||||||
|
import MetricIndicator from "@/components/metric-indicator";
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ function timeAgo(dateStr: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cpOnline(cp: ChargePoint): boolean {
|
function cpOnline(cp: ChargePoint): boolean {
|
||||||
if (!cp.lastHeartbeatAt) return false;
|
if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
|
||||||
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,24 +71,20 @@ function StatCard({
|
|||||||
}) {
|
}) {
|
||||||
const s = colorStyles[color];
|
const s = colorStyles[color];
|
||||||
return (
|
return (
|
||||||
<Card className={`border-t-2 ${s.border}`}>
|
<MetricIndicator
|
||||||
<Card.Content className="flex flex-col gap-3">
|
title={title}
|
||||||
<div className="flex items-start justify-between gap-2">
|
value={value}
|
||||||
<p className="text-sm text-muted">{title}</p>
|
color={s.border}
|
||||||
{Icon && (
|
valueClassName="text-3xl font-bold tabular-nums leading-none text-foreground"
|
||||||
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
|
icon={
|
||||||
<Icon className={`size-4.5 ${s.icon}`} />
|
Icon ? (
|
||||||
</div>
|
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
|
||||||
)}
|
<Icon className={`size-4.5 ${s.icon}`} />
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">{value}</p>
|
|
||||||
{footer && (
|
|
||||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
|
||||||
{footer}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined
|
||||||
</Card.Content>
|
}
|
||||||
</Card>
|
footer={footer}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +285,7 @@ function TrendChart() {
|
|||||||
|
|
||||||
// ── RecentTransactions ────────────────────────────────────────────────────
|
// ── RecentTransactions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
function RecentTransactions({ txns, isAdmin = false }: { txns: Transaction[]; isAdmin?: boolean }) {
|
||||||
if (txns.length === 0) {
|
if (txns.length === 0) {
|
||||||
return <div className="py-8 text-center text-sm text-muted">暂无充电记录</div>;
|
return <div className="py-8 text-center text-sm text-muted">暂无充电记录</div>;
|
||||||
}
|
}
|
||||||
@@ -317,10 +312,15 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
{tx.chargePointIdentifier ?? "—"}
|
{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}
|
||||||
{tx.connectorNumber != null && (
|
{tx.connectorNumber != null && (
|
||||||
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
|
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
|
||||||
|
<span className="ml-1 font-mono text-xs text-muted">
|
||||||
|
({tx.chargePointIdentifier})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
{tx.idTag}
|
{tx.idTag}
|
||||||
@@ -352,7 +352,7 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
|||||||
|
|
||||||
// ── ChargePointStatus ─────────────────────────────────────────────────────
|
// ── ChargePointStatus ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
|
function ChargePointStatus({ cps, isAdmin }: { cps: ChargePoint[]; isAdmin: boolean }) {
|
||||||
if (cps.length === 0) {
|
if (cps.length === 0) {
|
||||||
return <div className="py-8 text-center text-sm text-muted">暂无充电桩</div>;
|
return <div className="py-8 text-center text-sm text-muted">暂无充电桩</div>;
|
||||||
}
|
}
|
||||||
@@ -374,11 +374,16 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
|
|||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
{cp.chargePointIdentifier}
|
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
|
|
||||||
</p>
|
</p>
|
||||||
|
{isAdmin && cp.deviceName && (
|
||||||
|
<p className="font-mono text-xs text-muted">{cp.chargePointIdentifier}</p>
|
||||||
|
)}
|
||||||
|
{!(isAdmin && cp.deviceName) && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
{online ? (
|
{online ? (
|
||||||
@@ -568,12 +573,12 @@ export default function DashboardPage() {
|
|||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Panel title="充电桩状态">
|
<Panel title="充电桩状态">
|
||||||
<ChargePointStatus cps={data?.cps ?? []} />
|
<ChargePointStatus cps={data?.cps ?? []} isAdmin={isAdmin} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<Panel title="最近充电会话">
|
<Panel title="最近充电会话">
|
||||||
<RecentTransactions txns={data?.txns ?? []} />
|
<RecentTransactions txns={data?.txns ?? []} isAdmin={isAdmin} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
161
apps/web/app/dashboard/settings/parameters/page.tsx
Normal file
161
apps/web/app/dashboard/settings/parameters/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Alert, Button, Input, Label, Spinner, TextField, toast } from "@heroui/react";
|
||||||
|
import { Gear, Lock } from "@gravity-ui/icons";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import { api, type SystemSettings } from "@/lib/api";
|
||||||
|
|
||||||
|
const MIN_HEARTBEAT = 10;
|
||||||
|
const MAX_HEARTBEAT = 86400;
|
||||||
|
const DEFAULT_HEARTBEAT = 60;
|
||||||
|
|
||||||
|
export default function ParametersSettingsPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const isAdmin = session?.user?.role === "admin";
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ["system-settings"],
|
||||||
|
queryFn: () => api.settings.get(),
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [heartbeatInterval, setHeartbeatInterval] = useState(String(DEFAULT_HEARTBEAT));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
setHeartbeatInterval(String(settings.ocpp16j.heartbeatInterval));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const parsedHeartbeat = useMemo(() => {
|
||||||
|
const n = Number(heartbeatInterval);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return Math.round(n);
|
||||||
|
}, [heartbeatInterval]);
|
||||||
|
|
||||||
|
const heartbeatError =
|
||||||
|
parsedHeartbeat === null
|
||||||
|
? "请输入有效数字"
|
||||||
|
: parsedHeartbeat < MIN_HEARTBEAT || parsedHeartbeat > MAX_HEARTBEAT
|
||||||
|
? `范围应为 ${MIN_HEARTBEAT} - ${MAX_HEARTBEAT} 秒`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const isDirty = settings
|
||||||
|
? Number(heartbeatInterval) !== settings.ocpp16j.heartbeatInterval
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (parsedHeartbeat === null) {
|
||||||
|
toast.warning("请输入有效的心跳间隔");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedHeartbeat < MIN_HEARTBEAT || parsedHeartbeat > MAX_HEARTBEAT) {
|
||||||
|
toast.warning(`心跳间隔范围应为 ${MIN_HEARTBEAT}-${MAX_HEARTBEAT} 秒`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: SystemSettings = {
|
||||||
|
ocpp16j: { heartbeatInterval: parsedHeartbeat },
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.settings.put(payload);
|
||||||
|
toast.success("参数配置已保存");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["system-settings"] });
|
||||||
|
} catch {
|
||||||
|
toast.warning("保存失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
|
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-warning/10">
|
||||||
|
<Lock className="size-6 text-warning" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">需要管理员权限</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">参数配置仅对管理员开放</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gear className="size-5 text-foreground" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">参数配置</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-sm text-muted">
|
||||||
|
按功能模块管理系统参数,变更将影响后续设备交互行为
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-surface-secondary">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-accent/10">
|
||||||
|
<Gear className="size-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">OCPP 1.6J</p>
|
||||||
|
<p className="text-xs text-muted">协议层行为参数</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-5 py-4">
|
||||||
|
<TextField fullWidth>
|
||||||
|
<Label className="text-sm font-medium">心跳间隔</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={String(MIN_HEARTBEAT)}
|
||||||
|
max={String(MAX_HEARTBEAT)}
|
||||||
|
step="1"
|
||||||
|
value={heartbeatInterval}
|
||||||
|
onChange={(e) => setHeartbeatInterval(e.target.value)}
|
||||||
|
placeholder="60"
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
单位:秒。该值会用于 BootNotification.conf 的 interval
|
||||||
|
字段,并下发给充电桩默认心跳参数。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!!heartbeatError && (
|
||||||
|
<Alert status="warning">
|
||||||
|
<Alert.Indicator />
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Description>{heartbeatError}</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isDisabled={saving || !isDirty || !!heartbeatError}
|
||||||
|
onPress={handleSave}
|
||||||
|
>
|
||||||
|
{saving ? <Spinner size="sm" color="current" /> : "保存"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/app/dashboard/topology/page.tsx
Normal file
12
apps/web/app/dashboard/topology/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import TopologyClient from "./topology-client";
|
||||||
|
|
||||||
|
export default function TopologyPage() {
|
||||||
|
return (
|
||||||
|
// Break out of the dashboard's max-w-7xl / px padding by using
|
||||||
|
// a fixed overlay that covers exactly the main content area.
|
||||||
|
// left-0/lg:left-60 accounts for the sidebar width (w-60).
|
||||||
|
<div className="fixed inset-0 left-0 top-14 lg:left-60 lg:top-0">
|
||||||
|
<TopologyClient />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/app/dashboard/topology/topology-client.tsx
Normal file
18
apps/web/app/dashboard/topology/topology-client.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const TopologyFlow = dynamic(() => import("./topology-flow"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-muted text-sm">加载拓扑图…</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function TopologyClient() {
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100%", height: "100%" }}>
|
||||||
|
<TopologyFlow />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
438
apps/web/app/dashboard/topology/topology-flow.tsx
Normal file
438
apps/web/app/dashboard/topology/topology-flow.tsx
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
Handle,
|
||||||
|
MiniMap,
|
||||||
|
Panel,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
type NodeProps,
|
||||||
|
BackgroundVariant,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { api, type ChargePoint, type ConnectorSummary } from "@/lib/api";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
import { Clock, EvCharger, Plug, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
// ── Connection status ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ConnectionStatus = "online" | "stale" | "offline";
|
||||||
|
|
||||||
|
function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus {
|
||||||
|
if (cp.transportStatus === "unavailable") return "stale";
|
||||||
|
if (cp.transportStatus !== "online" || !connected.includes(cp.chargePointIdentifier)) return "offline";
|
||||||
|
if (!cp.lastHeartbeatAt) return "stale";
|
||||||
|
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "minute") < 5 ? "online" : "stale";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
ConnectionStatus,
|
||||||
|
{ color: string; edgeColor: string; label: string; animated: boolean }
|
||||||
|
> = {
|
||||||
|
online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true },
|
||||||
|
stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "通道异常", animated: true },
|
||||||
|
offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTOR_STATUS_COLOR: Record<string, string> = {
|
||||||
|
Available: "#22c55e",
|
||||||
|
Charging: "#3b82f6",
|
||||||
|
Preparing: "#f59e0b",
|
||||||
|
Finishing: "#f59e0b",
|
||||||
|
SuspendedEV: "#f59e0b",
|
||||||
|
SuspendedEVSE: "#f59e0b",
|
||||||
|
Reserved: "#a855f7",
|
||||||
|
Faulted: "#ef4444",
|
||||||
|
Unavailable: "#71717a",
|
||||||
|
Occupied: "#f59e0b",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTOR_STATUS_LABEL: Record<string, string> = {
|
||||||
|
Available: "空闲",
|
||||||
|
Charging: "充电中",
|
||||||
|
Preparing: "准备中",
|
||||||
|
Finishing: "结束中",
|
||||||
|
SuspendedEV: "EV 暂停",
|
||||||
|
SuspendedEVSE: "EVSE 暂停",
|
||||||
|
Reserved: "已预约",
|
||||||
|
Faulted: "故障",
|
||||||
|
Unavailable: "不可用",
|
||||||
|
Occupied: "占用",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── CSMS Hub Node ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CsmsNodeData = { connectedCount: number; totalCount: number };
|
||||||
|
|
||||||
|
function CsmsHubNode({ data }: NodeProps) {
|
||||||
|
const { connectedCount, totalCount } = data as CsmsNodeData;
|
||||||
|
return (
|
||||||
|
<div className="min-w-[200px] rounded-xl border border-accent/70 bg-accent px-5 py-4 text-accent-foreground shadow-lg shadow-accent/25">
|
||||||
|
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
|
||||||
|
<div className="mb-2.5 flex items-center gap-2.5">
|
||||||
|
<div className="flex rounded-lg bg-accent-foreground/15 p-1.5">
|
||||||
|
<Zap size={16} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-semibold leading-tight">CSMS 服务器</div>
|
||||||
|
<div className="mt-0.5 text-[11px] opacity-75">Helios EVCS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-accent-foreground/12 px-2.5 py-1.5">
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: connectedCount > 0 ? "#22c55e" : "#71717a",
|
||||||
|
boxShadow: connectedCount > 0 ? "0 0 6px #22c55e" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{connectedCount} / {totalCount} 设备在线
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Charge Point Node ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ChargePointNodeData = { cp: ChargePoint; status: ConnectionStatus; isAdmin: boolean };
|
||||||
|
|
||||||
|
function ChargePointNode({ data }: NodeProps) {
|
||||||
|
const { cp, status, isAdmin } = data as ChargePointNodeData;
|
||||||
|
const cfg = STATUS_CONFIG[status];
|
||||||
|
const hbText = cp.lastHeartbeatAt ? dayjs(cp.lastHeartbeatAt).fromNow() : "从未";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-w-[190px] flex-col rounded-xl bg-surface px-2 py-2"
|
||||||
|
style={{
|
||||||
|
border: `1.5px solid ${status === "offline" ? "var(--color-border)" : cfg.color + "80"}`,
|
||||||
|
boxShadow:
|
||||||
|
status !== "offline" ? `0 2px 12px ${cfg.color}25` : "0 1px 4px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
|
||||||
|
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<EvCharger className="size-4 shrink-0 text-muted" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[13px] font-semibold tracking-tight text-foreground">
|
||||||
|
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||||
|
</span>
|
||||||
|
{isAdmin && cp.deviceName && (
|
||||||
|
<span className="font-mono text-[10px] text-muted">{cp.chargePointIdentifier}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{ background: cfg.color + "18", color: cfg.color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: cfg.color,
|
||||||
|
boxShadow: status !== "offline" ? `0 0 5px ${cfg.color}` : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||||
|
<div className="text-[10px] text-muted">
|
||||||
|
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[9px] text-muted">
|
||||||
|
<Clock size={10} />
|
||||||
|
<span>心跳 {hbText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connector Node ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ConnectorNodeData = { conn: ConnectorSummary; cpStatus: ConnectionStatus };
|
||||||
|
|
||||||
|
function ConnectorNode({ data }: NodeProps) {
|
||||||
|
const { conn, cpStatus } = data as ConnectorNodeData;
|
||||||
|
const color =
|
||||||
|
cpStatus === "offline" ? "#71717a" : (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b");
|
||||||
|
const label = CONNECTOR_STATUS_LABEL[conn.status] ?? conn.status;
|
||||||
|
const isActive = conn.status === "Charging";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-w-[88px] flex-col items-center gap-1.5 rounded-lg bg-surface px-2.5 py-2"
|
||||||
|
style={{
|
||||||
|
border: `1.5px solid ${color}80`,
|
||||||
|
boxShadow: isActive ? `0 0 10px ${color}40` : "0 1px 3px rgba(0,0,0,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Plug className="size-3 shrink-0 text-muted" />
|
||||||
|
<span className="text-xs font-semibold text-foreground">#{conn.connectorId}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{ background: color + "18", color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block size-[5px] shrink-0 rounded-full"
|
||||||
|
style={{ background: color, boxShadow: isActive ? `0 0 4px ${color}` : "none" }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CP_W = 200;
|
||||||
|
const CP_H = 70; // matches actual rendered height
|
||||||
|
const CP_GAP_X = 60;
|
||||||
|
const CP_GAP_Y = 100;
|
||||||
|
const CONN_W = 96;
|
||||||
|
const CONN_H = 62;
|
||||||
|
const CONN_GAP_X = 12;
|
||||||
|
const CONN_ROW_GAP = 48;
|
||||||
|
const COLS = 5;
|
||||||
|
const CSMS_H = 88;
|
||||||
|
|
||||||
|
/** Horizontal space a charge point needs (driven by its connector spread). */
|
||||||
|
function slotWidth(cp: ChargePoint): number {
|
||||||
|
const n = cp.connectors.length;
|
||||||
|
if (n === 0) return CP_W;
|
||||||
|
return Math.max(CP_W, n * CONN_W + (n - 1) * CONN_GAP_X);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGraph(
|
||||||
|
chargePoints: ChargePoint[],
|
||||||
|
connectedIdentifiers: string[],
|
||||||
|
isAdmin: boolean,
|
||||||
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
// Group into rows
|
||||||
|
const rows: ChargePoint[][] = [];
|
||||||
|
for (let i = 0; i < chargePoints.length; i += COLS) {
|
||||||
|
rows.push(chargePoints.slice(i, i + COLS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width of each row (accounting for variable slot widths)
|
||||||
|
const rowWidths = rows.map((rowCps) =>
|
||||||
|
rowCps.reduce((sum, cp, ci) => sum + slotWidth(cp) + (ci > 0 ? CP_GAP_X : 0), 0),
|
||||||
|
);
|
||||||
|
const maxRowWidth = Math.max(...rowWidths, CP_W);
|
||||||
|
const csmsX = maxRowWidth / 2 - CP_W / 2;
|
||||||
|
|
||||||
|
const nodes: Node[] = [
|
||||||
|
{
|
||||||
|
id: "csms",
|
||||||
|
type: "csmsHub",
|
||||||
|
position: { x: csmsX, y: 0 },
|
||||||
|
data: { connectedCount: connectedIdentifiers.length, totalCount: chargePoints.length },
|
||||||
|
draggable: true,
|
||||||
|
width: CP_W,
|
||||||
|
height: CSMS_H,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
|
let cpY = CSMS_H + CP_GAP_Y;
|
||||||
|
|
||||||
|
rows.forEach((rowCps, _rowIdx) => {
|
||||||
|
const rowW = rowWidths[_rowIdx];
|
||||||
|
// Center narrower rows under CSMS
|
||||||
|
let curX = (maxRowWidth - rowW) / 2;
|
||||||
|
|
||||||
|
// tallest connector row determines next cpY offset
|
||||||
|
const maxConnSpread = Math.max(
|
||||||
|
...rowCps.map((cp) => (cp.connectors.length > 0 ? CONN_ROW_GAP + CONN_H : 0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
rowCps.forEach((cp) => {
|
||||||
|
const sw = slotWidth(cp);
|
||||||
|
const cpX = curX + (sw - CP_W) / 2; // center CP node within its slot
|
||||||
|
|
||||||
|
const status = getStatus(cp, connectedIdentifiers);
|
||||||
|
const cfg = STATUS_CONFIG[status];
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: cp.id,
|
||||||
|
type: "chargePoint",
|
||||||
|
position: { x: cpX, y: cpY },
|
||||||
|
data: { cp, status, isAdmin },
|
||||||
|
draggable: true,
|
||||||
|
width: CP_W,
|
||||||
|
height: CP_H,
|
||||||
|
});
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `csms-${cp.id}`,
|
||||||
|
source: "csms",
|
||||||
|
target: cp.id,
|
||||||
|
animated: cfg.animated,
|
||||||
|
style: {
|
||||||
|
stroke: cfg.edgeColor,
|
||||||
|
strokeWidth: status === "offline" ? 1 : 2,
|
||||||
|
strokeDasharray: status === "offline" ? "6 4" : undefined,
|
||||||
|
opacity: status === "offline" ? 0.4 : 0.85,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connector nodes — centered under their CP
|
||||||
|
const sorted = [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId);
|
||||||
|
const n = sorted.length;
|
||||||
|
if (n > 0) {
|
||||||
|
const totalConnW = n * CONN_W + (n - 1) * CONN_GAP_X;
|
||||||
|
const connStartX = cpX + CP_W / 2 - totalConnW / 2;
|
||||||
|
const connY = cpY + CP_H + CONN_ROW_GAP;
|
||||||
|
|
||||||
|
sorted.forEach((conn, ci) => {
|
||||||
|
const connNodeId = `conn-${conn.id}`;
|
||||||
|
const connColor =
|
||||||
|
status === "offline" ? "#71717a" : (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b");
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: connNodeId,
|
||||||
|
type: "connector",
|
||||||
|
position: { x: connStartX + ci * (CONN_W + CONN_GAP_X), y: connY },
|
||||||
|
data: { conn, cpStatus: status },
|
||||||
|
draggable: true,
|
||||||
|
width: CONN_W,
|
||||||
|
height: CONN_H,
|
||||||
|
});
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `${cp.id}-${connNodeId}`,
|
||||||
|
source: cp.id,
|
||||||
|
target: connNodeId,
|
||||||
|
animated: conn.status === "Charging",
|
||||||
|
style: {
|
||||||
|
stroke: connColor,
|
||||||
|
strokeWidth: conn.status === "Charging" ? 2 : 1.5,
|
||||||
|
opacity: status === "offline" ? 0.35 : 0.7,
|
||||||
|
strokeDasharray: status === "offline" ? "4 3" : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
curX += sw + CP_GAP_X;
|
||||||
|
});
|
||||||
|
|
||||||
|
cpY += CP_H + CP_GAP_Y + maxConnSpread;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node type registry (stable reference — must be outside component) ─────
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
csmsHub: CsmsHubNode,
|
||||||
|
chargePoint: ChargePointNode,
|
||||||
|
connector: ConnectorNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TopologyFlow() {
|
||||||
|
const { data: chargePoints = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["chargePoints"],
|
||||||
|
queryFn: () => api.chargePoints.list(),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: connections } = useQuery({
|
||||||
|
queryKey: ["chargePoints", "connections"],
|
||||||
|
queryFn: () => api.chargePoints.connections(),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sessionData } = useSession();
|
||||||
|
const isAdmin = sessionData?.user?.role === "admin";
|
||||||
|
|
||||||
|
const connectedIds = connections?.connectedIdentifiers ?? [];
|
||||||
|
|
||||||
|
const { nodes, edges } = useMemo(
|
||||||
|
() => buildGraph(chargePoints, connectedIds, isAdmin),
|
||||||
|
[chargePoints, connectedIds, isAdmin],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full items-center justify-center text-sm text-muted">加载中…</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chargePoints.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col items-center justify-center gap-2 text-muted">
|
||||||
|
<EvCharger className="size-10 opacity-30" />
|
||||||
|
<p className="text-sm">暂无充电桩</p>
|
||||||
|
<p className="text-xs opacity-60">在「充电桩」页面添加设备后将显示在此处</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="size-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
|
minZoom={0.15}
|
||||||
|
maxZoom={2}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
color="var(--color-border)"
|
||||||
|
/>
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={(n) => {
|
||||||
|
if (n.type === "csmsHub") return "#6366f1";
|
||||||
|
if (n.type === "chargePoint") {
|
||||||
|
const status = (n.data as ChargePointNodeData).status;
|
||||||
|
return STATUS_CONFIG[status].color;
|
||||||
|
}
|
||||||
|
if (n.type === "connector") {
|
||||||
|
const { conn, cpStatus } = n.data as ConnectorNodeData;
|
||||||
|
return cpStatus === "offline"
|
||||||
|
? "#71717a"
|
||||||
|
: (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b");
|
||||||
|
}
|
||||||
|
return "#888";
|
||||||
|
}}
|
||||||
|
nodeStrokeWidth={0}
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface-secondary)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
// zoomable
|
||||||
|
// pannable
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
453
apps/web/app/dashboard/transactions/[id]/page.tsx
Normal file
453
apps/web/app/dashboard/transactions/[id]/page.tsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Alert, Button, Chip, Modal, Spinner } from "@heroui/react";
|
||||||
|
import { ArrowLeft, ArrowRotateRight, TrashBin } from "@gravity-ui/icons";
|
||||||
|
import { APIError, api } from "@/lib/api";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
import InfoSection from "@/components/info-section";
|
||||||
|
import MetricIndicator from "@/components/metric-indicator";
|
||||||
|
import { BanknoteArrowUp, Clock, EvCharger } from "lucide-react";
|
||||||
|
|
||||||
|
const stopReasonLabelMap: Record<string, string> = {
|
||||||
|
EmergencyStop: "紧急停止",
|
||||||
|
EVDisconnected: "车辆断开",
|
||||||
|
HardReset: "硬重启",
|
||||||
|
Local: "本地结束",
|
||||||
|
Other: "其他原因",
|
||||||
|
PowerLoss: "断电结束",
|
||||||
|
Reboot: "重启结束",
|
||||||
|
Remote: "远程结束",
|
||||||
|
SoftReset: "软重启",
|
||||||
|
UnlockCommand: "解锁结束",
|
||||||
|
DeAuthorized: "鉴权拒绝",
|
||||||
|
};
|
||||||
|
|
||||||
|
const idTagRejectLabelMap: Record<string, string> = {
|
||||||
|
Blocked: "卡片不可用或余额不足",
|
||||||
|
Expired: "卡片已过期",
|
||||||
|
Invalid: "卡片无效",
|
||||||
|
ConcurrentTx: "该卡已有进行中的订单",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(start: string, stop: string | null): string {
|
||||||
|
if (!stop) return "进行中";
|
||||||
|
const min = dayjs(stop).diff(dayjs(start), "minute");
|
||||||
|
if (min < 60) return `${min} 分钟`;
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return dayjs(iso).format("YYYY/M/D HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEnergy(wh: number | null | undefined): string {
|
||||||
|
if (wh == null) return "—";
|
||||||
|
return `${(wh / 1000).toFixed(3)} kWh`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(fen: number | null | undefined): string {
|
||||||
|
if (fen == null) return "—";
|
||||||
|
return `¥${(fen / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransactionDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const txId = Number(id);
|
||||||
|
const isValidId = Number.isInteger(txId) && txId > 0;
|
||||||
|
|
||||||
|
const [stopping, setStopping] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const { data: sessionData } = useSession();
|
||||||
|
const isAdmin = sessionData?.user?.role === "admin";
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: tx,
|
||||||
|
isPending,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["transaction", txId],
|
||||||
|
queryFn: () => api.transactions.get(txId),
|
||||||
|
enabled: isValidId,
|
||||||
|
refetchInterval: 3_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
if (!tx) return;
|
||||||
|
setStopping(true);
|
||||||
|
try {
|
||||||
|
await api.transactions.stop(tx.id);
|
||||||
|
await refetch();
|
||||||
|
} finally {
|
||||||
|
setStopping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!tx) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.transactions.delete(tx.id);
|
||||||
|
router.push("/dashboard/transactions");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValidId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/transactions"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
充电记录
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-danger">无效的交易编号。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !tx) {
|
||||||
|
const notFound = error instanceof APIError && error.status === 404;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/transactions"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
充电记录
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-danger">
|
||||||
|
{notFound ? "交易记录不存在。" : "加载交易记录失败。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const energyWh = tx.energyWh ?? tx.liveEnergyWh;
|
||||||
|
const amountFen = tx.chargeAmount ?? tx.estimatedCost;
|
||||||
|
const isEstimatedEnergy = tx.energyWh == null && tx.liveEnergyWh != null;
|
||||||
|
const isEstimatedAmount = tx.chargeAmount == null && tx.estimatedCost != null;
|
||||||
|
const isRejected = tx.stopReason === "DeAuthorized";
|
||||||
|
const stopReasonLabel = tx.stopReason
|
||||||
|
? (stopReasonLabelMap[tx.stopReason] ?? tx.stopReason)
|
||||||
|
: "—";
|
||||||
|
const rejectReason =
|
||||||
|
tx.idTagStatus && tx.idTagStatus !== "Accepted"
|
||||||
|
? (idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus)
|
||||||
|
: "鉴权失败";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/transactions"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
充电记录
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">订单 #{tx.id}</h1>
|
||||||
|
{isRejected ? (
|
||||||
|
<Chip color="danger" size="sm" variant="soft">
|
||||||
|
已拒绝
|
||||||
|
</Chip>
|
||||||
|
) : tx.stopTimestamp ? (
|
||||||
|
<Chip color="success" size="sm" variant="soft">
|
||||||
|
已完成
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip color="warning" size="sm" variant="soft">
|
||||||
|
进行中
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{isEstimatedAmount && (
|
||||||
|
<Chip color="warning" size="sm" variant="soft">
|
||||||
|
费用预估
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{tx.stopReason && !isRejected && (
|
||||||
|
<Chip color="default" size="sm" variant="soft">
|
||||||
|
{stopReasonLabel}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
开始于 {formatDateTime(tx.startTimestamp)} · {tx.stopTimestamp ? "时长 " : ""}
|
||||||
|
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
isDisabled={isFetching}
|
||||||
|
onPress={() => refetch()}
|
||||||
|
aria-label="刷新"
|
||||||
|
>
|
||||||
|
<ArrowRotateRight className={`size-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!tx.stopTimestamp && (
|
||||||
|
<Modal>
|
||||||
|
<Button size="sm" variant="danger-soft" isDisabled={stopping}>
|
||||||
|
{stopping ? <Spinner size="sm" /> : "中止充电"}
|
||||||
|
</Button>
|
||||||
|
<Modal.Backdrop>
|
||||||
|
<Modal.Container scroll="outside">
|
||||||
|
<Modal.Dialog className="sm:max-w-96">
|
||||||
|
<Modal.CloseTrigger />
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Heading>确认中止充电</Modal.Heading>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
将远程中止充电交易{" "}
|
||||||
|
<span className="font-mono text-foreground">#{tx.id}</span>。
|
||||||
|
</p>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer className="flex justify-end gap-2">
|
||||||
|
<Button slot="close" variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
slot="close"
|
||||||
|
variant="danger"
|
||||||
|
isDisabled={stopping}
|
||||||
|
onPress={handleStop}
|
||||||
|
>
|
||||||
|
{stopping ? <Spinner size="sm" /> : "确认中止"}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Dialog>
|
||||||
|
</Modal.Container>
|
||||||
|
</Modal.Backdrop>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Modal>
|
||||||
|
<Button isIconOnly size="sm" variant="tertiary" isDisabled={deleting}>
|
||||||
|
{deleting ? <Spinner size="sm" /> : <TrashBin className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<Modal.Backdrop>
|
||||||
|
<Modal.Container scroll="outside">
|
||||||
|
<Modal.Dialog className="sm:max-w-96">
|
||||||
|
<Modal.CloseTrigger />
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Heading>确认删除记录</Modal.Heading>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
将永久删除交易 <span className="font-mono text-foreground">#{tx.id}</span>。
|
||||||
|
</p>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer className="flex justify-end gap-2">
|
||||||
|
<Button slot="close" variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
slot="close"
|
||||||
|
variant="danger"
|
||||||
|
isDisabled={deleting}
|
||||||
|
onPress={handleDelete}
|
||||||
|
>
|
||||||
|
{deleting ? <Spinner size="sm" /> : "确认删除"}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Dialog>
|
||||||
|
</Modal.Container>
|
||||||
|
</Modal.Backdrop>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert status={isRejected ? "danger" : tx.stopTimestamp ? "success" : "accent"}>
|
||||||
|
<Alert.Indicator />
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>
|
||||||
|
{isRejected ? "交易已被系统拒绝" : tx.stopTimestamp ? "本次交易已结束" : "充电进行中"}
|
||||||
|
</Alert.Title>
|
||||||
|
<Alert.Description>
|
||||||
|
{isRejected
|
||||||
|
? rejectReason
|
||||||
|
: tx.stopTimestamp
|
||||||
|
? `结束原因:${stopReasonLabel}`
|
||||||
|
: "充电进行中,实际充电量和费用以结束时系统计算为准。"}
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<MetricIndicator
|
||||||
|
title="充电量"
|
||||||
|
color="border-success"
|
||||||
|
icon={<EvCharger className="size-5 text-success" />}
|
||||||
|
value={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{formatEnergy(energyWh)}
|
||||||
|
{isEstimatedEnergy && (
|
||||||
|
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
|
||||||
|
预估
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricIndicator
|
||||||
|
title="总费用"
|
||||||
|
color="border-accent"
|
||||||
|
icon={<BanknoteArrowUp className="size-5 text-accent" />}
|
||||||
|
value={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{formatAmount(amountFen)}
|
||||||
|
{isEstimatedAmount && (
|
||||||
|
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
|
||||||
|
预估
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricIndicator
|
||||||
|
title="订单状态"
|
||||||
|
icon={<Clock className="size-5 text-foreground" />}
|
||||||
|
color={
|
||||||
|
isRejected ? "border-danger" : tx.stopTimestamp ? "border-success" : "border-warning"
|
||||||
|
}
|
||||||
|
value={isRejected ? "已拒绝" : tx.stopTimestamp ? "已完成" : "进行中"}
|
||||||
|
/>
|
||||||
|
<MetricIndicator title="停止原因" value={isRejected ? rejectReason : stopReasonLabel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<InfoSection title="交易信息">
|
||||||
|
<dl className="divide-y divide-border">
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">交易编号</dt>
|
||||||
|
<dd className="font-mono text-sm text-foreground">#{tx.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">储值卡</dt>
|
||||||
|
<dd className="font-mono text-sm text-foreground">{tx.idTag}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">充电桩</dt>
|
||||||
|
<dd className="text-right text-sm text-foreground">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span>{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}</span>
|
||||||
|
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
|
||||||
|
<span className="font-mono text-xs text-muted">{tx.chargePointIdentifier}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">连接器</dt>
|
||||||
|
<dd className="text-sm text-foreground">{tx.connectorNumber ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">开始时间</dt>
|
||||||
|
<dd className="text-right text-sm text-foreground">
|
||||||
|
{formatDateTime(tx.startTimestamp)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">结束时间</dt>
|
||||||
|
<dd className="text-right text-sm text-foreground">
|
||||||
|
{formatDateTime(tx.stopTimestamp)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">持续时长</dt>
|
||||||
|
<dd className="text-sm text-foreground">
|
||||||
|
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</InfoSection>
|
||||||
|
|
||||||
|
<InfoSection title="计量与费用">
|
||||||
|
<dl className="divide-y divide-border">
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">起始表计</dt>
|
||||||
|
<dd className="text-sm text-foreground">
|
||||||
|
{tx.startMeterValue != null ? `${tx.startMeterValue} Wh` : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">结束表计</dt>
|
||||||
|
<dd className="text-sm text-foreground">
|
||||||
|
{tx.stopMeterValue != null ? `${tx.stopMeterValue} Wh` : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">消耗电量</dt>
|
||||||
|
<dd className="text-sm text-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{formatEnergy(energyWh)}
|
||||||
|
{isEstimatedEnergy && (
|
||||||
|
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
|
||||||
|
预估
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">电费</dt>
|
||||||
|
<dd className="text-sm text-foreground">{formatAmount(tx.electricityFee)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">服务费</dt>
|
||||||
|
<dd className="text-sm text-foreground">{formatAmount(tx.serviceFee)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<dt className="shrink-0 text-sm text-muted">总费用</dt>
|
||||||
|
<dd className="text-sm text-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{formatAmount(amountFen)}
|
||||||
|
{isEstimatedAmount && (
|
||||||
|
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
|
||||||
|
预估
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</InfoSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
|
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
|
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
@@ -10,6 +12,39 @@ import dayjs from "@/lib/dayjs";
|
|||||||
|
|
||||||
const LIMIT = 15;
|
const LIMIT = 15;
|
||||||
|
|
||||||
|
const idTagRejectLabelMap: Record<string, string> = {
|
||||||
|
Blocked: "卡片不可用或余额不足",
|
||||||
|
Expired: "卡片已过期",
|
||||||
|
Invalid: "卡片无效",
|
||||||
|
ConcurrentTx: "该卡已有进行中的订单",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopReasonLabelMap: Record<string, string> = {
|
||||||
|
EmergencyStop: "紧急停止",
|
||||||
|
EVDisconnected: "车辆断开",
|
||||||
|
HardReset: "硬重启",
|
||||||
|
Local: "本地结束",
|
||||||
|
Other: "其他原因",
|
||||||
|
PowerLoss: "断电结束",
|
||||||
|
Reboot: "重启结束",
|
||||||
|
Remote: "远程结束",
|
||||||
|
SoftReset: "软重启",
|
||||||
|
UnlockCommand: "解锁结束",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopReasonColorMap: Record<string, "success" | "warning" | "danger" | "default"> = {
|
||||||
|
Local: "success",
|
||||||
|
EVDisconnected: "success",
|
||||||
|
Remote: "warning",
|
||||||
|
UnlockCommand: "warning",
|
||||||
|
EmergencyStop: "danger",
|
||||||
|
PowerLoss: "danger",
|
||||||
|
HardReset: "danger",
|
||||||
|
SoftReset: "warning",
|
||||||
|
Reboot: "warning",
|
||||||
|
Other: "default",
|
||||||
|
};
|
||||||
|
|
||||||
function formatDuration(start: string, stop: string | null): string {
|
function formatDuration(start: string, stop: string | null): string {
|
||||||
if (!stop) return "进行中";
|
if (!stop) return "进行中";
|
||||||
const min = dayjs(stop).diff(dayjs(start), "minute");
|
const min = dayjs(stop).diff(dayjs(start), "minute");
|
||||||
@@ -19,14 +54,28 @@ function formatDuration(start: string, stop: string | null): string {
|
|||||||
return `${h}h ${m}m`;
|
return `${h}h ${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
function TransactionsPageContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { data: sessionData } = useSession();
|
const { data: sessionData } = useSession();
|
||||||
const isAdmin = sessionData?.user?.role === "admin";
|
const isAdmin = sessionData?.user?.role === "admin";
|
||||||
|
const statusFromQuery = searchParams.get("status");
|
||||||
|
const initialStatus: "all" | "active" | "completed" =
|
||||||
|
statusFromQuery === "active" || statusFromQuery === "completed" ? statusFromQuery : "all";
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
|
const [status, setStatus] = useState<"all" | "active" | "completed">(initialStatus);
|
||||||
const [stoppingId, setStoppingId] = useState<number | null>(null);
|
const [stoppingId, setStoppingId] = useState<number | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== initialStatus) {
|
||||||
|
setStatus(initialStatus);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
// We intentionally depend on searchParams-derived value only.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialStatus]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isPending: loading,
|
isPending: loading,
|
||||||
@@ -131,12 +180,30 @@ export default function TransactionsPage() {
|
|||||||
>
|
>
|
||||||
{(data?.data ?? []).map((tx) => (
|
{(data?.data ?? []).map((tx) => (
|
||||||
<Table.Row key={tx.id} id={tx.id}>
|
<Table.Row key={tx.id} id={tx.id}>
|
||||||
<Table.Cell className="font-mono text-sm">{tx.id}</Table.Cell>
|
<Table.Cell className="font-mono text-sm">
|
||||||
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell>
|
<Link
|
||||||
|
href={`/dashboard/transactions/${tx.id}`}
|
||||||
|
className="text-accent hover:text-accent/80"
|
||||||
|
>
|
||||||
|
{tx.id}
|
||||||
|
</Link>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}</span>
|
||||||
|
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
|
||||||
|
<span className="font-mono text-xs text-muted">{tx.chargePointIdentifier}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
|
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
|
||||||
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
|
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{tx.stopTimestamp ? (
|
{tx.stopTimestamp && tx.stopReason === "DeAuthorized" ? (
|
||||||
|
<Chip color="danger" size="sm" variant="soft">
|
||||||
|
已拒绝
|
||||||
|
</Chip>
|
||||||
|
) : tx.stopTimestamp ? (
|
||||||
<Chip color="success" size="sm" variant="soft">
|
<Chip color="success" size="sm" variant="soft">
|
||||||
已完成
|
已完成
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -181,13 +248,23 @@ export default function TransactionsPage() {
|
|||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{tx.stopReason ? (
|
{tx.stopReason === "DeAuthorized" ? (
|
||||||
<Chip color="default" size="sm" variant="soft">
|
<p className="text-xs font-medium text-muted text-nowrap">
|
||||||
{tx.stopReason}
|
{tx.idTagStatus && tx.idTagStatus !== "Accepted"
|
||||||
|
? `${idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus}`
|
||||||
|
: "鉴权失败"}
|
||||||
|
</p>
|
||||||
|
) : tx.stopReason ? (
|
||||||
|
<Chip
|
||||||
|
color={stopReasonColorMap[tx.stopReason] ?? "default"}
|
||||||
|
size="sm"
|
||||||
|
variant="soft"
|
||||||
|
>
|
||||||
|
{stopReasonLabelMap[tx.stopReason] ?? tx.stopReason}
|
||||||
</Chip>
|
</Chip>
|
||||||
) : tx.stopTimestamp ? (
|
) : tx.stopTimestamp ? (
|
||||||
<Chip color="default" size="sm" variant="soft">
|
<Chip color="default" size="sm" variant="soft">
|
||||||
Local
|
本地结束
|
||||||
</Chip>
|
</Chip>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
@@ -333,3 +410,17 @@ export default function TransactionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function TransactionsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TransactionsPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type IdTagCardProps = {
|
|||||||
idTag: string;
|
idTag: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
/** 内容排列方式:余额、logo、卡号等信息元素的布局 */
|
/** 内容排列方式:余额、logo、卡号等信息元素的布局 */
|
||||||
layout?: CardLayoutName;
|
layout?: CardLayoutName;
|
||||||
/** 卡底装饰风格:纹理、光效、几何图形等视觉元素 */
|
/** 卡底装饰风格:纹理、光效、几何图形等视觉元素 */
|
||||||
@@ -45,6 +46,7 @@ export function IdTagCard({
|
|||||||
idTag,
|
idTag,
|
||||||
balance,
|
balance,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
|
isDisabled = false,
|
||||||
layout = "around",
|
layout = "around",
|
||||||
skin = "circles",
|
skin = "circles",
|
||||||
onClick,
|
onClick,
|
||||||
@@ -55,10 +57,12 @@ export function IdTagCard({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isDisabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={[
|
className={[
|
||||||
"relative w-full overflow-hidden rounded-2xl cursor-pointer select-none",
|
"relative w-full overflow-hidden rounded-2xl select-none",
|
||||||
"aspect-[1.586/1] transition-all duration-200 active:scale-[0.97]",
|
"aspect-[1.586/1] transition-all duration-200 active:scale-[0.97]",
|
||||||
|
isDisabled ? "cursor-not-allowed opacity-45 grayscale-[0.25]" : "cursor-pointer",
|
||||||
isSelected
|
isSelected
|
||||||
? "ring-3 ring-offset-2 ring-offset-background ring-accent shadow-2xl shadow-accent/25"
|
? "ring-3 ring-offset-2 ring-offset-background ring-accent shadow-2xl shadow-accent/25"
|
||||||
: "ring-1 ring-black/8 hover:ring-accent/40 shadow-md hover:shadow-xl",
|
: "ring-1 ring-black/8 hover:ring-accent/40 shadow-md hover:shadow-xl",
|
||||||
|
|||||||
15
apps/web/components/info-section.tsx
Normal file
15
apps/web/components/info-section.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type InfoSectionProps = {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InfoSection({ title, children }: InfoSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-foreground">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/components/metric-indicator.tsx
Normal file
41
apps/web/components/metric-indicator.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Card } from "@heroui/react";
|
||||||
|
|
||||||
|
type MetricIndicatorProps = {
|
||||||
|
title: string;
|
||||||
|
value: ReactNode;
|
||||||
|
hint?: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
color?: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MetricIndicator({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
footer,
|
||||||
|
icon,
|
||||||
|
color = "border-border",
|
||||||
|
valueClassName = "text-xl font-semibold text-foreground tabular-nums",
|
||||||
|
}: MetricIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<Card className={`border-t-2 ${color}`}>
|
||||||
|
<Card.Content className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted">{title}</p>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p className={valueClassName}>{value}</p>
|
||||||
|
{footer ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hint && <div className="text-xs text-muted">{hint}</div>
|
||||||
|
)}
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "@gravity-ui/icons";
|
} from "@gravity-ui/icons";
|
||||||
import SidebarFooter from "@/components/sidebar-footer";
|
import SidebarFooter from "@/components/sidebar-footer";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { EvCharger, Gauge, ReceiptText, UserCog, Users } from "lucide-react";
|
import { EvCharger, Gauge, Network, ReceiptText, UserCog, Users } from "lucide-react";
|
||||||
|
|
||||||
const chargeItems = [
|
const chargeItems = [
|
||||||
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
|
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
|
||||||
@@ -27,6 +27,7 @@ const chargeItems = [
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "概览", icon: Gauge, exact: true, adminOnly: false },
|
{ href: "/dashboard", label: "概览", icon: Gauge, exact: true, adminOnly: false },
|
||||||
{ href: "/dashboard/charge-points", label: "充电桩", icon: EvCharger, adminOnly: false },
|
{ href: "/dashboard/charge-points", label: "充电桩", icon: EvCharger, adminOnly: false },
|
||||||
|
{ href: "/dashboard/topology", label: "拓扑图", icon: Network, adminOnly: false },
|
||||||
{ href: "/dashboard/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false },
|
{ href: "/dashboard/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false },
|
||||||
{ href: "/dashboard/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false },
|
{ href: "/dashboard/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false },
|
||||||
{ href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true },
|
{ href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true },
|
||||||
@@ -35,6 +36,7 @@ const navItems = [
|
|||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ href: "/dashboard/settings/user", label: "账号设置", icon: UserCog, adminOnly: false },
|
{ href: "/dashboard/settings/user", label: "账号设置", icon: UserCog, adminOnly: false },
|
||||||
|
{ href: "/dashboard/settings/parameters", label: "参数配置", icon: Gear, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
function NavContent({
|
function NavContent({
|
||||||
|
|||||||
@@ -68,13 +68,25 @@ export type ConnectorDetail = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConnectionsStatus = {
|
||||||
|
connectedIdentifiers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
|
||||||
|
|
||||||
export type ChargePoint = {
|
export type ChargePoint = {
|
||||||
id: string;
|
id: string;
|
||||||
chargePointIdentifier: string;
|
chargePointIdentifier: string;
|
||||||
|
deviceName: string | null;
|
||||||
chargePointVendor: string | null;
|
chargePointVendor: string | null;
|
||||||
chargePointModel: string | null;
|
chargePointModel: string | null;
|
||||||
registrationStatus: string;
|
registrationStatus: string;
|
||||||
|
transportStatus: ChargePointConnectionStatus;
|
||||||
lastHeartbeatAt: string | null;
|
lastHeartbeatAt: string | null;
|
||||||
|
lastWsConnectedAt: string | null;
|
||||||
|
lastWsDisconnectedAt: string | null;
|
||||||
|
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
|
||||||
|
lastCommandAt: string | null;
|
||||||
lastBootNotificationAt: string | null;
|
lastBootNotificationAt: string | null;
|
||||||
feePerKwh: number;
|
feePerKwh: number;
|
||||||
pricingMode: "fixed" | "tou";
|
pricingMode: "fixed" | "tou";
|
||||||
@@ -86,6 +98,7 @@ export type ChargePoint = {
|
|||||||
export type ChargePointDetail = {
|
export type ChargePointDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
chargePointIdentifier: string;
|
chargePointIdentifier: string;
|
||||||
|
deviceName: string | null;
|
||||||
chargePointVendor: string | null;
|
chargePointVendor: string | null;
|
||||||
chargePointModel: string | null;
|
chargePointModel: string | null;
|
||||||
chargePointSerialNumber: string | null;
|
chargePointSerialNumber: string | null;
|
||||||
@@ -96,7 +109,12 @@ export type ChargePointDetail = {
|
|||||||
meterType: string | null;
|
meterType: string | null;
|
||||||
registrationStatus: string;
|
registrationStatus: string;
|
||||||
heartbeatInterval: number | null;
|
heartbeatInterval: number | null;
|
||||||
|
transportStatus: ChargePointConnectionStatus;
|
||||||
lastHeartbeatAt: string | null;
|
lastHeartbeatAt: string | null;
|
||||||
|
lastWsConnectedAt: string | null;
|
||||||
|
lastWsDisconnectedAt: string | null;
|
||||||
|
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
|
||||||
|
lastCommandAt: string | null;
|
||||||
lastBootNotificationAt: string | null;
|
lastBootNotificationAt: string | null;
|
||||||
feePerKwh: number;
|
feePerKwh: number;
|
||||||
pricingMode: "fixed" | "tou";
|
pricingMode: "fixed" | "tou";
|
||||||
@@ -110,6 +128,7 @@ export type ChargePointDetail = {
|
|||||||
export type Transaction = {
|
export type Transaction = {
|
||||||
id: number;
|
id: number;
|
||||||
chargePointIdentifier: string | null;
|
chargePointIdentifier: string | null;
|
||||||
|
chargePointDeviceName: string | null;
|
||||||
connectorNumber: number | null;
|
connectorNumber: number | null;
|
||||||
idTag: string;
|
idTag: string;
|
||||||
idTagStatus: string | null;
|
idTagStatus: string | null;
|
||||||
@@ -127,6 +146,9 @@ export type Transaction = {
|
|||||||
chargeAmount: number | null;
|
chargeAmount: number | null;
|
||||||
electricityFee: number | null;
|
electricityFee: number | null;
|
||||||
serviceFee: number | null;
|
serviceFee: number | null;
|
||||||
|
remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" | null;
|
||||||
|
remoteStopRequestedAt: string | null;
|
||||||
|
remoteStopRequestId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IdTag = {
|
export type IdTag = {
|
||||||
@@ -153,6 +175,18 @@ export type UserRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChargePointCreated = ChargePoint & {
|
||||||
|
/** 仅在创建时返回一次的明文密码,之后不可再查 */
|
||||||
|
plainPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChargePointPasswordReset = {
|
||||||
|
id: string;
|
||||||
|
chargePointIdentifier: string;
|
||||||
|
/** 仅在重置时返回一次的新明文密码 */
|
||||||
|
plainPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PaginatedTransactions = {
|
export type PaginatedTransactions = {
|
||||||
data: Transaction[];
|
data: Transaction[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -183,6 +217,14 @@ export type TariffConfig = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Ocpp16jSettings = {
|
||||||
|
heartbeatInterval: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemSettings = {
|
||||||
|
ocpp16j: Ocpp16jSettings;
|
||||||
|
};
|
||||||
|
|
||||||
// ── API functions ──────────────────────────────────────────────────────────
|
// ── API functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ChartRange = "30d" | "7d" | "24h";
|
export type ChartRange = "30d" | "7d" | "24h";
|
||||||
@@ -204,6 +246,7 @@ export const api = {
|
|||||||
chargePoints: {
|
chargePoints: {
|
||||||
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
||||||
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
|
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
|
||||||
|
connections: () => apiFetch<ConnectionsStatus>("/api/charge-points/connections"),
|
||||||
create: (data: {
|
create: (data: {
|
||||||
chargePointIdentifier: string;
|
chargePointIdentifier: string;
|
||||||
chargePointVendor?: string;
|
chargePointVendor?: string;
|
||||||
@@ -211,8 +254,9 @@ export const api = {
|
|||||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||||
feePerKwh?: number;
|
feePerKwh?: number;
|
||||||
pricingMode?: "fixed" | "tou";
|
pricingMode?: "fixed" | "tou";
|
||||||
|
deviceName?: string;
|
||||||
}) =>
|
}) =>
|
||||||
apiFetch<ChargePoint>("/api/charge-points", {
|
apiFetch<ChargePointCreated>("/api/charge-points", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
@@ -224,6 +268,7 @@ export const api = {
|
|||||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||||
chargePointVendor?: string;
|
chargePointVendor?: string;
|
||||||
chargePointModel?: string;
|
chargePointModel?: string;
|
||||||
|
deviceName?: string | null;
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
|
apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
|
||||||
@@ -232,6 +277,10 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
delete: (id: string) =>
|
delete: (id: string) =>
|
||||||
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
|
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
|
||||||
|
resetPassword: (id: string) =>
|
||||||
|
apiFetch<ChargePointPasswordReset>(`/api/charge-points/${id}/reset-password`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
transactions: {
|
transactions: {
|
||||||
list: (params?: {
|
list: (params?: {
|
||||||
@@ -323,4 +372,9 @@ export const api = {
|
|||||||
put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) =>
|
put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) =>
|
||||||
apiFetch<TariffConfig>("/api/tariff", { method: "PUT", body: JSON.stringify(data) }),
|
apiFetch<TariffConfig>("/api/tariff", { method: "PUT", body: JSON.stringify(data) }),
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
get: () => apiFetch<SystemSettings>("/api/settings"),
|
||||||
|
put: (data: SystemSettings) =>
|
||||||
|
apiFetch<SystemSettings>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tremor/react": "4.0.0-beta-tremor-v4.4",
|
"@tremor/react": "4.0.0-beta-tremor-v4.4",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
|
|||||||
37
hardware/firmware/lib/MicroOcppMongoose/library.json
Normal file
37
hardware/firmware/lib/MicroOcppMongoose/library.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "MicroOcppMongoose",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"description": "Mongoose Adapter for the MicroOCPP Client",
|
||||||
|
"keywords": "OCPP, 1.6, OCPP 1.6, OCPP 2.0.1, Smart Energy, Smart Charging, client, ESP8266, ESP32, Arduino, EVSE, Charge Point, Mongoose",
|
||||||
|
"repository":
|
||||||
|
{
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/matth-x/MicroOcppMongoose/"
|
||||||
|
},
|
||||||
|
"authors":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Matthias Akstaller",
|
||||||
|
"url": "https://www.micro-ocpp.com",
|
||||||
|
"maintainer": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"homepage": "https://www.micro-ocpp.com",
|
||||||
|
"frameworks": "arduino,espidf",
|
||||||
|
"platforms": "espressif8266, espressif32",
|
||||||
|
"export": {
|
||||||
|
"include":
|
||||||
|
[
|
||||||
|
"src/MicroOcppMongooseClient_c.cpp",
|
||||||
|
"src/MicroOcppMongooseClient_c.h",
|
||||||
|
"src/MicroOcppMongooseClient.cpp",
|
||||||
|
"src/MicroOcppMongooseClient.h",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"CMakeLists.txt",
|
||||||
|
"library.json",
|
||||||
|
"LICENSE",
|
||||||
|
"README.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
// matth-x/MicroOcppMongoose
|
||||||
|
// Copyright Matthias Akstaller 2019 - 2024
|
||||||
|
// GPL-3.0 License (see LICENSE)
|
||||||
|
|
||||||
|
#include "MicroOcppMongooseClient.h"
|
||||||
|
#include <MicroOcpp/Core/Configuration.h>
|
||||||
|
#include <MicroOcpp/Debug.h>
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
#include <MicroOcpp/Model/Variables/VariableContainer.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define DEBUG_MSG_INTERVAL 5000UL
|
||||||
|
#define WS_UNRESPONSIVE_THRESHOLD_MS 15000UL
|
||||||
|
|
||||||
|
#define MO_MG_V614 614
|
||||||
|
#define MO_MG_V708 708
|
||||||
|
#define MO_MG_V713 713
|
||||||
|
#define MO_MG_V714 714
|
||||||
|
#define MO_MG_V715 715
|
||||||
|
|
||||||
|
#ifndef MO_MG_USE_VERSION
|
||||||
|
#if defined(MO_MG_VERSION_614)
|
||||||
|
#define MO_MG_USE_VERSION MO_MG_V614
|
||||||
|
#else
|
||||||
|
#define MO_MG_USE_VERSION MO_MG_V708
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
#define MO_MG_F_IS_MOcppMongooseClient MG_F_USER_2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace MicroOcpp {
|
||||||
|
bool validateAuthorizationKeyHex(const char *auth_key_hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
using namespace MicroOcpp;
|
||||||
|
|
||||||
|
#if MO_MG_USE_VERSION <= MO_MG_V708
|
||||||
|
void ws_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data);
|
||||||
|
#else
|
||||||
|
void ws_cb(struct mg_connection *c, int ev, void *ev_data);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
MOcppMongooseClient::MOcppMongooseClient(struct mg_mgr *mgr,
|
||||||
|
const char *backend_url_factory,
|
||||||
|
const char *charge_box_id_factory,
|
||||||
|
unsigned char *auth_key_factory, size_t auth_key_factory_len,
|
||||||
|
const char *ca_certificate,
|
||||||
|
std::shared_ptr<FilesystemAdapter> filesystem,
|
||||||
|
ProtocolVersion protocolVersion) : mgr(mgr), protocolVersion(protocolVersion) {
|
||||||
|
|
||||||
|
bool readonly;
|
||||||
|
|
||||||
|
if (filesystem) {
|
||||||
|
configuration_init(filesystem);
|
||||||
|
|
||||||
|
//all credentials are persistent over reboots
|
||||||
|
readonly = false;
|
||||||
|
} else {
|
||||||
|
//make the credentials non-persistent
|
||||||
|
MO_DBG_WARN("Credentials non-persistent. Use MicroOcpp::makeDefaultFilesystemAdapter(...) for persistency");
|
||||||
|
readonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth_key_factory_len > MO_AUTHKEY_LEN_MAX) {
|
||||||
|
MO_DBG_WARN("auth_key_factory too long - will be cropped");
|
||||||
|
auth_key_factory_len = MO_AUTHKEY_LEN_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
if (protocolVersion.major == 2) {
|
||||||
|
websocketSettings = std::unique_ptr<VariableContainerOwning>(new VariableContainerOwning());
|
||||||
|
if (filesystem) {
|
||||||
|
websocketSettings->enablePersistency(filesystem, MO_WSCONN_FN_V201);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto csmsUrl = makeVariable(Variable::InternalDataType::String, Variable::AttributeType::Actual);
|
||||||
|
csmsUrl->setComponentId("SecurityCtrlr");
|
||||||
|
csmsUrl->setName("CsmsUrl");
|
||||||
|
csmsUrl->setString(backend_url_factory ? backend_url_factory : "");
|
||||||
|
csmsUrl->setPersistent();
|
||||||
|
v201csmsUrlString = csmsUrl.get();
|
||||||
|
websocketSettings->add(std::move(csmsUrl));
|
||||||
|
|
||||||
|
auto identity = makeVariable(Variable::InternalDataType::String, Variable::AttributeType::Actual);
|
||||||
|
identity->setComponentId("SecurityCtrlr");
|
||||||
|
identity->setName("Identity");
|
||||||
|
identity->setString(charge_box_id_factory ? charge_box_id_factory : "");
|
||||||
|
identity->setPersistent();
|
||||||
|
v201identityString = identity.get();
|
||||||
|
websocketSettings->add(std::move(identity));
|
||||||
|
|
||||||
|
auto basicAuthPassword = makeVariable(Variable::InternalDataType::String, Variable::AttributeType::Actual);
|
||||||
|
basicAuthPassword->setComponentId("SecurityCtrlr");
|
||||||
|
basicAuthPassword->setName("BasicAuthPassword");
|
||||||
|
char basicAuthPasswordVal [MO_AUTHKEY_LEN_MAX + 1];
|
||||||
|
snprintf(basicAuthPasswordVal, sizeof(basicAuthPasswordVal), "%.*s", (int)auth_key_factory_len, auth_key_factory ? (const char*)auth_key_factory : "");
|
||||||
|
basicAuthPassword->setString(basicAuthPasswordVal);
|
||||||
|
basicAuthPassword->setPersistent();
|
||||||
|
v201basicAuthPasswordString = basicAuthPassword.get();
|
||||||
|
websocketSettings->add(std::move(basicAuthPassword));
|
||||||
|
|
||||||
|
websocketSettings->load(); //if settings on flash already exist, this overwrites factory defaults
|
||||||
|
} else
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
setting_backend_url_str = declareConfiguration<const char*>(
|
||||||
|
MO_CONFIG_EXT_PREFIX "BackendUrl", backend_url_factory, MO_WSCONN_FN, readonly, true);
|
||||||
|
setting_cb_id_str = declareConfiguration<const char*>(
|
||||||
|
MO_CONFIG_EXT_PREFIX "ChargeBoxId", charge_box_id_factory, MO_WSCONN_FN, readonly, true);
|
||||||
|
|
||||||
|
char auth_key_hex [2 * MO_AUTHKEY_LEN_MAX + 1];
|
||||||
|
auth_key_hex[0] = '\0';
|
||||||
|
if (auth_key_factory) {
|
||||||
|
for (size_t i = 0; i < auth_key_factory_len; i++) {
|
||||||
|
snprintf(auth_key_hex + 2 * i, 3, "%02X", auth_key_factory[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setting_auth_key_hex_str = declareConfiguration<const char*>(
|
||||||
|
"AuthorizationKey", auth_key_hex, MO_WSCONN_FN, readonly, true);
|
||||||
|
registerConfigurationValidator("AuthorizationKey", validateAuthorizationKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws_ping_interval_int = declareConfiguration<int>(
|
||||||
|
"WebSocketPingInterval", 5, MO_WSCONN_FN);
|
||||||
|
reconnect_interval_int = declareConfiguration<int>(
|
||||||
|
MO_CONFIG_EXT_PREFIX "ReconnectInterval", 10, MO_WSCONN_FN);
|
||||||
|
stale_timeout_int = declareConfiguration<int>(
|
||||||
|
MO_CONFIG_EXT_PREFIX "StaleTimeout", 300, MO_WSCONN_FN);
|
||||||
|
|
||||||
|
configuration_load(MO_WSCONN_FN); //load configs with values stored on flash
|
||||||
|
|
||||||
|
ca_cert = ca_certificate;
|
||||||
|
|
||||||
|
reloadConfigs(); //load WS creds with configs values
|
||||||
|
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
MO_DBG_DEBUG("use MG version %s (tested with 6.14)", MG_VERSION);
|
||||||
|
#elif MO_MG_USE_VERSION == MO_MG_V708
|
||||||
|
MO_DBG_DEBUG("use MG version %s (tested with 7.8)", MG_VERSION);
|
||||||
|
#elif MO_MG_USE_VERSION == MO_MG_V713
|
||||||
|
MO_DBG_DEBUG("use MG version %s (tested with 7.13)", MG_VERSION);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
maintainWsConn();
|
||||||
|
}
|
||||||
|
|
||||||
|
MOcppMongooseClient::MOcppMongooseClient(struct mg_mgr *mgr,
|
||||||
|
const char *backend_url_factory,
|
||||||
|
const char *charge_box_id_factory,
|
||||||
|
const char *auth_key_factory,
|
||||||
|
const char *ca_certificate,
|
||||||
|
std::shared_ptr<FilesystemAdapter> filesystem,
|
||||||
|
ProtocolVersion protocolVersion) :
|
||||||
|
|
||||||
|
MOcppMongooseClient(mgr,
|
||||||
|
backend_url_factory,
|
||||||
|
charge_box_id_factory,
|
||||||
|
(unsigned char *)auth_key_factory, auth_key_factory ? strlen(auth_key_factory) : 0,
|
||||||
|
ca_certificate,
|
||||||
|
filesystem,
|
||||||
|
protocolVersion) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
MOcppMongooseClient::~MOcppMongooseClient() {
|
||||||
|
MO_DBG_DEBUG("destruct MOcppMongooseClient");
|
||||||
|
if (websocket) {
|
||||||
|
reconnect(); //close WS connection, won't be reopened
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
websocket->flags &= ~MO_MG_F_IS_MOcppMongooseClient;
|
||||||
|
websocket->user_data = nullptr;
|
||||||
|
#else
|
||||||
|
websocket->fn_data = nullptr;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::loop() {
|
||||||
|
maintainWsConn();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MOcppMongooseClient::sendTXT(const char *msg, size_t length) {
|
||||||
|
if (!websocket || !isConnectionOpen()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
size_t sent;
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
if (websocket->send_mbuf.len > 0) {
|
||||||
|
sent = 0;
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
mg_send_websocket_frame(websocket, WEBSOCKET_OP_TEXT, msg, length);
|
||||||
|
sent = length;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
sent = mg_ws_send(websocket, msg, length, WEBSOCKET_OP_TEXT);
|
||||||
|
#endif
|
||||||
|
if (sent < length) {
|
||||||
|
MO_DBG_WARN("mg_ws_send did only accept %zu out of %zu bytes", sent, length);
|
||||||
|
//flush broken package and wait for next retry
|
||||||
|
(void)0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::maintainWsConn() {
|
||||||
|
if (mocpp_tick_ms() - last_status_dbg_msg >= DEBUG_MSG_INTERVAL) {
|
||||||
|
last_status_dbg_msg = mocpp_tick_ms();
|
||||||
|
|
||||||
|
//WS successfully connected?
|
||||||
|
if (!isConnectionOpen()) {
|
||||||
|
MO_DBG_DEBUG("WS unconnected");
|
||||||
|
} else if (mocpp_tick_ms() - last_recv >= (ws_ping_interval_int && ws_ping_interval_int->getInt() > 0 ? (ws_ping_interval_int->getInt() * 1000UL) : 0UL) + WS_UNRESPONSIVE_THRESHOLD_MS) {
|
||||||
|
//WS connected but unresponsive
|
||||||
|
MO_DBG_DEBUG("WS unresponsive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocket && isConnectionOpen() &&
|
||||||
|
stale_timeout_int && stale_timeout_int->getInt() > 0 && mocpp_tick_ms() - last_recv >= (stale_timeout_int->getInt() * 1000UL)) {
|
||||||
|
MO_DBG_INFO("connection %s -- stale, reconnect", url.c_str());
|
||||||
|
reconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocket && isConnectionOpen() &&
|
||||||
|
ws_ping_interval_int && ws_ping_interval_int->getInt() > 0 && mocpp_tick_ms() - last_hb >= (ws_ping_interval_int->getInt() * 1000UL)) {
|
||||||
|
last_hb = mocpp_tick_ms();
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
mg_send_websocket_frame(websocket, WEBSOCKET_OP_PING, "", 0);
|
||||||
|
#else
|
||||||
|
mg_ws_send(websocket, "", 0, WEBSOCKET_OP_PING);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocket != nullptr) { //connection pointer != nullptr means that the socket is still open
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.empty()) {
|
||||||
|
//cannot open OCPP connection: credentials missing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnect_interval_int && reconnect_interval_int->getInt() > 0 && mocpp_tick_ms() - last_reconnection_attempt < (reconnect_interval_int->getInt() * 1000UL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MO_DBG_DEBUG("(re-)connect to %s", url.c_str());
|
||||||
|
|
||||||
|
last_reconnection_attempt = mocpp_tick_ms();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* determine auth token
|
||||||
|
*/
|
||||||
|
|
||||||
|
std::string basic_auth64;
|
||||||
|
|
||||||
|
if (auth_key_len > 0) {
|
||||||
|
|
||||||
|
#if MO_DBG_LEVEL >= MO_DL_DEBUG
|
||||||
|
{
|
||||||
|
char auth_key_hex [2 * MO_AUTHKEY_LEN_MAX + 1];
|
||||||
|
auth_key_hex[0] = '\0';
|
||||||
|
for (size_t i = 0; i < auth_key_len; i++) {
|
||||||
|
snprintf(auth_key_hex + 2 * i, 3, "%02X", auth_key[i]);
|
||||||
|
}
|
||||||
|
MO_DBG_DEBUG("auth Token=%s:%s (key will be converted to non-hex)", cb_id.c_str(), auth_key_hex);
|
||||||
|
}
|
||||||
|
#endif //MO_DBG_LEVEL >= MO_DL_DEBUG
|
||||||
|
|
||||||
|
unsigned char *token = new unsigned char[cb_id.length() + 1 + auth_key_len]; //cb_id:auth_key
|
||||||
|
if (!token) {
|
||||||
|
//OOM
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t len = 0;
|
||||||
|
memcpy(token, cb_id.c_str(), cb_id.length());
|
||||||
|
len += cb_id.length();
|
||||||
|
token[len++] = (unsigned char) ':';
|
||||||
|
memcpy(token + len, auth_key, auth_key_len);
|
||||||
|
len += auth_key_len;
|
||||||
|
|
||||||
|
int base64_length = ((len + 2) / 3) * 4; //3 bytes base256 get encoded into 4 bytes base64. --> base64_len = ceil(len/3) * 4
|
||||||
|
char *base64 = new char[base64_length + 1];
|
||||||
|
if (!base64) {
|
||||||
|
//OOM
|
||||||
|
delete[] token;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mg_base64_encode() places a null terminator automatically, because the output is a c-string
|
||||||
|
#if MO_MG_USE_VERSION <= MO_MG_V708
|
||||||
|
mg_base64_encode(token, len, base64);
|
||||||
|
#else
|
||||||
|
mg_base64_encode(token, len, base64, base64_length + 1);
|
||||||
|
#endif
|
||||||
|
delete[] token;
|
||||||
|
|
||||||
|
MO_DBG_DEBUG("auth64 len=%u, auth64 Token=%s", base64_length, base64);
|
||||||
|
|
||||||
|
basic_auth64 = &base64[0];
|
||||||
|
|
||||||
|
delete[] base64;
|
||||||
|
} else {
|
||||||
|
MO_DBG_DEBUG("no authentication");
|
||||||
|
(void) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
|
||||||
|
struct mg_connect_opts opts;
|
||||||
|
memset(&opts, 0, sizeof(opts));
|
||||||
|
|
||||||
|
const char *ca_string = ca_cert ? ca_cert : "*"; //"*" enables TLS but disables CA verification
|
||||||
|
|
||||||
|
//Check if SSL is disabled, i.e. if URL starts with "ws:"
|
||||||
|
if (url.length() >= strlen("ws:") &&
|
||||||
|
tolower(url.c_str()[0]) == 'w' &&
|
||||||
|
tolower(url.c_str()[1]) == 's' &&
|
||||||
|
url.c_str()[2] == ':') {
|
||||||
|
//yes, disable SSL
|
||||||
|
ca_string = nullptr;
|
||||||
|
MO_DBG_WARN("Insecure connection (WS)");
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.ssl_ca_cert = ca_string;
|
||||||
|
|
||||||
|
char extra_headers [128] = {'\0'};
|
||||||
|
|
||||||
|
if (!basic_auth64.empty()) {
|
||||||
|
auto ret = snprintf(extra_headers, 128, "Authorization: Basic %s\r\n", basic_auth64.c_str());
|
||||||
|
if (ret < 0 || ret >= 128) {
|
||||||
|
MO_DBG_ERR("Basic Authentication failed: %d", ret);
|
||||||
|
(void)0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
websocket = mg_connect_ws_opt(
|
||||||
|
mgr,
|
||||||
|
ws_cb,
|
||||||
|
this,
|
||||||
|
opts,
|
||||||
|
url.c_str(),
|
||||||
|
protocolVersion.major == 2 ? "ocpp2.0.1" : "ocpp1.6",
|
||||||
|
*extra_headers ? extra_headers : nullptr);
|
||||||
|
|
||||||
|
if (websocket) {
|
||||||
|
websocket->flags |= MO_MG_F_IS_MOcppMongooseClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
websocket = mg_ws_connect(
|
||||||
|
mgr,
|
||||||
|
url.c_str(),
|
||||||
|
ws_cb,
|
||||||
|
this,
|
||||||
|
"Sec-WebSocket-Protocol: %s%s%s\r\n",
|
||||||
|
protocolVersion.major == 2 ? "ocpp2.0.1" : "ocpp1.6",
|
||||||
|
basic_auth64.empty() ? "" : "\r\nAuthorization: Basic ",
|
||||||
|
basic_auth64.empty() ? "" : basic_auth64.c_str()); // Create client
|
||||||
|
#endif
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::reconnect() {
|
||||||
|
if (!websocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
if (!connection_closing) {
|
||||||
|
const char *msg = "socket closed by client";
|
||||||
|
mg_send_websocket_frame(websocket, WEBSOCKET_OP_CLOSE, msg, strlen(msg));
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
websocket->is_closing = 1; //Mongoose will close the socket and the following maintainWsConn() call will open it again
|
||||||
|
#endif
|
||||||
|
setConnectionOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::setBackendUrl(const char *backend_url_cstr) {
|
||||||
|
if (!backend_url_cstr) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
if (protocolVersion.major == 2) {
|
||||||
|
if (v201csmsUrlString) {
|
||||||
|
v201csmsUrlString->setString(backend_url_cstr);
|
||||||
|
websocketSettings->commit();
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
if (setting_backend_url_str) {
|
||||||
|
setting_backend_url_str->setString(backend_url_cstr);
|
||||||
|
configuration_save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::setChargeBoxId(const char *cb_id_cstr) {
|
||||||
|
if (!cb_id_cstr) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
if (protocolVersion.major == 2) {
|
||||||
|
if (v201identityString) {
|
||||||
|
v201identityString->setString(cb_id_cstr);
|
||||||
|
websocketSettings->commit();
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
if (setting_cb_id_str) {
|
||||||
|
setting_cb_id_str->setString(cb_id_cstr);
|
||||||
|
configuration_save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::setAuthKey(const char *auth_key_cstr) {
|
||||||
|
if (!auth_key_cstr) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return setAuthKey((const unsigned char*)auth_key_cstr, strlen(auth_key_cstr));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::setAuthKey(const unsigned char *auth_key, size_t len) {
|
||||||
|
if (!auth_key || len > MO_AUTHKEY_LEN_MAX) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
if (protocolVersion.major == 2) {
|
||||||
|
char basicAuthPassword [MO_AUTHKEY_LEN_MAX + 1];
|
||||||
|
snprintf(basicAuthPassword, sizeof(basicAuthPassword), "%.*s", (int)len, auth_key ? (const char*)auth_key : "");
|
||||||
|
if (v201basicAuthPasswordString) {
|
||||||
|
v201basicAuthPasswordString->setString(basicAuthPassword);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
char auth_key_hex [2 * MO_AUTHKEY_LEN_MAX + 1];
|
||||||
|
auth_key_hex[0] = '\0';
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
snprintf(auth_key_hex + 2 * i, 3, "%02X", auth_key[i]);
|
||||||
|
}
|
||||||
|
if (setting_auth_key_hex_str) {
|
||||||
|
setting_auth_key_hex_str->setString(auth_key_hex);
|
||||||
|
configuration_save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::setCaCert(const char *ca_cert_cstr) {
|
||||||
|
ca_cert = ca_cert_cstr; //updated ca_cert takes immediate effect
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::reloadConfigs() {
|
||||||
|
|
||||||
|
reconnect(); //closes WS connection; will be reopened in next maintainWsConn execution
|
||||||
|
|
||||||
|
/*
|
||||||
|
* reload WS credentials from configs
|
||||||
|
*/
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
if (protocolVersion.major == 2) {
|
||||||
|
if (v201csmsUrlString) {
|
||||||
|
backend_url = v201csmsUrlString->getString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v201identityString) {
|
||||||
|
cb_id = v201identityString->getString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v201basicAuthPasswordString) {
|
||||||
|
snprintf((char*)auth_key, sizeof(auth_key), "%s", v201basicAuthPasswordString->getString());
|
||||||
|
auth_key_len = strlen((char*)auth_key);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
if (setting_backend_url_str) {
|
||||||
|
backend_url = setting_backend_url_str->getString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting_cb_id_str) {
|
||||||
|
cb_id = setting_cb_id_str->getString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting_auth_key_hex_str) {
|
||||||
|
auto auth_key_hex = setting_auth_key_hex_str->getString();
|
||||||
|
auto auth_key_hex_len = strlen(setting_auth_key_hex_str->getString());
|
||||||
|
if (!validateAuthorizationKeyHex(auth_key_hex)) {
|
||||||
|
MO_DBG_ERR("AuthorizationKey stored with format error. Disable Basic Auth");
|
||||||
|
auth_key_hex_len = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_key_len = auth_key_hex_len / 2;
|
||||||
|
|
||||||
|
#if MO_MG_VERSION_614
|
||||||
|
cs_from_hex((char*)auth_key, auth_key_hex, auth_key_hex_len);
|
||||||
|
#elif MO_MG_USE_VERSION <= MO_MG_V713
|
||||||
|
mg_unhex(auth_key_hex, auth_key_hex_len, auth_key);
|
||||||
|
#else
|
||||||
|
for (size_t i = 0; i < auth_key_len; i++) {
|
||||||
|
mg_str_to_num(mg_str_n(auth_key_hex + 2*i, 2), 16, auth_key + i, sizeof(uint8_t));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
auth_key[auth_key_len] = '\0'; //need null-termination as long as deprecated `const char *getAuthKey()` exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* determine new URL with updated WS credentials
|
||||||
|
*/
|
||||||
|
|
||||||
|
url.clear();
|
||||||
|
|
||||||
|
if (backend_url.empty()) {
|
||||||
|
MO_DBG_DEBUG("empty URL closes connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
url = backend_url;
|
||||||
|
|
||||||
|
if (url.back() != '/' && !cb_id.empty()) {
|
||||||
|
url.append("/");
|
||||||
|
}
|
||||||
|
url.append(cb_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
int MOcppMongooseClient::printAuthKey(unsigned char *buf, size_t size) {
|
||||||
|
if (!buf || size < auth_key_len) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(buf, auth_key, auth_key_len);
|
||||||
|
return (int)auth_key_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::setConnectionOpen(bool open) {
|
||||||
|
if (open) {
|
||||||
|
connection_established = true;
|
||||||
|
last_connection_established = mocpp_tick_ms();
|
||||||
|
} else {
|
||||||
|
connection_closing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::cleanConnection() {
|
||||||
|
connection_established = false;
|
||||||
|
connection_closing = false;
|
||||||
|
websocket = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MOcppMongooseClient::updateRcvTimer() {
|
||||||
|
last_recv = mocpp_tick_ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long MOcppMongooseClient::getLastRecv() {
|
||||||
|
return last_recv;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long MOcppMongooseClient::getLastConnected() {
|
||||||
|
return last_connection_established;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
VariableContainer *MOcppMongooseClient::getVariableContainer() {
|
||||||
|
return websocketSettings.get();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if MO_MG_USE_VERSION == MO_MG_V614
|
||||||
|
|
||||||
|
void ws_cb(struct mg_connection *nc, int ev, void *ev_data, void *user_data) {
|
||||||
|
|
||||||
|
MOcppMongooseClient *osock = nullptr;
|
||||||
|
|
||||||
|
if (user_data && nc->flags & MG_F_IS_WEBSOCKET && nc->flags & MO_MG_F_IS_MOcppMongooseClient) {
|
||||||
|
osock = reinterpret_cast<MOcppMongooseClient*>(user_data);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ev) {
|
||||||
|
case MG_EV_CONNECT: {
|
||||||
|
int status = *((int *) ev_data);
|
||||||
|
if (status != 0) {
|
||||||
|
MO_DBG_WARN("connection %s -- error %d", osock->getUrl(), status);
|
||||||
|
(void)0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MG_EV_WEBSOCKET_HANDSHAKE_DONE: {
|
||||||
|
struct http_message *hm = (struct http_message *) ev_data;
|
||||||
|
if (hm->resp_code == 101) {
|
||||||
|
MO_DBG_INFO("connection %s -- connected!", osock->getUrl());
|
||||||
|
osock->setConnectionOpen(true);
|
||||||
|
} else {
|
||||||
|
MO_DBG_WARN("connection %s -- HTTP error %d", osock->getUrl(), hm->resp_code);
|
||||||
|
(void)0;
|
||||||
|
/* Connection will be closed after this. */
|
||||||
|
}
|
||||||
|
osock->updateRcvTimer();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MG_EV_POLL: {
|
||||||
|
/* Nothing to do here. OCPP engine has own loop-function */
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MG_EV_WEBSOCKET_FRAME: {
|
||||||
|
struct websocket_message *wm = (struct websocket_message *) ev_data;
|
||||||
|
|
||||||
|
if (!osock->getReceiveTXTcallback()((const char *) wm->data, wm->size)) { //forward message to Context
|
||||||
|
MO_DBG_ERR("processing WS input failed");
|
||||||
|
(void)0;
|
||||||
|
}
|
||||||
|
osock->updateRcvTimer();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MG_EV_WEBSOCKET_CONTROL_FRAME: {
|
||||||
|
osock->updateRcvTimer();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MG_EV_CLOSE: {
|
||||||
|
MO_DBG_INFO("connection %s -- closed", osock->getUrl());
|
||||||
|
osock->cleanConnection();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
#if MO_MG_USE_VERSION <= MO_MG_V708
|
||||||
|
void ws_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data) {
|
||||||
|
#else
|
||||||
|
void ws_cb(struct mg_connection *c, int ev, void *ev_data) {
|
||||||
|
void *fn_data = c->fn_data;
|
||||||
|
#endif
|
||||||
|
if (ev != 2) {
|
||||||
|
MO_DBG_VERBOSE("Cb fn with event: %d\n", ev);
|
||||||
|
(void)0;
|
||||||
|
}
|
||||||
|
|
||||||
|
MOcppMongooseClient *osock = reinterpret_cast<MOcppMongooseClient*>(fn_data);
|
||||||
|
if (!osock) {
|
||||||
|
if (ev == MG_EV_ERROR || ev == MG_EV_CLOSE) {
|
||||||
|
MO_DBG_INFO("connection %s", ev == MG_EV_CLOSE ? "closed" : "error");
|
||||||
|
(void)0;
|
||||||
|
} else {
|
||||||
|
MO_DBG_ERR("invalid state %d", ev);
|
||||||
|
(void)0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev == MG_EV_ERROR) {
|
||||||
|
// On error, log error message
|
||||||
|
MG_ERROR(("%p %s", c->fd, (char *) ev_data));
|
||||||
|
} else if (ev == MG_EV_CONNECT) {
|
||||||
|
// If target URL is SSL/TLS, command client connection to use TLS
|
||||||
|
if (mg_url_is_ssl(osock->getUrl())) {
|
||||||
|
const char *ca_string = osock->getCaCert();
|
||||||
|
if (ca_string && *ca_string == '\0') { //check if certificate verification is disabled (cert string is empty)
|
||||||
|
//yes, disabled
|
||||||
|
ca_string = nullptr;
|
||||||
|
}
|
||||||
|
struct mg_tls_opts opts;
|
||||||
|
memset(&opts, 0, sizeof(struct mg_tls_opts));
|
||||||
|
#if MO_MG_USE_VERSION <= MO_MG_V708
|
||||||
|
opts.ca = ca_string;
|
||||||
|
opts.srvname = mg_url_host(osock->getUrl());
|
||||||
|
#else
|
||||||
|
opts.ca = mg_str(ca_string);
|
||||||
|
opts.name = mg_url_host(osock->getUrl());
|
||||||
|
#endif
|
||||||
|
mg_tls_init(c, &opts);
|
||||||
|
} else {
|
||||||
|
MO_DBG_WARN("Insecure connection (WS)");
|
||||||
|
}
|
||||||
|
} else if (ev == MG_EV_WS_OPEN) {
|
||||||
|
// WS connection established. Perform MQTT login
|
||||||
|
MO_DBG_INFO("connection %s -- connected!", osock->getUrl());
|
||||||
|
osock->setConnectionOpen(true);
|
||||||
|
osock->updateRcvTimer();
|
||||||
|
} else if (ev == MG_EV_WS_MSG) {
|
||||||
|
struct mg_ws_message *wm = (struct mg_ws_message *) ev_data;
|
||||||
|
#if MO_MG_USE_VERSION <= MO_MG_V713
|
||||||
|
if (!osock->getReceiveTXTcallback()((const char*) wm->data.ptr, wm->data.len)) {
|
||||||
|
#else
|
||||||
|
if (!osock->getReceiveTXTcallback()((const char*) wm->data.buf, wm->data.len)) {
|
||||||
|
#endif
|
||||||
|
MO_DBG_WARN("processing input message failed");
|
||||||
|
}
|
||||||
|
osock->updateRcvTimer();
|
||||||
|
} else if (ev == MG_EV_WS_CTL) {
|
||||||
|
osock->updateRcvTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev == MG_EV_ERROR || ev == MG_EV_CLOSE) {
|
||||||
|
MO_DBG_INFO("connection %s -- %s", osock->getUrl(), ev == MG_EV_CLOSE ? "closed" : "error");
|
||||||
|
osock->cleanConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
bool MicroOcpp::validateAuthorizationKeyHex(const char *auth_key_hex) {
|
||||||
|
if (!auth_key_hex) {
|
||||||
|
return true; //nullptr (or "") means disable Auth
|
||||||
|
}
|
||||||
|
bool valid = true;
|
||||||
|
size_t i = 0;
|
||||||
|
while (i <= 2 * MO_AUTHKEY_LEN_MAX && auth_key_hex[i] != '\0') {
|
||||||
|
//check if character is in 0-9, a-f, or A-F
|
||||||
|
if ( (auth_key_hex[i] >= '0' && auth_key_hex[i] <= '9') ||
|
||||||
|
(auth_key_hex[i] >= 'a' && auth_key_hex[i] <= 'f') ||
|
||||||
|
(auth_key_hex[i] >= 'A' && auth_key_hex[i] <= 'F')) {
|
||||||
|
//yes, it is
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
//no, it isn't
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
valid &= auth_key_hex[i] == '\0';
|
||||||
|
valid &= (i % 2) == 0;
|
||||||
|
if (!valid) {
|
||||||
|
MO_DBG_ERR("AuthorizationKey must be hex with at most 20 octets");
|
||||||
|
(void)0;
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// matth-x/MicroOcppMongoose
|
||||||
|
// Copyright Matthias Akstaller 2019 - 2024
|
||||||
|
// GPL-3.0 License (see LICENSE)
|
||||||
|
|
||||||
|
#ifndef MO_MONGOOSECLIENT_H
|
||||||
|
#define MO_MONGOOSECLIENT_H
|
||||||
|
|
||||||
|
#if defined(ARDUINO) //fix for conflicting definitions of IPAddress on Arduino
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <IPAddress.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "mongoose.h"
|
||||||
|
#include <MicroOcpp/Core/Connection.h>
|
||||||
|
#include <MicroOcpp/Version.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#ifndef MO_WSCONN_FN
|
||||||
|
#define MO_WSCONN_FN (MO_FILENAME_PREFIX "ws-conn.jsn")
|
||||||
|
#define MO_WSCONN_FN_V201 (MO_FILENAME_PREFIX "ws-conn-v201.jsn")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define MO_AUTHKEY_LEN_MAX 63 // Basic Auth password length for both OCPP 1.6 and 2.0.1
|
||||||
|
|
||||||
|
namespace MicroOcpp {
|
||||||
|
|
||||||
|
class FilesystemAdapter;
|
||||||
|
class Configuration;
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
class Variable;
|
||||||
|
class VariableContainer;
|
||||||
|
class VariableContainerOwning;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
class MOcppMongooseClient : public MicroOcpp::Connection {
|
||||||
|
private:
|
||||||
|
struct mg_mgr *mgr {nullptr};
|
||||||
|
struct mg_connection *websocket {nullptr};
|
||||||
|
std::string backend_url;
|
||||||
|
std::string cb_id;
|
||||||
|
std::string url; //url = backend_url + '/' + cb_id
|
||||||
|
unsigned char auth_key [MO_AUTHKEY_LEN_MAX + 1]; // Stores the raw Basic Auth password bytes. Appends a terminating '\0' for legacy accessors.
|
||||||
|
size_t auth_key_len;
|
||||||
|
const char *ca_cert; //zero-copy. The host system must ensure that this pointer remains valid during the lifetime of this class
|
||||||
|
std::shared_ptr<Configuration> setting_backend_url_str;
|
||||||
|
std::shared_ptr<Configuration> setting_cb_id_str;
|
||||||
|
std::shared_ptr<Configuration> setting_auth_key_hex_str;
|
||||||
|
unsigned long last_status_dbg_msg {0}, last_recv {0};
|
||||||
|
std::shared_ptr<Configuration> reconnect_interval_int; //minimum time between two connect trials in s
|
||||||
|
unsigned long last_reconnection_attempt {-1UL / 2UL};
|
||||||
|
std::shared_ptr<Configuration> stale_timeout_int; //inactivity period after which the connection will be closed
|
||||||
|
std::shared_ptr<Configuration> ws_ping_interval_int; //heartbeat intervall in s. 0 sets hb off
|
||||||
|
unsigned long last_hb {0};
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
std::unique_ptr<VariableContainerOwning> websocketSettings;
|
||||||
|
Variable *v201csmsUrlString = nullptr;
|
||||||
|
Variable *v201identityString = nullptr;
|
||||||
|
Variable *v201basicAuthPasswordString = nullptr;
|
||||||
|
#endif
|
||||||
|
bool connection_established {false};
|
||||||
|
unsigned long last_connection_established {-1UL / 2UL};
|
||||||
|
bool connection_closing {false};
|
||||||
|
ReceiveTXTcallback receiveTXTcallback = [] (const char *, size_t) {return false;};
|
||||||
|
|
||||||
|
ProtocolVersion protocolVersion;
|
||||||
|
|
||||||
|
void reconnect();
|
||||||
|
|
||||||
|
void maintainWsConn();
|
||||||
|
|
||||||
|
public:
|
||||||
|
MOcppMongooseClient(struct mg_mgr *mgr,
|
||||||
|
const char *backend_url_factory,
|
||||||
|
const char *charge_box_id_factory,
|
||||||
|
unsigned char *auth_key_factory, size_t auth_key_factory_len,
|
||||||
|
const char *ca_cert = nullptr, //zero-copy, the string must outlive this class and mg_mgr. Forwards this string to Mongoose as ssl_ca_cert (see https://github.com/cesanta/mongoose/blob/ab650ec5c99ceb52bb9dc59e8e8ec92a2724932b/mongoose.h#L4192)
|
||||||
|
std::shared_ptr<MicroOcpp::FilesystemAdapter> filesystem = nullptr,
|
||||||
|
ProtocolVersion protocolVersion = ProtocolVersion(1,6));
|
||||||
|
|
||||||
|
//DEPRECATED: will be removed in a future release
|
||||||
|
MOcppMongooseClient(struct mg_mgr *mgr,
|
||||||
|
const char *backend_url_factory = nullptr,
|
||||||
|
const char *charge_box_id_factory = nullptr,
|
||||||
|
const char *auth_key_factory = nullptr,
|
||||||
|
const char *ca_cert = nullptr, //zero-copy, the string must outlive this class and mg_mgr. Forwards this string to Mongoose as ssl_ca_cert (see https://github.com/cesanta/mongoose/blob/ab650ec5c99ceb52bb9dc59e8e8ec92a2724932b/mongoose.h#L4192)
|
||||||
|
std::shared_ptr<MicroOcpp::FilesystemAdapter> filesystem = nullptr,
|
||||||
|
ProtocolVersion protocolVersion = ProtocolVersion(1,6));
|
||||||
|
|
||||||
|
~MOcppMongooseClient();
|
||||||
|
|
||||||
|
void loop() override;
|
||||||
|
|
||||||
|
bool sendTXT(const char *msg, size_t length) override;
|
||||||
|
|
||||||
|
void setReceiveTXTcallback(MicroOcpp::ReceiveTXTcallback &receiveTXT) override {
|
||||||
|
this->receiveTXTcallback = receiveTXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
MicroOcpp::ReceiveTXTcallback &getReceiveTXTcallback() {
|
||||||
|
return receiveTXTcallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
//update WS configs. To apply the updates, call `reloadConfigs()` afterwards
|
||||||
|
void setBackendUrl(const char *backend_url);
|
||||||
|
void setChargeBoxId(const char *cb_id);
|
||||||
|
void setAuthKey(const char *auth_key); //DEPRECATED: will be removed in a future release
|
||||||
|
void setAuthKey(const unsigned char *auth_key, size_t len); //set the auth key in bytes-encoded format
|
||||||
|
void setCaCert(const char *ca_cert); //forwards this string to Mongoose as ssl_ca_cert (see https://github.com/cesanta/mongoose/blob/ab650ec5c99ceb52bb9dc59e8e8ec92a2724932b/mongoose.h#L4192)
|
||||||
|
|
||||||
|
void reloadConfigs();
|
||||||
|
|
||||||
|
const char *getBackendUrl() {return backend_url.c_str();}
|
||||||
|
const char *getChargeBoxId() {return cb_id.c_str();}
|
||||||
|
const char *getAuthKey() {return (const char*)auth_key;} //DEPRECATED: will be removed in a future release
|
||||||
|
int printAuthKey(unsigned char *buf, size_t size);
|
||||||
|
const char *getCaCert() {return ca_cert ? ca_cert : "";}
|
||||||
|
|
||||||
|
const char *getUrl() {return url.c_str();}
|
||||||
|
|
||||||
|
void setConnectionOpen(bool open);
|
||||||
|
bool isConnectionOpen() {return connection_established && !connection_closing;}
|
||||||
|
bool isConnected() {return isConnectionOpen();}
|
||||||
|
void cleanConnection();
|
||||||
|
|
||||||
|
void updateRcvTimer();
|
||||||
|
unsigned long getLastRecv(); //get time of last successful receive in millis
|
||||||
|
unsigned long getLastConnected(); //get time of last connection establish
|
||||||
|
|
||||||
|
#if MO_ENABLE_V201
|
||||||
|
//WS client creates and manages its own Variables. This getter function is a temporary solution, in future
|
||||||
|
//the WS client will be initialized with a Context reference for registering the Variables directly
|
||||||
|
VariableContainer *getVariableContainer();
|
||||||
|
#endif
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// matth-x/MicroOcppMongoose
|
||||||
|
// Copyright Matthias Akstaller 2019 - 2024
|
||||||
|
// GPL-3.0 License (see LICENSE)
|
||||||
|
|
||||||
|
#include "MicroOcppMongooseClient_c.h"
|
||||||
|
#include "MicroOcppMongooseClient.h"
|
||||||
|
|
||||||
|
#include <MicroOcpp/Core/FilesystemAdapter.h>
|
||||||
|
#include <MicroOcpp/Debug.h>
|
||||||
|
|
||||||
|
using namespace MicroOcpp;
|
||||||
|
|
||||||
|
OCPP_Connection *ocpp_makeConnection(struct mg_mgr *mgr,
|
||||||
|
const char *backend_url_default,
|
||||||
|
const char *charge_box_id_default,
|
||||||
|
const char *auth_key_default,
|
||||||
|
const char *CA_cert_default,
|
||||||
|
OCPP_FilesystemOpt fsopt) {
|
||||||
|
|
||||||
|
std::shared_ptr<MicroOcpp::FilesystemAdapter> filesystem;
|
||||||
|
|
||||||
|
#ifndef MO_DEACTIVATE_FLASH
|
||||||
|
filesystem = makeDefaultFilesystemAdapter(fsopt);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
auto sock = new MOcppMongooseClient(mgr,
|
||||||
|
backend_url_default,
|
||||||
|
charge_box_id_default,
|
||||||
|
auth_key_default,
|
||||||
|
CA_cert_default,
|
||||||
|
filesystem);
|
||||||
|
|
||||||
|
return reinterpret_cast<OCPP_Connection*>(sock);;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ocpp_deinitConnection(OCPP_Connection *sock) {
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
delete mgsock;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ocpp_setBackendUrl(OCPP_Connection *sock, const char *backend_url) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
mgsock->setBackendUrl(backend_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ocpp_setChargeBoxId(OCPP_Connection *sock, const char *cb_id) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
mgsock->setChargeBoxId(cb_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ocpp_setAuthKey(OCPP_Connection *sock, const char *auth_key) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
mgsock->setAuthKey(auth_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ocpp_setCaCert(OCPP_Connection *sock, const char *ca_cert) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
mgsock->setCaCert(ca_cert);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ocpp_reloadConfigs(OCPP_Connection *sock) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
mgsock->reloadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ocpp_getBackendUrl(OCPP_Connection *sock) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
return mgsock->getBackendUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ocpp_getChargeBoxId(OCPP_Connection *sock) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
return mgsock->getChargeBoxId();
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ocpp_getAuthKey(OCPP_Connection *sock) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
return mgsock->getAuthKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ocpp_getCaCert(OCPP_Connection *sock) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
return mgsock->getCaCert();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ocpp_isConnectionOpen(OCPP_Connection *sock) {
|
||||||
|
if (!sock) {
|
||||||
|
MO_DBG_ERR("invalid argument");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
|
||||||
|
return mgsock->isConnectionOpen();
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// matth-x/MicroOcppMongoose
|
||||||
|
// Copyright Matthias Akstaller 2019 - 2024
|
||||||
|
// GPL-3.0 License (see LICENSE)
|
||||||
|
|
||||||
|
#ifndef MO_MONGOOSECLIENT_C_H
|
||||||
|
#define MO_MONGOOSECLIENT_C_H
|
||||||
|
|
||||||
|
#if defined(__cplusplus) && defined(ARDUINO) //fix for conflicting defitions of IPAddress on Arduino
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <IPAddress.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "mongoose.h"
|
||||||
|
#include <MicroOcpp/Core/ConfigurationOptions.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct OCPP_Connection;
|
||||||
|
typedef struct OCPP_Connection OCPP_Connection;
|
||||||
|
|
||||||
|
OCPP_Connection *ocpp_makeConnection(struct mg_mgr *mgr,
|
||||||
|
const char *backend_url_default, //all cstrings can be NULL
|
||||||
|
const char *charge_box_id_default,
|
||||||
|
const char *auth_key_default,
|
||||||
|
const char *CA_cert_default,
|
||||||
|
struct OCPP_FilesystemOpt fsopt);
|
||||||
|
|
||||||
|
void ocpp_deinitConnection(OCPP_Connection *sock);
|
||||||
|
|
||||||
|
//update WS configs. To apply the updates, call `ocpp_reloadConfigs()` afterwards
|
||||||
|
void ocpp_setBackendUrl(OCPP_Connection *sock, const char *backend_url);
|
||||||
|
void ocpp_setChargeBoxId(OCPP_Connection *sock, const char *cb_id);
|
||||||
|
void ocpp_setAuthKey(OCPP_Connection *sock, const char *auth_key);
|
||||||
|
void ocpp_setCaCert(OCPP_Connection *sock, const char *ca_cert);
|
||||||
|
|
||||||
|
void ocpp_reloadConfigs(OCPP_Connection *sock);
|
||||||
|
|
||||||
|
const char *ocpp_getBackendUrl(OCPP_Connection *sock);
|
||||||
|
const char *ocpp_getChargeBoxId(OCPP_Connection *sock);
|
||||||
|
const char *ocpp_getAuthKey(OCPP_Connection *sock);
|
||||||
|
const char *ocpp_getCaCert(OCPP_Connection *sock);
|
||||||
|
|
||||||
|
bool ocpp_isConnectionOpen(OCPP_Connection *sock);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1 +1,3 @@
|
|||||||
#define MG_ARCH MG_ARCH_ESP32
|
#define MG_ARCH MG_ARCH_ESP32
|
||||||
|
// Enable TLS support using mbedTLS (built into ESP32)
|
||||||
|
#define MG_TLS MG_TLS_MBED
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ board = rymcu-esp32-devkitc
|
|||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
matth-x/MicroOcpp@^1.2.0
|
matth-x/MicroOcpp@^1.2.0
|
||||||
matth-x/MicroOcppMongoose@^1.2.0
|
|
||||||
roboticsbrno/SmartLeds@^3.1.5
|
roboticsbrno/SmartLeds@^3.1.5
|
||||||
miguelbalboa/MFRC522@^1.4.12
|
miguelbalboa/MFRC522@^1.4.12
|
||||||
tzapu/WiFiManager@^2.0.17
|
tzapu/WiFiManager@^2.0.17
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
// OCPP 1.6-J: MOcppMongooseClient will append "/<CFG_CP_IDENTIFIER>" to this URL.
|
// OCPP 1.6-J: MOcppMongooseClient will append "/<CFG_CP_IDENTIFIER>" to this URL.
|
||||||
// For local dev: ws://<host>:3001/ocpp
|
// For local dev: ws://<host>:3001/ocpp
|
||||||
// For production: ws://csms.helios.bh8.ga:8180/steve/websocket/CentralSystemService
|
// For production: ws://csms.helios.bh8.ga:8180/steve/websocket/CentralSystemService
|
||||||
#define CFG_OCPP_BACKEND "ws://csms.helios.bh8.ga:8180/steve/websocket/CentralSystemService"
|
#define CFG_OCPP_BACKEND "wss://csms-server.uniiem.com/ocpp"
|
||||||
#define CFG_CP_IDENTIFIER "CQWU_HHB_0001"
|
// #define CFG_CP_IDENTIFIER "CQWU_HHB_0001"
|
||||||
|
#define CFG_CP_IDENTIFIER ""
|
||||||
#define CFG_CB_SERIAL "REDAone_prototype00"
|
#define CFG_CB_SERIAL "REDAone_prototype00"
|
||||||
#define CFG_CP_FW_VERSION "1.0.0"
|
#define CFG_CP_FW_VERSION "1.0.0"
|
||||||
#define CFG_CP_MODAL "Helios DA One"
|
#define CFG_CP_MODAL "Helios DA One"
|
||||||
#define CFG_CP_VENDOR "RayineElec"
|
#define CFG_CP_VENDOR "RayineElec"
|
||||||
#define CFG_AUTHORIZATIONKEY "my_secret_key"
|
// OCPP Security Profile 1: sent as Authorization: Basic base64(<chargePointIdentifier>:<password>)
|
||||||
|
// Set to nullptr to disable authentication
|
||||||
|
// #define CFG_OCPP_PASSWORD "my_password"
|
||||||
|
#define CFG_OCPP_PASSWORD nullptr
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <WiFiManager.h>
|
#include <WiFiManager.h>
|
||||||
|
#include <Preferences.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include <MicroOcpp.h>
|
#include <MicroOcpp.h>
|
||||||
@@ -18,7 +19,9 @@ enum LEDState
|
|||||||
LED_INITIALIZING, // Blue blinking - Initialization and WiFi connecting
|
LED_INITIALIZING, // Blue blinking - Initialization and WiFi connecting
|
||||||
LED_WIFI_CONNECTED, // Blue solid - WiFi connected, connecting to OCPP server
|
LED_WIFI_CONNECTED, // Blue solid - WiFi connected, connecting to OCPP server
|
||||||
LED_OCPP_CONNECTED, // Green solid - Successfully connected to OCPP server
|
LED_OCPP_CONNECTED, // Green solid - Successfully connected to OCPP server
|
||||||
LED_ERROR // Red - Error state
|
LED_ERROR, // Red solid - Error state
|
||||||
|
LED_RESET_TX, // Yellow solid - 3s BOOT button hold (Ready to clear transaction)
|
||||||
|
LED_FACTORY_RESET // Magenta fast blink - 7s BOOT button hold (Ready to factory reset)
|
||||||
};
|
};
|
||||||
|
|
||||||
static int s_retry_num = 0;
|
static int s_retry_num = 0;
|
||||||
@@ -31,6 +34,19 @@ static const unsigned long BLINK_INTERVAL = 200; // 200ms blink interval
|
|||||||
uint8_t mac[6];
|
uint8_t mac[6];
|
||||||
char cpSerial[13];
|
char cpSerial[13];
|
||||||
|
|
||||||
|
// OCPP Configuration Variables
|
||||||
|
char ocpp_backend[128];
|
||||||
|
char cp_identifier[64];
|
||||||
|
char ocpp_password[64];
|
||||||
|
bool shouldSaveConfig = false;
|
||||||
|
|
||||||
|
// callback notifying us of the need to save config
|
||||||
|
void saveConfigCallback()
|
||||||
|
{
|
||||||
|
Serial.println("Should save config");
|
||||||
|
shouldSaveConfig = true;
|
||||||
|
}
|
||||||
|
|
||||||
struct mg_mgr mgr;
|
struct mg_mgr mgr;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +102,38 @@ void updateLED()
|
|||||||
leds[0] = Rgb{255, 0, 0}; // Red solid
|
leds[0] = Rgb{255, 0, 0}; // Red solid
|
||||||
leds.show();
|
leds.show();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case LED_RESET_TX:
|
||||||
|
// Yellow fast blink - Ready to clear transaction
|
||||||
|
if (current_time - s_blink_last_time >= 100)
|
||||||
|
{
|
||||||
|
s_blink_last_time = current_time;
|
||||||
|
s_blink_on = !s_blink_on;
|
||||||
|
|
||||||
|
if (s_blink_on)
|
||||||
|
leds[0] = Rgb{150, 150, 0}; // Yellow
|
||||||
|
else
|
||||||
|
leds[0] = Rgb{0, 0, 0};
|
||||||
|
|
||||||
|
leds.show();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LED_FACTORY_RESET:
|
||||||
|
// Magenta fast blink - Ready to factory reset
|
||||||
|
if (current_time - s_blink_last_time >= 100)
|
||||||
|
{
|
||||||
|
s_blink_last_time = current_time;
|
||||||
|
s_blink_on = !s_blink_on;
|
||||||
|
|
||||||
|
if (s_blink_on)
|
||||||
|
leds[0] = Rgb{255, 0, 255}; // Magenta
|
||||||
|
else
|
||||||
|
leds[0] = Rgb{0, 0, 0};
|
||||||
|
|
||||||
|
leds.show();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +144,18 @@ void setup()
|
|||||||
snprintf(cpSerial, sizeof(cpSerial),
|
snprintf(cpSerial, sizeof(cpSerial),
|
||||||
"%02X%02X%02X%02X%02X%02X",
|
"%02X%02X%02X%02X%02X%02X",
|
||||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||||
|
|
||||||
|
if (strlen(CFG_CP_IDENTIFIER) > 0)
|
||||||
|
{
|
||||||
|
strncpy(cp_identifier, CFG_CP_IDENTIFIER, sizeof(cp_identifier) - 1);
|
||||||
|
cp_identifier[sizeof(cp_identifier) - 1] = '\0';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Auto-generate Charge Point Identifier based on MAC (e.g. HLCP_A1B2C3)
|
||||||
|
snprintf(cp_identifier, sizeof(cp_identifier), "HLCP_%s", cpSerial + 6);
|
||||||
|
}
|
||||||
|
|
||||||
// reset LED
|
// reset LED
|
||||||
leds[0] = Rgb{0, 0, 0};
|
leds[0] = Rgb{0, 0, 0};
|
||||||
leds.show();
|
leds.show();
|
||||||
@@ -103,6 +163,7 @@ void setup()
|
|||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(1000);
|
delay(1000);
|
||||||
Serial.printf("\n\n%s(%s) made by %s\n", CFG_CP_MODAL, cpSerial, CFG_CP_VENDOR);
|
Serial.printf("\n\n%s(%s) made by %s\n", CFG_CP_MODAL, cpSerial, CFG_CP_VENDOR);
|
||||||
|
Serial.printf("Charge Point Identifier: %s\n", cp_identifier);
|
||||||
Serial.println("Initializing firmware...\n");
|
Serial.println("Initializing firmware...\n");
|
||||||
|
|
||||||
// Initialize LED
|
// Initialize LED
|
||||||
@@ -113,7 +174,32 @@ void setup()
|
|||||||
leds[0] = Rgb{255, 255, 0};
|
leds[0] = Rgb{255, 255, 0};
|
||||||
leds.show();
|
leds.show();
|
||||||
|
|
||||||
|
// Load configuration from Preferences
|
||||||
|
Preferences preferences;
|
||||||
|
preferences.begin("ocpp-config", false);
|
||||||
|
String b = preferences.getString("backend", CFG_OCPP_BACKEND);
|
||||||
|
String p = preferences.getString("ocpp_password", CFG_OCPP_PASSWORD ? CFG_OCPP_PASSWORD : "");
|
||||||
|
|
||||||
|
Serial.printf("\n[OCPP] Loaded Backend URL: %s\n", b.c_str());
|
||||||
|
Serial.printf("[OCPP] Loaded Password length: %d\n", p.length());
|
||||||
|
|
||||||
|
strncpy(ocpp_backend, b.c_str(), sizeof(ocpp_backend) - 1);
|
||||||
|
ocpp_backend[sizeof(ocpp_backend) - 1] = '\0';
|
||||||
|
strncpy(ocpp_password, p.c_str(), sizeof(ocpp_password) - 1);
|
||||||
|
ocpp_password[sizeof(ocpp_password) - 1] = '\0';
|
||||||
|
|
||||||
WiFiManager wm;
|
WiFiManager wm;
|
||||||
|
wm.setSaveConfigCallback(saveConfigCallback);
|
||||||
|
wm.setSaveParamsCallback(saveConfigCallback);
|
||||||
|
wm.setParamsPage(true);
|
||||||
|
|
||||||
|
// Use autocomplete=off to prevent browsers from autofilling old URLs after a reset
|
||||||
|
WiFiManagerParameter custom_ocpp_backend("backend", "OCPP Backend URL", ocpp_backend, 128, "autocomplete=\"off\"");
|
||||||
|
WiFiManagerParameter custom_ocpp_password("ocpp_password", "OCPP Basic AuthKey", ocpp_password, 64, "autocomplete=\"off\" type=\"password\"");
|
||||||
|
|
||||||
|
wm.addParameter(&custom_ocpp_backend);
|
||||||
|
wm.addParameter(&custom_ocpp_password);
|
||||||
|
|
||||||
const char *customHeadElement = R"rawliteral(
|
const char *customHeadElement = R"rawliteral(
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -255,7 +341,21 @@ void setup()
|
|||||||
</style>
|
</style>
|
||||||
)rawliteral";
|
)rawliteral";
|
||||||
wm.setCustomHeadElement(customHeadElement);
|
wm.setCustomHeadElement(customHeadElement);
|
||||||
bool autoConnectRet = wm.autoConnect((String("HLCP_") + String(cpSerial).substring(String(cpSerial).length() - 6)).c_str(), cpSerial);
|
bool autoConnectRet = wm.autoConnect(cp_identifier, cpSerial);
|
||||||
|
|
||||||
|
if (shouldSaveConfig)
|
||||||
|
{
|
||||||
|
strncpy(ocpp_backend, custom_ocpp_backend.getValue(), sizeof(ocpp_backend) - 1);
|
||||||
|
ocpp_backend[sizeof(ocpp_backend) - 1] = '\0';
|
||||||
|
strncpy(ocpp_password, custom_ocpp_password.getValue(), sizeof(ocpp_password) - 1);
|
||||||
|
ocpp_password[sizeof(ocpp_password) - 1] = '\0';
|
||||||
|
|
||||||
|
preferences.putString("backend", ocpp_backend);
|
||||||
|
preferences.putString("ocpp_password", ocpp_password);
|
||||||
|
Serial.println("Saved new OCPP config to Preferences");
|
||||||
|
}
|
||||||
|
preferences.end();
|
||||||
|
|
||||||
if (!autoConnectRet)
|
if (!autoConnectRet)
|
||||||
{
|
{
|
||||||
Serial.println("Failed to connect and hit timeout");
|
Serial.println("Failed to connect and hit timeout");
|
||||||
@@ -267,8 +367,52 @@ void setup()
|
|||||||
s_led_state = LED_WIFI_CONNECTED;
|
s_led_state = LED_WIFI_CONNECTED;
|
||||||
|
|
||||||
mg_mgr_init(&mgr);
|
mg_mgr_init(&mgr);
|
||||||
MicroOcpp::MOcppMongooseClient *client = new MicroOcpp::MOcppMongooseClient(&mgr, CFG_OCPP_BACKEND, CFG_CP_IDENTIFIER, CFG_AUTHORIZATIONKEY, "", MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail), MicroOcpp::ProtocolVersion(1, 6));
|
const char *basic_auth_password = (strlen(ocpp_password) > 0) ? ocpp_password : nullptr;
|
||||||
|
unsigned char *basic_auth_password_bytes = nullptr;
|
||||||
|
size_t basic_auth_password_len = 0;
|
||||||
|
|
||||||
|
if (basic_auth_password)
|
||||||
|
{
|
||||||
|
basic_auth_password_bytes = reinterpret_cast<unsigned char *>(const_cast<char *>(basic_auth_password));
|
||||||
|
basic_auth_password_len = strlen(basic_auth_password);
|
||||||
|
}
|
||||||
|
|
||||||
|
MicroOcpp::MOcppMongooseClient *client = new MicroOcpp::MOcppMongooseClient(
|
||||||
|
&mgr,
|
||||||
|
ocpp_backend,
|
||||||
|
cp_identifier,
|
||||||
|
nullptr,
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail),
|
||||||
|
MicroOcpp::ProtocolVersion(1, 6));
|
||||||
|
|
||||||
|
// Preferences and firmware config are the source of truth. Override any stale
|
||||||
|
// values cached in MicroOcpp's ws-conn storage before the first reconnect cycle.
|
||||||
|
client->setBackendUrl(ocpp_backend);
|
||||||
|
client->setChargeBoxId(cp_identifier);
|
||||||
|
if (basic_auth_password_bytes)
|
||||||
|
{
|
||||||
|
client->setAuthKey(basic_auth_password_bytes, basic_auth_password_len);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
client->setAuthKey(reinterpret_cast<const unsigned char *>(""), 0);
|
||||||
|
}
|
||||||
|
client->reloadConfigs();
|
||||||
|
|
||||||
mocpp_initialize(*client, ChargerCredentials(CFG_CP_MODAL, CFG_CP_VENDOR, CFG_CP_FW_VERSION, cpSerial, nullptr, nullptr, CFG_CB_SERIAL, nullptr, nullptr), MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail));
|
mocpp_initialize(*client, ChargerCredentials(CFG_CP_MODAL, CFG_CP_VENDOR, CFG_CP_FW_VERSION, cpSerial, nullptr, nullptr, CFG_CB_SERIAL, nullptr, nullptr), MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail));
|
||||||
|
|
||||||
|
// For development/recovery: Set up BOOT button (GPIO 0)
|
||||||
|
pinMode(0, INPUT_PULLUP);
|
||||||
|
|
||||||
|
// Forcefully accept rejected RemoteStopTransaction (if hardware goes out of sync with CSMS)
|
||||||
|
setOnSendConf("RemoteStopTransaction", [](JsonObject payload)
|
||||||
|
{
|
||||||
|
if (!strcmp(payload["status"], "Rejected")) {
|
||||||
|
Serial.println("[main] MicroOcpp rejected RemoteStopTransaction! Force overriding and stopping charging...");
|
||||||
|
endTransaction(nullptr, "Remote", 1);
|
||||||
|
} });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,19 +421,89 @@ void loop()
|
|||||||
mg_mgr_poll(&mgr, 10);
|
mg_mgr_poll(&mgr, 10);
|
||||||
mocpp_loop();
|
mocpp_loop();
|
||||||
|
|
||||||
auto ctx = getOcppContext();
|
// Handle BOOT button (GPIO 0) interactions for recovery
|
||||||
if (ctx && ctx->getConnection().isConnected())
|
bool is_btn_pressed = (digitalRead(0) == LOW);
|
||||||
|
static unsigned long boot_press_time = 0;
|
||||||
|
static bool boot_was_pressed = false;
|
||||||
|
|
||||||
|
if (is_btn_pressed)
|
||||||
{
|
{
|
||||||
if (s_led_state != LED_OCPP_CONNECTED)
|
if (!boot_was_pressed)
|
||||||
{
|
{
|
||||||
s_led_state = LED_OCPP_CONNECTED;
|
boot_was_pressed = true;
|
||||||
|
boot_press_time = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long held_time = millis() - boot_press_time;
|
||||||
|
if (held_time >= 7000)
|
||||||
|
{
|
||||||
|
s_led_state = LED_FACTORY_RESET;
|
||||||
|
}
|
||||||
|
else if (held_time >= 3000)
|
||||||
|
{
|
||||||
|
s_led_state = LED_RESET_TX;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (s_led_state != LED_WIFI_CONNECTED)
|
if (boot_was_pressed)
|
||||||
{
|
{
|
||||||
s_led_state = LED_WIFI_CONNECTED;
|
unsigned long held_time = millis() - boot_press_time;
|
||||||
|
if (held_time >= 7000)
|
||||||
|
{
|
||||||
|
Serial.println("BOOT button held for > 7s! Clearing WiFi and OCPP settings, then restarting...");
|
||||||
|
|
||||||
|
// Clear WiFi completely
|
||||||
|
WiFi.disconnect(true, true);
|
||||||
|
WiFiManager wm;
|
||||||
|
wm.resetSettings();
|
||||||
|
|
||||||
|
// Clear Preferences explicitely
|
||||||
|
Preferences preferences;
|
||||||
|
preferences.begin("ocpp-config", false);
|
||||||
|
preferences.remove("backend");
|
||||||
|
preferences.remove("ocpp_password");
|
||||||
|
preferences.clear();
|
||||||
|
preferences.end();
|
||||||
|
Serial.println("NVS ocpp-config cleared.");
|
||||||
|
|
||||||
|
// Clear MicroOcpp FS configs (this removes MO's cached URL)
|
||||||
|
auto fs = MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail);
|
||||||
|
fs->remove(MO_WSCONN_FN);
|
||||||
|
Serial.println("MicroOcpp config cache cleared.");
|
||||||
|
|
||||||
|
// Give time for serial to print and NVS to sync
|
||||||
|
delay(1000);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
else if (held_time >= 3000)
|
||||||
|
{
|
||||||
|
Serial.println("BOOT button held for > 3s! Forcefully ending dangling transaction...");
|
||||||
|
endTransaction(nullptr, "PowerLoss", 1);
|
||||||
|
}
|
||||||
|
boot_was_pressed = false;
|
||||||
|
// Temporarily set to init so the logic below restores the actual network state accurately
|
||||||
|
s_led_state = LED_INITIALIZING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update default LED states if button is not overriding them
|
||||||
|
if (!is_btn_pressed)
|
||||||
|
{
|
||||||
|
auto ctx = getOcppContext();
|
||||||
|
if (ctx && ctx->getConnection().isConnected())
|
||||||
|
{
|
||||||
|
if (s_led_state != LED_OCPP_CONNECTED)
|
||||||
|
{
|
||||||
|
s_led_state = LED_OCPP_CONNECTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (s_led_state != LED_WIFI_CONNECTED)
|
||||||
|
{
|
||||||
|
s_led_state = LED_WIFI_CONNECTED;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
hardware/pcb/.gitignore
vendored
Normal file
2
hardware/pcb/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*-backups
|
||||||
|
|
||||||
2
hardware/pcb/HeliosDAONE.kicad_pcb
Normal file
2
hardware/pcb/HeliosDAONE.kicad_pcb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
(kicad_pcb (version 20241229) (generator "pcbnew") (generator_version "9.0")
|
||||||
|
)
|
||||||
99
hardware/pcb/HeliosDAONE.kicad_prl
Normal file
99
hardware/pcb/HeliosDAONE.kicad_prl
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"board": {
|
||||||
|
"active_layer": 0,
|
||||||
|
"active_layer_preset": "",
|
||||||
|
"auto_track_width": true,
|
||||||
|
"hidden_netclasses": [],
|
||||||
|
"hidden_nets": [],
|
||||||
|
"high_contrast_mode": 0,
|
||||||
|
"net_color_mode": 1,
|
||||||
|
"opacity": {
|
||||||
|
"images": 0.6,
|
||||||
|
"pads": 1.0,
|
||||||
|
"shapes": 1.0,
|
||||||
|
"tracks": 1.0,
|
||||||
|
"vias": 1.0,
|
||||||
|
"zones": 0.6
|
||||||
|
},
|
||||||
|
"selection_filter": {
|
||||||
|
"dimensions": true,
|
||||||
|
"footprints": true,
|
||||||
|
"graphics": true,
|
||||||
|
"keepouts": true,
|
||||||
|
"lockedItems": false,
|
||||||
|
"otherItems": true,
|
||||||
|
"pads": true,
|
||||||
|
"text": true,
|
||||||
|
"tracks": true,
|
||||||
|
"vias": true,
|
||||||
|
"zones": true
|
||||||
|
},
|
||||||
|
"visible_items": [
|
||||||
|
"vias",
|
||||||
|
"footprint_text",
|
||||||
|
"footprint_anchors",
|
||||||
|
"ratsnest",
|
||||||
|
"grid",
|
||||||
|
"footprints_front",
|
||||||
|
"footprints_back",
|
||||||
|
"footprint_values",
|
||||||
|
"footprint_references",
|
||||||
|
"tracks",
|
||||||
|
"drc_errors",
|
||||||
|
"drawing_sheet",
|
||||||
|
"bitmaps",
|
||||||
|
"pads",
|
||||||
|
"zones",
|
||||||
|
"drc_warnings",
|
||||||
|
"drc_exclusions",
|
||||||
|
"locked_item_shadows",
|
||||||
|
"conflict_shadows",
|
||||||
|
"shapes"
|
||||||
|
],
|
||||||
|
"visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff",
|
||||||
|
"zone_display_mode": 0
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"integration_disabled": false,
|
||||||
|
"repo_type": "",
|
||||||
|
"repo_username": "",
|
||||||
|
"ssh_key": ""
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"filename": "HeliosDAONE.kicad_prl",
|
||||||
|
"version": 5
|
||||||
|
},
|
||||||
|
"net_inspector_panel": {
|
||||||
|
"col_hidden": [],
|
||||||
|
"col_order": [],
|
||||||
|
"col_widths": [],
|
||||||
|
"custom_group_rules": [],
|
||||||
|
"expanded_rows": [],
|
||||||
|
"filter_by_net_name": true,
|
||||||
|
"filter_by_netclass": true,
|
||||||
|
"filter_text": "",
|
||||||
|
"group_by_constraint": false,
|
||||||
|
"group_by_netclass": false,
|
||||||
|
"show_unconnected_nets": false,
|
||||||
|
"show_zero_pad_nets": false,
|
||||||
|
"sort_ascending": true,
|
||||||
|
"sorting_column": -1
|
||||||
|
},
|
||||||
|
"open_jobsets": [],
|
||||||
|
"project": {
|
||||||
|
"files": []
|
||||||
|
},
|
||||||
|
"schematic": {
|
||||||
|
"selection_filter": {
|
||||||
|
"graphics": true,
|
||||||
|
"images": true,
|
||||||
|
"labels": true,
|
||||||
|
"lockedItems": false,
|
||||||
|
"otherItems": true,
|
||||||
|
"pins": true,
|
||||||
|
"symbols": true,
|
||||||
|
"text": true,
|
||||||
|
"wires": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
418
hardware/pcb/HeliosDAONE.kicad_pro
Normal file
418
hardware/pcb/HeliosDAONE.kicad_pro
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
{
|
||||||
|
"board": {
|
||||||
|
"3dviewports": [],
|
||||||
|
"design_settings": {
|
||||||
|
"defaults": {},
|
||||||
|
"diff_pair_dimensions": [],
|
||||||
|
"drc_exclusions": [],
|
||||||
|
"rules": {},
|
||||||
|
"track_widths": [],
|
||||||
|
"via_dimensions": []
|
||||||
|
},
|
||||||
|
"ipc2581": {
|
||||||
|
"dist": "",
|
||||||
|
"distpn": "",
|
||||||
|
"internal_id": "",
|
||||||
|
"mfg": "",
|
||||||
|
"mpn": ""
|
||||||
|
},
|
||||||
|
"layer_pairs": [],
|
||||||
|
"layer_presets": [],
|
||||||
|
"viewports": []
|
||||||
|
},
|
||||||
|
"boards": [],
|
||||||
|
"cvpcb": {
|
||||||
|
"equivalence_files": []
|
||||||
|
},
|
||||||
|
"erc": {
|
||||||
|
"erc_exclusions": [],
|
||||||
|
"meta": {
|
||||||
|
"version": 0
|
||||||
|
},
|
||||||
|
"pin_map": [
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"rule_severities": {
|
||||||
|
"bus_definition_conflict": "error",
|
||||||
|
"bus_entry_needed": "error",
|
||||||
|
"bus_to_bus_conflict": "error",
|
||||||
|
"bus_to_net_conflict": "error",
|
||||||
|
"different_unit_footprint": "error",
|
||||||
|
"different_unit_net": "error",
|
||||||
|
"duplicate_reference": "error",
|
||||||
|
"duplicate_sheet_names": "error",
|
||||||
|
"endpoint_off_grid": "warning",
|
||||||
|
"extra_units": "error",
|
||||||
|
"footprint_filter": "ignore",
|
||||||
|
"footprint_link_issues": "warning",
|
||||||
|
"four_way_junction": "ignore",
|
||||||
|
"global_label_dangling": "warning",
|
||||||
|
"hier_label_mismatch": "error",
|
||||||
|
"label_dangling": "error",
|
||||||
|
"label_multiple_wires": "warning",
|
||||||
|
"lib_symbol_issues": "warning",
|
||||||
|
"lib_symbol_mismatch": "warning",
|
||||||
|
"missing_bidi_pin": "warning",
|
||||||
|
"missing_input_pin": "warning",
|
||||||
|
"missing_power_pin": "error",
|
||||||
|
"missing_unit": "warning",
|
||||||
|
"multiple_net_names": "warning",
|
||||||
|
"net_not_bus_member": "warning",
|
||||||
|
"no_connect_connected": "warning",
|
||||||
|
"no_connect_dangling": "warning",
|
||||||
|
"pin_not_connected": "error",
|
||||||
|
"pin_not_driven": "error",
|
||||||
|
"pin_to_pin": "warning",
|
||||||
|
"power_pin_not_driven": "error",
|
||||||
|
"same_local_global_label": "warning",
|
||||||
|
"similar_label_and_power": "warning",
|
||||||
|
"similar_labels": "warning",
|
||||||
|
"similar_power": "warning",
|
||||||
|
"simulation_model_issue": "ignore",
|
||||||
|
"single_global_label": "ignore",
|
||||||
|
"unannotated": "error",
|
||||||
|
"unconnected_wire_endpoint": "warning",
|
||||||
|
"undefined_netclass": "error",
|
||||||
|
"unit_value_mismatch": "error",
|
||||||
|
"unresolved_variable": "error",
|
||||||
|
"wire_dangling": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraries": {
|
||||||
|
"pinned_footprint_libs": [],
|
||||||
|
"pinned_symbol_libs": []
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"filename": "HeliosDAONE.kicad_pro",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"net_settings": {
|
||||||
|
"classes": [
|
||||||
|
{
|
||||||
|
"bus_width": 12,
|
||||||
|
"clearance": 0.2,
|
||||||
|
"diff_pair_gap": 0.25,
|
||||||
|
"diff_pair_via_gap": 0.25,
|
||||||
|
"diff_pair_width": 0.2,
|
||||||
|
"line_style": 0,
|
||||||
|
"microvia_diameter": 0.3,
|
||||||
|
"microvia_drill": 0.1,
|
||||||
|
"name": "Default",
|
||||||
|
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||||
|
"priority": 2147483647,
|
||||||
|
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||||
|
"track_width": 0.2,
|
||||||
|
"via_diameter": 0.6,
|
||||||
|
"via_drill": 0.3,
|
||||||
|
"wire_width": 6
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"version": 4
|
||||||
|
},
|
||||||
|
"net_colors": null,
|
||||||
|
"netclass_assignments": null,
|
||||||
|
"netclass_patterns": []
|
||||||
|
},
|
||||||
|
"pcbnew": {
|
||||||
|
"last_paths": {
|
||||||
|
"gencad": "",
|
||||||
|
"idf": "",
|
||||||
|
"netlist": "",
|
||||||
|
"plot": "",
|
||||||
|
"pos_files": "",
|
||||||
|
"specctra_dsn": "",
|
||||||
|
"step": "",
|
||||||
|
"svg": "",
|
||||||
|
"vrml": ""
|
||||||
|
},
|
||||||
|
"page_layout_descr_file": ""
|
||||||
|
},
|
||||||
|
"schematic": {
|
||||||
|
"annotate_start_num": 0,
|
||||||
|
"bom_export_filename": "${PROJECTNAME}.csv",
|
||||||
|
"bom_fmt_presets": [],
|
||||||
|
"bom_fmt_settings": {
|
||||||
|
"field_delimiter": ",",
|
||||||
|
"keep_line_breaks": false,
|
||||||
|
"keep_tabs": false,
|
||||||
|
"name": "CSV",
|
||||||
|
"ref_delimiter": ",",
|
||||||
|
"ref_range_delimiter": "",
|
||||||
|
"string_delimiter": "\""
|
||||||
|
},
|
||||||
|
"bom_presets": [],
|
||||||
|
"bom_settings": {
|
||||||
|
"exclude_dnp": false,
|
||||||
|
"fields_ordered": [
|
||||||
|
{
|
||||||
|
"group_by": false,
|
||||||
|
"label": "Reference",
|
||||||
|
"name": "Reference",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_by": false,
|
||||||
|
"label": "Qty",
|
||||||
|
"name": "${QUANTITY}",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_by": true,
|
||||||
|
"label": "Value",
|
||||||
|
"name": "Value",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_by": true,
|
||||||
|
"label": "DNP",
|
||||||
|
"name": "${DNP}",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_by": true,
|
||||||
|
"label": "Exclude from BOM",
|
||||||
|
"name": "${EXCLUDE_FROM_BOM}",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_by": true,
|
||||||
|
"label": "Exclude from Board",
|
||||||
|
"name": "${EXCLUDE_FROM_BOARD}",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_by": true,
|
||||||
|
"label": "Footprint",
|
||||||
|
"name": "Footprint",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_by": false,
|
||||||
|
"label": "Datasheet",
|
||||||
|
"name": "Datasheet",
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filter_string": "",
|
||||||
|
"group_symbols": true,
|
||||||
|
"include_excluded_from_bom": true,
|
||||||
|
"name": "Default Editing",
|
||||||
|
"sort_asc": true,
|
||||||
|
"sort_field": "位号"
|
||||||
|
},
|
||||||
|
"connection_grid_size": 50.0,
|
||||||
|
"drawing": {
|
||||||
|
"dashed_lines_dash_length_ratio": 12.0,
|
||||||
|
"dashed_lines_gap_length_ratio": 3.0,
|
||||||
|
"default_line_thickness": 6.0,
|
||||||
|
"default_text_size": 50.0,
|
||||||
|
"field_names": [],
|
||||||
|
"intersheets_ref_own_page": false,
|
||||||
|
"intersheets_ref_prefix": "",
|
||||||
|
"intersheets_ref_short": false,
|
||||||
|
"intersheets_ref_show": false,
|
||||||
|
"intersheets_ref_suffix": "",
|
||||||
|
"junction_size_choice": 3,
|
||||||
|
"label_size_ratio": 0.375,
|
||||||
|
"operating_point_overlay_i_precision": 3,
|
||||||
|
"operating_point_overlay_i_range": "~A",
|
||||||
|
"operating_point_overlay_v_precision": 3,
|
||||||
|
"operating_point_overlay_v_range": "~V",
|
||||||
|
"overbar_offset_ratio": 1.23,
|
||||||
|
"pin_symbol_size": 25.0,
|
||||||
|
"text_offset_ratio": 0.15
|
||||||
|
},
|
||||||
|
"legacy_lib_dir": "",
|
||||||
|
"legacy_lib_list": [],
|
||||||
|
"meta": {
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"net_format_name": "",
|
||||||
|
"page_layout_descr_file": "",
|
||||||
|
"plot_directory": "",
|
||||||
|
"space_save_all_events": true,
|
||||||
|
"spice_current_sheet_as_root": false,
|
||||||
|
"spice_external_command": "spice \"%I\"",
|
||||||
|
"spice_model_current_sheet_as_root": true,
|
||||||
|
"spice_save_all_currents": false,
|
||||||
|
"spice_save_all_dissipations": false,
|
||||||
|
"spice_save_all_voltages": false,
|
||||||
|
"subpart_first_id": 65,
|
||||||
|
"subpart_id_separator": 0
|
||||||
|
},
|
||||||
|
"sheets": [
|
||||||
|
[
|
||||||
|
"ef4a6f87-87d3-400e-b11f-0c7519b83474",
|
||||||
|
"Root"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"text_variables": {}
|
||||||
|
}
|
||||||
20
hardware/pcb/HeliosDAONE.kicad_sch
Normal file
20
hardware/pcb/HeliosDAONE.kicad_sch
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
(kicad_sch
|
||||||
|
(version 20250114)
|
||||||
|
(generator "eeschema")
|
||||||
|
(generator_version "9.0")
|
||||||
|
(uuid "ef4a6f87-87d3-400e-b11f-0c7519b83474")
|
||||||
|
(paper "A4")
|
||||||
|
(title_block
|
||||||
|
(title "Helios DA ONE")
|
||||||
|
(date "2026-02-18")
|
||||||
|
(rev "1.0.0")
|
||||||
|
(company "RayineElec")
|
||||||
|
)
|
||||||
|
(lib_symbols)
|
||||||
|
(sheet_instances
|
||||||
|
(path "/"
|
||||||
|
(page "1")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(embedded_fonts no)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user