Unify charge point command channel status

This commit is contained in:
2026-03-18 15:26:56 +08:00
parent 3508e7de19
commit 2c90404637
14 changed files with 347 additions and 86 deletions

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

@@ -32,6 +32,7 @@ export async function handleBootNotification(
registrationStatus: 'Pending',
heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
})
.onConflictDoUpdate({
target: chargePoint.chargePointIdentifier,
@@ -47,6 +48,7 @@ export async function handleBootNotification(
// Do NOT override registrationStatus — preserve whatever the admin set
heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(),
},
})

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

@@ -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,6 +116,80 @@ function sendCallError(
)
}
async function updateTransportState(
chargePointIdentifier: string,
values: Partial<typeof chargePoint.$inferInsert>,
): Promise<void> {
const db = useDrizzle()
await db
.update(chargePoint)
.set({
...values,
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, chargePointIdentifier))
}
function getCommandChannelStatus(chargePointIdentifier: string): CommandChannelStatus {
return ocppConnections.has(chargePointIdentifier) ? 'online' : 'unavailable'
}
export async function sendOcppCall<TPayload extends Record<string, unknown>, TResult = unknown>(
chargePointIdentifier: string,
action: string,
payload: TPayload,
timeoutOrOptions: number | { timeoutMs?: number; uniqueId?: string } = 15000,
): Promise<TResult> {
const entry = ocppConnections.get(chargePointIdentifier)
if (!entry) {
await updateTransportState(chargePointIdentifier, { transportStatus: 'unavailable' })
throw new Error('TransportUnavailable')
}
const timeoutMs =
typeof timeoutOrOptions === 'number' ? timeoutOrOptions : (timeoutOrOptions.timeoutMs ?? 15000)
const uniqueId =
typeof timeoutOrOptions === 'number' ? crypto.randomUUID() : (timeoutOrOptions.uniqueId ?? crypto.randomUUID())
const resultPromise = new Promise<TResult>((resolve, reject) => {
const timeout = setTimeout(async () => {
pendingCalls.delete(uniqueId)
await updateTransportState(chargePointIdentifier, {
transportStatus: getCommandChannelStatus(chargePointIdentifier),
lastCommandStatus: 'Timeout',
lastCommandAt: dayjs().toDate(),
})
reject(new Error('CommandTimeout'))
}, timeoutMs)
pendingCalls.set(uniqueId, {
chargePointIdentifier,
action,
resolve: (response) => resolve(response as TResult),
reject,
timeout,
})
})
try {
entry.ws.send(JSON.stringify([OCPP_MESSAGE_TYPE.CALL, uniqueId, action, payload]))
} catch (error) {
const pending = pendingCalls.get(uniqueId)
if (pending) {
clearTimeout(pending.timeout)
pendingCalls.delete(uniqueId)
}
await updateTransportState(chargePointIdentifier, {
transportStatus: 'unavailable',
lastCommandStatus: 'Error',
lastCommandAt: dayjs().toDate(),
})
throw error instanceof Error ? error : new Error('CommandSendFailed')
}
return resultPromise
}
/**
* Factory that produces a hono-ws event handler object for a single
* OCPP WebSocket connection.
@@ -104,15 +202,26 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
chargePointIdentifier,
isRegistered: false,
}
const sessionId = crypto.randomUUID()
return {
onOpen(_evt: Event, ws: WSContext) {
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` +
(remoteAddr ? ` from ${remoteAddr}` : ''),
@@ -122,6 +231,11 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
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
@@ -141,7 +255,36 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
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
@@ -174,8 +317,15 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
}
},
onClose(evt: CloseEvent, _ws: WSContext) {
ocppConnections.delete(chargePointIdentifier)
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

@@ -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} ` +
@@ -311,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();
@@ -324,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))
@@ -336,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 },
]),
);
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
{ 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,
);
}
if (message === "CommandTimeout") {
return c.json(
{
error: "ChargePoint did not confirm RemoteStopTransaction in time",
online: true,
remoteStopStatus,
},
504,
);
}
return c.json(
{
error: `RemoteStopTransaction failed: ${message}`,
online,
remoteStopStatus,
},
502,
);
}
}
// Settle in DB regardless (charge point may be offline or slow to respond)
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
const stopMeterValue = row.transaction.startMeterValue;
const energyWh = 0; // cannot know actual energy without stop meter value
const feePerKwh = row.feePerKwh ?? 0;
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
const [updated] = await db
.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,
});
});