Compare commits

..

23 Commits

Author SHA1 Message Date
e61e244c39 chore: generate migration 2026-03-18 15:44:43 +08:00
2c90404637 Unify charge point command channel status 2026-03-18 15:26:56 +08:00
3508e7de19 docs: update README.md 2026-03-18 13:05:49 +08:00
adc67e428d chore(pcb): initial kicad project 2026-03-18 12:47:56 +08:00
dee947ce3e feat(csms): add system settings management for OCPP 1.6J heartbeat interval 2026-03-17 01:42:29 +08:00
4d940e2cd4 chore(firmware): update text 2026-03-17 01:26:12 +08:00
8371b2a76b feat(firmware): add Mongoose client implementation for OCPP communication 2026-03-17 01:25:41 +08:00
e1fb43d57b refactor(csms): 更新 OCPP 认证相关文本 2026-03-17 00:38:37 +08:00
5825783f8b feat(csms): update schema for ocpp authorization 2026-03-17 00:32:54 +08:00
e884fc5bc0 feat(firmware): 更新 OCPP 配置,添加密码字段以支持基本认证 2026-03-16 17:17:15 +08:00
cf0861f8f6 feat(csms): 添加 OCPP 鉴权 2026-03-16 16:53:39 +08:00
4885cf6778 refactor: 移除重复的 tariff-schema 导出 2026-03-16 15:27:40 +08:00
654a2a66d9 feat(csms): 充电桩添加 deviceName 字段,区别于 identifier 用于区分设备 2026-03-16 13:43:46 +08:00
0118dd2e15 feat(web): 添加拓扑图页面和相关组件
feat(csms): 添加获取当前连接状态的API
feat(csms): 添加获取当前活动OCPP WebSocket连接的接口
deps(web): 添加@xyflow/react依赖
2026-03-16 12:59:05 +08:00
6888454727 fix(web): 修正二维码链接的URL路径 2026-03-16 02:03:57 +08:00
91d91ebd08 feat(main): 自动生成充电点标识符并优化WiFi设置 2026-03-16 01:48:18 +08:00
37c5cfe5a9 feat(main): 添加OCPP配置的持久化支持和LED状态管理功能 2026-03-16 00:52:17 +08:00
2de43d5fbb fix(csms): 添加缺失的chargePointId参数检查 2026-03-15 04:41:00 +08:00
434dbc15e9 fix(config): 修正OCPP后端URL 2026-03-15 04:33:52 +08:00
d5b2e529ff feat(mongoose): 添加对mbedTLS的TLS支持 2026-03-15 04:33:33 +08:00
d7b7ebfef9 Revert "feat(ocpp): 添加对WebSocket子协议的支持和缺失参数检查"
This reverts commit 216a8e118d.
2026-03-15 04:28:32 +08:00
8f3b2fd6e2 Revert "feat(boot-notification): 添加超时处理和日志记录以增强引导通知的持久性"
This reverts commit 8a537e80e3.
2026-03-15 04:28:32 +08:00
8a537e80e3 feat(boot-notification): 添加超时处理和日志记录以增强引导通知的持久性 2026-03-15 04:23:13 +08:00
54 changed files with 11462 additions and 245 deletions

View File

@@ -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]
├── hardware/ # 硬件和固件工程
│ ├── firmware/ # 充电桩固件
│ └── pcb-kicad/ # PCB 设计文件KiCAD
│ └── pcb/ # PCB 设计文件KiCAD
├── package.json
├── pnpm-workspace.yaml
── pnpm-lock.yaml
└── .gitmodules
── pnpm-lock.yaml
```
## 📦 工作区包
### `apps/csms` - 充电管理系统后端
### `apps/csms` - CSMS 后端
- **技术栈**Node.js + Hono + TypeScript
- **端口**3001(默认)
- **端口**3001
- **职责**
- OCPP 协议实现
- 充电设备管理
@@ -35,16 +34,17 @@ helios-evcs/
- 实时数据处理
- RESTful API 接口
### `apps/web` - 前端管理界面(子模块)
### `apps/web` - CSMS 前端
- **技术栈**Next.js 15 + React 19 + Tailwind CSS
- **端口**3000(默认)
- **源仓库**https://github.com/HoshinoSuzumi/helios
- **技术栈**Next.js 16 + React 19 + Tailwind CSS
- **端口**3000
- **职责**
- 充电管理界面
- 实时监控面板
- 数据可视化
- 充电管理
- 概览监控面板
- 储值卡管理
- 用户管理
- 充电会话历史
- 远程启动/停止充电
### `hardware/` - 硬件和固件工程
@@ -52,17 +52,18 @@ helios-evcs/
- 设备驱动实现
- 通信协议栈
- 实时控制逻辑
- **pcb-kicad/** - PCB 电路板设计
- **pcb/** - PCB 电路板设计
- KiCAD 工程文件
- 电路原理图
- PCB 布局设计
- BOM 物料清单
- 制造文件Gerber
## 🚀 快速开始
### 前置要求
- Node.js >= 18
- Node.js >= 20
- pnpm >= 10.18.2
### 安装依赖
@@ -78,6 +79,8 @@ pnpm install
# 启动所有开发服务器CSMS + Web
pnpm dev
# 迁移数据库
pnpm --filter csms run db:migrate
# 仅启动后端
pnpm dev:csms
@@ -111,21 +114,11 @@ pnpm start:csms
# 启动前端
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 运行特定包命令
@@ -133,7 +126,7 @@ pnpm start:web
```bash
# 在指定包中运行命令
pnpm --filter csms <command>
pnpm --filter helios-web <command>
pnpm --filter web <command>
# 示例:在 csms 中运行测试
pnpm --filter csms run test
@@ -143,42 +136,11 @@ pnpm --filter csms run test
```bash
# 在根安装(所有包共用)
pnpm add <package>
pnpm add <package> -w
# 在特定包中安装
pnpm --filter csms add <package>
pnpm --filter helios-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
pnpm --filter web add <package>
```
## 📚 技术文档

View File

@@ -0,0 +1 @@
ALTER TABLE "charge_point" ADD COLUMN "device_name" varchar(100);

View File

@@ -0,0 +1 @@
ALTER TABLE "charge_point" ADD COLUMN "password_hash" varchar(255);

View 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
);

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,34 @@
"when": 1773307380017,
"tag": "0003_milky_supreme_intelligence",
"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
}
]
}

View File

@@ -62,6 +62,29 @@ export const chargePoint = pgTable('charge_point', {
heartbeatInterval: integer('heartbeat_interval').default(60),
/** 最后一次收到 Heartbeat.req 的时间UTC */
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 */
lastBootNotificationAt: timestamp('last_boot_notification_at', {
withTimezone: true,
@@ -71,6 +94,11 @@ export const chargePoint = pgTable('charge_point', {
* 交易结束时按实际用电量从储值卡扣费fee = ceil(energyWh * feePerKwh / 1000)
* 默认为 0即不计费。仅在 pricingMode = 'fixed' 时生效。
*/
/**
* 设备名称(系统内部维护,不会被设备上报信息覆盖)
* 供运营人员标记,例如"1号楼A区01号桩"
*/
deviceName: varchar('device_name', { length: 100 }),
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'] })
.notNull()
.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 })
.notNull()
.defaultNow(),
@@ -387,6 +421,16 @@ export const transaction = pgTable(
'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 处理时根据实际用电量和充电桩电价计算写入

View File

@@ -1,4 +1,4 @@
export * from './auth-schema.ts'
export * from './ocpp-schema.ts'
export * from './tariff-schema.ts'
export * from './tariff-schema.ts'
export * from './settings-schema.ts'

View 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()),
});

View File

@@ -8,6 +8,10 @@ import { logger } from 'hono/logger'
import { showRoutes } from 'hono/dev'
import { auth } from './lib/auth.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 statsChartRoutes from './routes/stats-chart.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 setupRoutes from './routes/setup.ts'
import tariffRoutes from './routes/tariff.ts'
import settingsRoutes from './routes/settings.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/setup', setupRoutes)
app.route('/api/tariff', tariffRoutes)
app.route('/api/settings', settingsRoutes)
app.get('/api', (c) => {
const user = c.get('user')
@@ -83,14 +89,47 @@ app.get('/api', (c) => {
app.get(
'/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) => {
const chargePointId = c.req.param('chargePointId')
if (!chargePointId) {
throw new Error('Missing chargePointId route param')
}
const connInfo = getConnInfo(c)
const requestedProtocol = c.req.header('sec-websocket-protocol')
return createOcppHandler(chargePointId, connInfo.remote.address, requestedProtocol)
return createOcppHandler(chargePointId, connInfo.remote.address)
}),
)

View 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)
}

View 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),
};
}

View File

@@ -1,19 +1,19 @@
import { useDrizzle } from '@/lib/db.js'
import dayjs from 'dayjs'
import { chargePoint } from '@/db/schema.js'
import { getOcpp16jSettings } from '@/lib/system-settings.js'
import type {
BootNotificationRequest,
BootNotificationResponse,
OcppConnectionContext,
} from '../types.ts'
const DEFAULT_HEARTBEAT_INTERVAL = 60
export async function handleBootNotification(
payload: BootNotificationRequest,
ctx: OcppConnectionContext,
): Promise<BootNotificationResponse> {
const db = useDrizzle()
const { heartbeatInterval } = await getOcpp16jSettings()
const [cp] = await db
.insert(chargePoint)
@@ -30,8 +30,9 @@ export async function handleBootNotification(
meterSerialNumber: payload.meterSerialNumber ?? null,
// New, unknown devices start as Pending — admin must manually accept them
registrationStatus: 'Pending',
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
})
.onConflictDoUpdate({
target: chargePoint.chargePointIdentifier,
@@ -45,8 +46,9 @@ export async function handleBootNotification(
meterType: payload.meterType ?? null,
meterSerialNumber: payload.meterSerialNumber ?? null,
// Do NOT override registrationStatus — preserve whatever the admin set
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(),
},
})
@@ -59,7 +61,7 @@ export async function handleBootNotification(
return {
currentTime: dayjs().toISOString(),
interval: DEFAULT_HEARTBEAT_INTERVAL,
interval: heartbeatInterval,
status,
}
}

View File

@@ -16,7 +16,11 @@ export async function handleHeartbeat(
await db
.update(chargePoint)
.set({ lastHeartbeatAt: dayjs().toDate() })
.set({
lastHeartbeatAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
return {

View File

@@ -73,7 +73,7 @@ export async function handleStartTransaction(
if (rejected) {
await db
.update(connector)
.set({ status: "Available", updatedAt: now })
.set({ status: "Available", updatedAt: now.toDate() })
.where(eq(connector.id, conn.id));
}

View File

@@ -26,6 +26,11 @@ export async function handleStopTransaction(
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? 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(),
})
.where(eq(transaction.id, payload.transactionId))

View File

@@ -1,11 +1,18 @@
import type { WSContext } from 'hono/ws'
import dayjs from 'dayjs'
import { eq } from 'drizzle-orm'
import { isSupportedOCPP } from '@/constants.js'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint } from '@/db/schema.js'
import {
OCPP_MESSAGE_TYPE,
type OcppCall,
type OcppCallErrorMessage,
type OcppCallResultMessage,
type OcppErrorCode,
type OcppMessage,
type OcppConnectionContext,
type CommandChannelStatus,
type AuthorizeRequest,
type AuthorizeResponse,
type BootNotificationRequest,
@@ -24,9 +31,26 @@ import {
/**
* 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 { handleBootNotification } from './actions/boot-notification.ts'
import { handleHeartbeat } from './actions/heartbeat.ts'
@@ -92,24 +116,78 @@ function sendCallError(
)
}
function pickOcppSubprotocol(
negotiatedProtocol?: string | null,
requestedHeader?: string | null,
) {
if (negotiatedProtocol && isSupportedOCPP(negotiatedProtocol)) {
return negotiatedProtocol
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))
}
if (!requestedHeader) {
return null
function getCommandChannelStatus(chargePointIdentifier: string): CommandChannelStatus {
return ocppConnections.has(chargePointIdentifier) ? 'online' : 'unavailable'
}
const requestedProtocols = requestedHeader
.split(',')
.map((value) => value.trim())
.filter(Boolean)
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')
}
return requestedProtocols.find((protocol) => isSupportedOCPP(protocol)) ?? null
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
}
/**
@@ -119,26 +197,33 @@ function pickOcppSubprotocol(
* Usage in route:
* upgradeWebSocket((c) => createOcppHandler(c.req.param('chargePointId'), remoteAddr))
*/
export function createOcppHandler(
chargePointIdentifier: string,
remoteAddr?: string,
requestedProtocolHeader?: string,
) {
export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: string) {
const ctx: OcppConnectionContext = {
chargePointIdentifier,
isRegistered: false,
}
const sessionId = crypto.randomUUID()
return {
onOpen(_evt: Event, ws: WSContext) {
const subProtocol = pickOcppSubprotocol(ws.protocol, requestedProtocolHeader)
if (!subProtocol) {
async onOpen(_evt: Event, ws: WSContext) {
const subProtocol = ws.protocol ?? 'unknown'
if (!isSupportedOCPP(subProtocol)) {
ws.close(1002, 'Unsupported subprotocol')
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(
`[OCPP] ${chargePointIdentifier} connected (${subProtocol})` +
`[OCPP] ${chargePointIdentifier} connected` +
(remoteAddr ? ` from ${remoteAddr}` : ''),
)
},
@@ -146,6 +231,11 @@ export function createOcppHandler(
async onMessage(evt: MessageEvent, ws: WSContext) {
let uniqueId = '(unknown)'
try {
const current = ocppConnections.get(chargePointIdentifier)
if (current) {
current.lastMessageAt = new Date()
}
const raw = evt.data
if (typeof raw !== 'string') return
@@ -165,7 +255,36 @@ export function createOcppHandler(
const [messageType, msgUniqueId] = message
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
const [, , action, payload] = message as OcppCall
@@ -198,8 +317,15 @@ export function createOcppHandler(
}
},
onClose(evt: CloseEvent, _ws: WSContext) {
async onClose(evt: CloseEvent, _ws: WSContext) {
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})`)
},
}

View File

@@ -34,6 +34,11 @@ export type OcppConnectionContext = {
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)
// ---------------------------------------------------------------------------

View File

@@ -1,8 +1,10 @@
import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import { desc, eq, sql, inArray } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.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";
const app = new Hono<HonoEnv>();
@@ -70,6 +72,7 @@ app.post("/", async (c) => {
registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
deviceName?: string;
}>();
if (!body.chargePointIdentifier?.trim()) {
@@ -82,6 +85,9 @@ app.post("/", async (c) => {
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
}
const plainPassword = generateOcppPassword()
const passwordHash = await hashOcppPassword(plainPassword)
const [created] = await db
.insert(chargePoint)
.values({
@@ -92,6 +98,8 @@ app.post("/", async (c) => {
registrationStatus: body.registrationStatus ?? "Pending",
feePerKwh: body.feePerKwh ?? 0,
pricingMode: body.pricingMode ?? "fixed",
deviceName: body.deviceName?.trim() || null,
passwordHash,
})
.returning()
.catch((err: Error) => {
@@ -99,7 +107,15 @@ app.post("/", async (c) => {
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 */
@@ -134,6 +150,7 @@ app.patch("/:id", async (c) => {
registrationStatus?: string;
chargePointVendor?: string;
chargePointModel?: string;
deviceName?: string | null;
}>();
const set: {
@@ -142,6 +159,7 @@ app.patch("/:id", async (c) => {
registrationStatus?: "Accepted" | "Pending" | "Rejected";
chargePointVendor?: string;
chargePointModel?: string;
deviceName?: string | null;
updatedAt: Date;
} = { updatedAt: dayjs().toDate() };
@@ -159,6 +177,7 @@ app.patch("/:id", async (c) => {
}
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.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 (!['fixed', 'tou'].includes(body.pricingMode)) {
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 });
});
/** 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;

View 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;

View File

@@ -26,7 +26,9 @@ app.get("/", async (c) => {
db
.select({ count: sql<number>`count(*)::int` })
.from(chargePoint)
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
.where(
sql`${chargePoint.transportStatus} = 'online' and ${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`,
),
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)

View File

@@ -5,8 +5,7 @@ import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import type { SampledValue } from "@/db/schema.js";
import { user } from "@/db/auth-schema.js";
import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import { sendOcppCall } from "@/ocpp/handler.js";
import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js";
import type { HonoEnv } from "@/types/hono.ts";
@@ -84,19 +83,28 @@ app.post("/remote-start", async (c) => {
return c.json({ error: "ChargePoint is not accepted" }, 400);
}
// Require the charge point to be online
const ws = ocppConnections.get(body.chargePointIdentifier.trim());
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503);
try {
const response = await sendOcppCall<
{ connectorId: number; idTag: string },
{ status?: string }
>(body.chargePointIdentifier.trim(), "RemoteStartTransaction", {
connectorId: body.connectorId,
idTag: body.idTag.trim(),
})
const uniqueId = crypto.randomUUID();
ws.send(
JSON.stringify([
OCPP_MESSAGE_TYPE.CALL,
uniqueId,
"RemoteStartTransaction",
{ connectorId: body.connectorId, idTag: body.idTag.trim() },
]),
);
if (response?.status && response.status !== "Accepted") {
return c.json({ error: `RemoteStartTransaction ${response.status}` }, 409)
}
} catch (error) {
const message = error instanceof Error ? error.message : "CommandSendFailed"
if (message === "TransportUnavailable") {
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(
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
@@ -144,6 +152,7 @@ app.get("/", async (c) => {
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
chargePointDeviceName: chargePoint.deviceName,
feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode,
connectorNumber: connector.connectorId,
@@ -217,6 +226,7 @@ app.get("/", async (c) => {
return {
...r.transaction,
chargePointIdentifier: r.chargePointIdentifier,
chargePointDeviceName: r.chargePointDeviceName,
connectorNumber: r.connectorNumber,
idTagUserId: r.idTagUserId,
idTagUserName: r.idTagUserName,
@@ -243,6 +253,7 @@ app.get("/:id", async (c) => {
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
chargePointDeviceName: chargePoint.deviceName,
connectorNumber: connector.connectorId,
feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode,
@@ -294,6 +305,7 @@ app.get("/:id", async (c) => {
return c.json({
...row.transaction,
chargePointIdentifier: row.chargePointIdentifier,
chargePointDeviceName: row.chargePointDeviceName,
connectorNumber: row.connectorNumber,
energyWh:
row.transaction.stopMeterValue != null
@@ -307,9 +319,8 @@ app.get("/:id", async (c) => {
/**
* POST /api/transactions/:id/stop
* Manually stop an active transaction.
* 1. If the charge point is connected, send OCPP RemoteStopTransaction.
* 2. In either case (online or offline), settle the transaction in the DB immediately
* so the record is always finalised from the admin side.
* 1. If the charge point is connected, send OCPP RemoteStopTransaction and wait for confirmation.
* 2. Record the stop request state in DB; final settlement still happens on StopTransaction.
*/
app.post("/:id/stop", async (c) => {
const db = useDrizzle();
@@ -320,7 +331,6 @@ app.post("/:id/stop", async (c) => {
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
feePerKwh: chargePoint.feePerKwh,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
@@ -332,55 +342,74 @@ app.post("/:id/stop", async (c) => {
const now = dayjs();
// Try to send RemoteStopTransaction via OCPP if the charge point is online
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
if (ws) {
const uniqueId = crypto.randomUUID();
ws.send(
JSON.stringify([
OCPP_MESSAGE_TYPE.CALL,
uniqueId,
let remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" = "Requested";
let remoteStopRequestId: string | null = null;
let online = false;
if (row.chargePointIdentifier) {
remoteStopRequestId = crypto.randomUUID();
try {
const response = await sendOcppCall<{ transactionId: number }, { status?: string }>(
row.chargePointIdentifier,
"RemoteStopTransaction",
{ transactionId: row.transaction.id },
]),
{ uniqueId: remoteStopRequestId },
)
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,
);
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
}
// 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;
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,
);
}
}
const [updated] = await db
.update(transaction)
.set({
stopTimestamp: now.toDate(),
stopMeterValue,
stopReason: "Remote",
chargeAmount: feeFen,
remoteStopRequestedAt: now.toDate(),
remoteStopRequestId,
remoteStopStatus,
updatedAt: now.toDate(),
})
.where(eq(transaction.id, id))
.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({
...updated,
chargePointIdentifier: row.chargePointIdentifier,
online: !!ws,
energyWh,
online,
remoteStopStatus,
});
});

View File

@@ -15,9 +15,10 @@ import {
Spinner,
Table,
TextField,
Tooltip,
} from "@heroui/react";
import { ArrowLeft, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons";
import { api, type ChargePointPasswordReset } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import InfoSection from "@/components/info-section";
@@ -77,6 +78,7 @@ function relativeTime(iso: string): string {
// ── Edit form type ─────────────────────────────────────────────────────────
type EditForm = {
deviceName: string;
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
@@ -96,6 +98,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
const [editOpen, setEditOpen] = useState(false);
const [editBusy, setEditBusy] = useState(false);
const [editForm, setEditForm] = useState<EditForm>({
deviceName: "",
chargePointVendor: "",
chargePointModel: "",
registrationStatus: "Pending",
@@ -103,6 +106,29 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
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({
queryKey: ["chargePoint", id],
queryFn: () => api.chargePoints.get(id),
@@ -122,6 +148,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
const openEdit = () => {
if (!cp) return;
setEditForm({
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
@@ -142,6 +169,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
registrationStatus: editForm.registrationStatus,
pricingMode: editForm.pricingMode,
feePerKwh: editForm.pricingMode === "fixed" ? fee : 0,
deviceName: editForm.deviceName.trim() || null,
});
await cpQuery.refetch();
setEditOpen(false);
@@ -152,8 +180,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// Online if last heartbeat within 3× interval
const isOnline =
cp?.transportStatus === "online" &&
cp?.lastHeartbeatAt != null &&
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 isAdmin = sessionData?.user?.role === "admin";
@@ -202,9 +238,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<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="font-mono text-2xl font-semibold text-foreground">
{cp.chargePointIdentifier}
<h1 className="text-2xl font-semibold text-foreground">
{cp.deviceName ?? <span className="font-mono">{cp.chargePointIdentifier}</span>}
</h1>
{isAdmin && cp.deviceName && (
<span className="font-mono text-sm text-muted">{cp.chargePointIdentifier}</span>
)}
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
@@ -214,9 +253,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Chip>
<div className="flex items-center gap-1.5">
<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>
{(cp.chargePointVendor || cp.chargePointModel) && (
@@ -230,10 +269,21 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
<>
<Tooltip>
<Tooltip.Content> OCPP </Tooltip.Content>
<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>
@@ -395,9 +445,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dd>
<div className="flex items-center gap-1.5">
<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>
</dd>
</div>
@@ -545,6 +595,57 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
)}
</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 */}
{isAdmin && (
<Modal
@@ -561,6 +662,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Modal.Heading></Modal.Heading>
</Modal.Header>
<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">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>

View File

@@ -6,6 +6,7 @@ import {
Button,
Chip,
Input,
InputGroup,
Label,
ListBox,
Modal,
@@ -22,11 +23,13 @@ import {
TrashBin,
ArrowRotateRight,
QrCode,
Copy,
Check,
} from "@gravity-ui/icons";
import { QRCodeSVG } from "qrcode.react";
import Link from "next/link";
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 dayjs from "@/lib/dayjs";
@@ -97,6 +100,7 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
type FormData = {
chargePointIdentifier: string;
deviceName: string;
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
@@ -106,6 +110,7 @@ type FormData = {
const EMPTY_FORM: FormData = {
chargePointIdentifier: "",
deviceName: "",
chargePointVendor: "",
chargePointModel: "",
registrationStatus: "Pending",
@@ -121,6 +126,8 @@ export default function ChargePointsPage() {
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
const [createdCp, setCreatedCp] = useState<ChargePointCreated | null>(null);
const [copied, setCopied] = useState(false);
const {
data: chargePoints = [],
refetch: refetchList,
@@ -141,6 +148,7 @@ export default function ChargePointsPage() {
setFormTarget(cp);
setFormData({
chargePointIdentifier: cp.chargePointIdentifier,
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
@@ -163,20 +171,25 @@ export default function ChargePointsPage() {
registrationStatus: formData.registrationStatus,
pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || null,
});
await refetchList();
setFormOpen(false);
} else {
// Create
await api.chargePoints.create({
// Create — capture plainPassword for one-time display
const created = await api.chargePoints.create({
chargePointIdentifier: formData.chargePointIdentifier.trim(),
chargePointVendor: formData.chargePointVendor.trim() || undefined,
chargePointModel: formData.chargePointModel.trim() || undefined,
registrationStatus: formData.registrationStatus,
pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || undefined,
});
}
await refetchList();
setFormOpen(false);
setCreatedCp(created);
}
} finally {
setFormBusy(false);
}
@@ -196,6 +209,13 @@ export default function ChargePointsPage() {
const isEdit = formTarget !== null;
const handleCopyPassword = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
@@ -258,8 +278,16 @@ export default function ChargePointsPage() {
}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
<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">
<TextField fullWidth isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="ABB"
@@ -269,7 +297,7 @@ export default function ChargePointsPage() {
}
/>
</TextField>
<TextField fullWidth>
<TextField fullWidth isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Terra AC"
@@ -381,16 +409,18 @@ export default function ChargePointsPage() {
<Modal.Dialog className="sm:max-w-lg">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{qrTarget?.chargePointIdentifier} </Modal.Heading>
<Modal.Heading>
{qrTarget?.deviceName ?? qrTarget?.chargePointIdentifier}
</Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-muted">
</p>
{qrTarget &&
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
<p className="text-sm text-muted">
线
线
</p>
)}
<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)
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => {
const url = `${qrOrigin}/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
return (
<div
key={conn.id}
@@ -427,11 +457,100 @@ export default function ChargePointsPage() {
</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 &lt;base64({createdCp?.chargePointIdentifier}
:&lt;password&gt;)&gt;
</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.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column isRowHeader></Table.Column>
{isAdmin && <Table.Column> / </Table.Column>}
{isAdmin && <Table.Column></Table.Column>}
<Table.Column></Table.Column>
@@ -455,7 +574,14 @@ export default function ChargePointsPage() {
{isAdmin && <Table.Cell>{""}</Table.Cell>}
</Table.Row>
)}
{chargePoints.map((cp) => (
{chargePoints.map((cp) => {
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>
<Tooltip delay={0}>
@@ -463,26 +589,26 @@ export default function ChargePointsPage() {
<div className="flex items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${
cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "bg-success"
: "bg-gray-300"
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-gray-300"
}`}
/>
<div className="flex flex-col">
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-medium text-accent"
>
{cp.chargePointIdentifier}
{cp.deviceName ?? cp.chargePointIdentifier}
</Link>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted">
{cp.chargePointIdentifier}
</span>
)}
</div>
</div>
</Tooltip.Trigger>
<Tooltip.Content placement="start">
{cp.lastHeartbeatAt
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "在线"
: "离线"
: "从未连接"}
{online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"}
</Tooltip.Content>
</Tooltip>
</Table.Cell>
@@ -594,9 +720,14 @@ export default function ChargePointsPage() {
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
<span className="font-medium text-foreground">
{cp.deviceName ?? cp.chargePointIdentifier}
</span>
{cp.deviceName && (
<span className="font-mono ml-1 text-xs text-muted">
({cp.chargePointIdentifier})
</span>
)}
</p>
</Modal.Body>
@@ -621,7 +752,8 @@ export default function ChargePointsPage() {
</Table.Cell>
)}
</Table.Row>
))}
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>

View File

@@ -222,6 +222,7 @@ function QrScanner({ onResult, onClose }: ScannerProps) {
function ChargePageContent() {
const searchParams = useSearchParams();
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [step, setStep] = useState(1);
const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
@@ -234,6 +235,7 @@ function ChargePageContent() {
const [startSnapshot, setStartSnapshot] = useState<{
cpId: string;
chargePointIdentifier: string;
deviceName: string | null;
connectorId: number;
idTag: string;
} | null>(null);
@@ -348,6 +350,7 @@ function ChargePageContent() {
setStartSnapshot({
cpId: selectedCp.id,
chargePointIdentifier: selectedCp.chargePointIdentifier,
deviceName: selectedCp.deviceName,
connectorId: selectedConnectorId,
idTag: selectedIdTag,
});
@@ -360,8 +363,11 @@ function ChargePageContent() {
const msg = err.message ?? "";
const lowerMsg = msg.toLowerCase();
if (lowerMsg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试");
else if (
if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
setStartError("充电桩下行通道不可用,请稍后再试");
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
setStartError("充电桩未及时确认启动指令,请稍后重试");
} else if (
lowerMsg.includes("chargepoint is not accepted") ||
lowerMsg.includes("not accepted")
) {
@@ -424,7 +430,7 @@ function ChargePageContent() {
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-medium text-foreground">
{startSnapshot?.chargePointIdentifier ?? selectedCp?.chargePointIdentifier}
{startSnapshot?.deviceName ?? startSnapshot?.chargePointIdentifier ?? selectedCp?.deviceName ?? selectedCp?.chargePointIdentifier}
</span>
</div>
<div className="flex justify-between text-sm">
@@ -593,7 +599,10 @@ function ChargePageContent() {
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
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(
(c) => c.status === "Available",
).length;
@@ -617,12 +626,17 @@ function ChargePageContent() {
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
].join(" ")}
>
{/* Top row: identifier + status */}
{/* Top row: name + status */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-semibold text-foreground truncate leading-tight">
{cp.deviceName ?? cp.chargePointIdentifier}
</span>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted truncate">
{cp.chargePointIdentifier}
</span>
)}
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted truncate">
{[cp.chargePointVendor, cp.chargePointModel]
@@ -636,13 +650,17 @@ function ChargePageContent() {
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
online
? "bg-success/12 text-success"
: commandChannelUnavailable
? "bg-warning/12 text-warning"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
<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>
</div>
{/* Bottom row: connectors + fee */}
@@ -686,7 +704,7 @@ function ChargePageContent() {
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.chargePointIdentifier}
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span>
</div>
)}
@@ -773,7 +791,7 @@ function ChargePageContent() {
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.chargePointIdentifier}
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span>
</div>
)}

View File

@@ -35,7 +35,7 @@ function timeAgo(dateStr: string | null | undefined): string {
}
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;
}
@@ -285,7 +285,7 @@ function TrendChart() {
// ── RecentTransactions ────────────────────────────────────────────────────
function RecentTransactions({ txns }: { txns: Transaction[] }) {
function RecentTransactions({ txns, isAdmin = false }: { txns: Transaction[]; isAdmin?: boolean }) {
if (txns.length === 0) {
return <div className="py-8 text-center text-sm text-muted"></div>;
}
@@ -312,10 +312,15 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{tx.chargePointIdentifier ?? "—"}
{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}
{tx.connectorNumber != null && (
<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 className="text-xs text-muted">
{tx.idTag}
@@ -347,7 +352,7 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
// ── ChargePointStatus ─────────────────────────────────────────────────────
function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
function ChargePointStatus({ cps, isAdmin }: { cps: ChargePoint[]; isAdmin: boolean }) {
if (cps.length === 0) {
return <div className="py-8 text-center text-sm text-muted"></div>;
}
@@ -369,11 +374,16 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{cp.chargePointIdentifier}
{cp.deviceName ?? cp.chargePointIdentifier}
</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 className="shrink-0 text-right">
{online ? (
@@ -563,12 +573,12 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div className="lg:col-span-2">
<Panel title="充电桩状态">
<ChargePointStatus cps={data?.cps ?? []} />
<ChargePointStatus cps={data?.cps ?? []} isAdmin={isAdmin} />
</Panel>
</div>
<div className="lg:col-span-3">
<Panel title="最近充电会话">
<RecentTransactions txns={data?.txns ?? []} />
<RecentTransactions txns={data?.txns ?? []} isAdmin={isAdmin} />
</Panel>
</div>
</div>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -362,9 +362,14 @@ export default function TransactionDetailPage({ params }: { params: Promise<{ id
<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="font-mono text-sm text-foreground">
{tx.chargePointIdentifier ?? "—"}
<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">

View File

@@ -188,7 +188,14 @@ function TransactionsPageContent() {
{tx.id}
</Link>
</Table.Cell>
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</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 className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell>

View File

@@ -17,7 +17,7 @@ import {
} from "@gravity-ui/icons";
import SidebarFooter from "@/components/sidebar-footer";
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 = [
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
@@ -27,6 +27,7 @@ const chargeItems = [
const navItems = [
{ href: "/dashboard", label: "概览", icon: Gauge, exact: true, 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/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false },
{ href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true },
@@ -35,6 +36,7 @@ const navItems = [
const settingsItems = [
{ href: "/dashboard/settings/user", label: "账号设置", icon: UserCog, adminOnly: false },
{ href: "/dashboard/settings/parameters", label: "参数配置", icon: Gear, adminOnly: true },
];
function NavContent({

View File

@@ -68,13 +68,25 @@ export type ConnectorDetail = {
updatedAt: string;
};
export type ConnectionsStatus = {
connectedIdentifiers: string[];
};
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
export type ChargePoint = {
id: string;
chargePointIdentifier: string;
deviceName: string | null;
chargePointVendor: string | null;
chargePointModel: string | null;
registrationStatus: string;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null;
feePerKwh: number;
pricingMode: "fixed" | "tou";
@@ -86,6 +98,7 @@ export type ChargePoint = {
export type ChargePointDetail = {
id: string;
chargePointIdentifier: string;
deviceName: string | null;
chargePointVendor: string | null;
chargePointModel: string | null;
chargePointSerialNumber: string | null;
@@ -96,7 +109,12 @@ export type ChargePointDetail = {
meterType: string | null;
registrationStatus: string;
heartbeatInterval: number | null;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null;
feePerKwh: number;
pricingMode: "fixed" | "tou";
@@ -110,6 +128,7 @@ export type ChargePointDetail = {
export type Transaction = {
id: number;
chargePointIdentifier: string | null;
chargePointDeviceName: string | null;
connectorNumber: number | null;
idTag: string;
idTagStatus: string | null;
@@ -127,6 +146,9 @@ export type Transaction = {
chargeAmount: number | null;
electricityFee: number | null;
serviceFee: number | null;
remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" | null;
remoteStopRequestedAt: string | null;
remoteStopRequestId: string | null;
};
export type IdTag = {
@@ -153,6 +175,18 @@ export type UserRow = {
createdAt: string;
};
export type ChargePointCreated = ChargePoint & {
/** 仅在创建时返回一次的明文密码,之后不可再查 */
plainPassword: string;
};
export type ChargePointPasswordReset = {
id: string;
chargePointIdentifier: string;
/** 仅在重置时返回一次的新明文密码 */
plainPassword: string;
};
export type PaginatedTransactions = {
data: Transaction[];
total: number;
@@ -183,6 +217,14 @@ export type TariffConfig = {
updatedAt?: string;
};
export type Ocpp16jSettings = {
heartbeatInterval: number;
};
export type SystemSettings = {
ocpp16j: Ocpp16jSettings;
};
// ── API functions ──────────────────────────────────────────────────────────
export type ChartRange = "30d" | "7d" | "24h";
@@ -204,6 +246,7 @@ export const api = {
chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
connections: () => apiFetch<ConnectionsStatus>("/api/charge-points/connections"),
create: (data: {
chargePointIdentifier: string;
chargePointVendor?: string;
@@ -211,8 +254,9 @@ export const api = {
registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
deviceName?: string;
}) =>
apiFetch<ChargePoint>("/api/charge-points", {
apiFetch<ChargePointCreated>("/api/charge-points", {
method: "POST",
body: JSON.stringify(data),
}),
@@ -224,6 +268,7 @@ export const api = {
registrationStatus?: "Accepted" | "Pending" | "Rejected";
chargePointVendor?: string;
chargePointModel?: string;
deviceName?: string | null;
},
) =>
apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
@@ -232,6 +277,10 @@ export const api = {
}),
delete: (id: string) =>
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
resetPassword: (id: string) =>
apiFetch<ChargePointPasswordReset>(`/api/charge-points/${id}/reset-password`, {
method: "POST",
}),
},
transactions: {
list: (params?: {
@@ -323,4 +372,9 @@ export const api = {
put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) =>
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) }),
},
};

View File

@@ -15,6 +15,7 @@
"@tanstack/react-query": "catalog:",
"@tremor/react": "4.0.0-beta-tremor-v4.4",
"@types/qrcode": "^1.5.6",
"@xyflow/react": "^12.10.1",
"better-auth": "catalog:",
"dayjs": "catalog:",
"jsqr": "^1.4.0",

View 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"
]
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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

View File

@@ -1,2 +1,3 @@
#define MG_ARCH MG_ARCH_ESP32
#define MG_TLS MG_TLS_MBED // Use ESP-IDF built-in mbedTLS for WSS support
// Enable TLS support using mbedTLS (built into ESP32)
#define MG_TLS MG_TLS_MBED

View File

@@ -14,7 +14,6 @@ board = rymcu-esp32-devkitc
framework = arduino
lib_deps =
matth-x/MicroOcpp@^1.2.0
matth-x/MicroOcppMongoose@^1.2.0
roboticsbrno/SmartLeds@^3.1.5
miguelbalboa/MFRC522@^1.4.12
tzapu/WiFiManager@^2.0.17

View File

@@ -2,12 +2,14 @@
// OCPP 1.6-J: MOcppMongooseClient will append "/<CFG_CP_IDENTIFIER>" to this URL.
// For local dev: ws://<host>:3001/ocpp
// For production: ws://csms.helios.bh8.ga:8180/steve/websocket/CentralSystemService
#define CFG_OCPP_BACKEND "wss://csms.uniiem.com/ocpp"
#define CFG_OCPP_BACKEND "wss://csms-server.uniiem.com/ocpp"
// #define CFG_CP_IDENTIFIER "CQWU_HHB_0001"
#define CFG_CP_IDENTIFIER "ICP_906A28"
#define CFG_CP_IDENTIFIER ""
#define CFG_CB_SERIAL "REDAone_prototype00"
#define CFG_CP_FW_VERSION "1.0.0"
#define CFG_CP_MODAL "Helios DA One"
#define CFG_CP_VENDOR "RayineElec"
// #define CFG_AUTHORIZATIONKEY "my_secret_key"
#define CFG_AUTHORIZATIONKEY nullptr
// 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

View File

@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <WiFiManager.h>
#include <Preferences.h>
#include <string.h>
#include <MicroOcpp.h>
@@ -18,7 +19,9 @@ enum LEDState
LED_INITIALIZING, // Blue blinking - Initialization and WiFi connecting
LED_WIFI_CONNECTED, // Blue solid - WiFi connected, connecting 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;
@@ -31,6 +34,19 @@ static const unsigned long BLINK_INTERVAL = 200; // 200ms blink interval
uint8_t mac[6];
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;
/**
@@ -86,6 +102,38 @@ void updateLED()
leds[0] = Rgb{255, 0, 0}; // Red solid
leds.show();
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),
"%02X%02X%02X%02X%02X%02X",
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
leds[0] = Rgb{0, 0, 0};
leds.show();
@@ -103,6 +163,7 @@ void setup()
Serial.begin(115200);
delay(1000);
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");
// Initialize LED
@@ -113,7 +174,32 @@ void setup()
leds[0] = Rgb{255, 255, 0};
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;
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(
<style>
:root {
@@ -255,7 +341,21 @@ void setup()
</style>
)rawliteral";
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)
{
Serial.println("Failed to connect and hit timeout");
@@ -267,8 +367,52 @@ void setup()
s_led_state = LED_WIFI_CONNECTED;
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));
// 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,6 +421,75 @@ void loop()
mg_mgr_poll(&mgr, 10);
mocpp_loop();
// Handle BOOT button (GPIO 0) interactions for recovery
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 (!boot_was_pressed)
{
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
{
if (boot_was_pressed)
{
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())
{
@@ -292,6 +505,7 @@ void loop()
s_led_state = LED_WIFI_CONNECTED;
}
}
}
updateLED();

2
hardware/pcb/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*-backups

View File

@@ -0,0 +1,2 @@
(kicad_pcb (version 20241229) (generator "pcbnew") (generator_version "9.0")
)

View 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
}
}
}

View 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": {}
}

View 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)
)