diff --git a/apps/csms/src/db/ocpp-schema.ts b/apps/csms/src/db/ocpp-schema.ts index 93a0263..5aa21dd 100644 --- a/apps/csms/src/db/ocpp-schema.ts +++ b/apps/csms/src/db/ocpp-schema.ts @@ -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 处理时根据实际用电量和充电桩电价计算写入 diff --git a/apps/csms/src/ocpp/actions/boot-notification.ts b/apps/csms/src/ocpp/actions/boot-notification.ts index f5e02eb..967327d 100644 --- a/apps/csms/src/ocpp/actions/boot-notification.ts +++ b/apps/csms/src/ocpp/actions/boot-notification.ts @@ -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(), }, }) diff --git a/apps/csms/src/ocpp/actions/heartbeat.ts b/apps/csms/src/ocpp/actions/heartbeat.ts index ed90bf8..16fba39 100644 --- a/apps/csms/src/ocpp/actions/heartbeat.ts +++ b/apps/csms/src/ocpp/actions/heartbeat.ts @@ -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 { diff --git a/apps/csms/src/ocpp/actions/stop-transaction.ts b/apps/csms/src/ocpp/actions/stop-transaction.ts index bacb2f6..728d038 100644 --- a/apps/csms/src/ocpp/actions/stop-transaction.ts +++ b/apps/csms/src/ocpp/actions/stop-transaction.ts @@ -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)) diff --git a/apps/csms/src/ocpp/handler.ts b/apps/csms/src/ocpp/handler.ts index dfe2988..b1f82e9 100644 --- a/apps/csms/src/ocpp/handler.ts +++ b/apps/csms/src/ocpp/handler.ts @@ -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() +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 + } + +export const ocppConnections = new Map() +const pendingCalls = new Map() 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, +): Promise { + 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, TResult = unknown>( + chargePointIdentifier: string, + action: string, + payload: TPayload, + timeoutOrOptions: number | { timeoutMs?: number; uniqueId?: string } = 15000, +): Promise { + 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((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})`) }, } diff --git a/apps/csms/src/ocpp/types.ts b/apps/csms/src/ocpp/types.ts index 29b9f24..29b880a 100644 --- a/apps/csms/src/ocpp/types.ts +++ b/apps/csms/src/ocpp/types.ts @@ -34,6 +34,11 @@ export type OcppConnectionContext = { isRegistered: boolean } +export type OcppCallResultMessage = [3, string, Record] +export type OcppCallErrorMessage = [4, string, OcppErrorCode, string, Record] + +export type CommandChannelStatus = 'online' | 'unavailable' | 'offline' + // --------------------------------------------------------------------------- // Action payload types (OCPP 1.6-J Section 4.x) // --------------------------------------------------------------------------- diff --git a/apps/csms/src/routes/stats.ts b/apps/csms/src/routes/stats.ts index cf0c929..9b66c39 100644 --- a/apps/csms/src/routes/stats.ts +++ b/apps/csms/src/routes/stats.ts @@ -26,7 +26,9 @@ app.get("/", async (c) => { db .select({ count: sql`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`count(*)::int` }) .from(transaction) diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts index a7b1bc5..684accc 100644 --- a/apps/csms/src/routes/transactions.ts +++ b/apps/csms/src/routes/transactions.ts @@ -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, }); }); diff --git a/apps/web/app/dashboard/charge-points/[id]/page.tsx b/apps/web/app/dashboard/charge-points/[id]/page.tsx index a9e4563..2ac14a1 100644 --- a/apps/web/app/dashboard/charge-points/[id]/page.tsx +++ b/apps/web/app/dashboard/charge-points/[id]/page.tsx @@ -180,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"; @@ -245,9 +253,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
- {isOnline ? "在线" : "离线"} + {statusLabel}
{(cp.chargePointVendor || cp.chargePointModel) && ( @@ -437,9 +445,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
- {isOnline ? "在线" : "离线"} + {statusLabel}
diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx index 75f0e0b..61b7e72 100644 --- a/apps/web/app/dashboard/charge-points/page.tsx +++ b/apps/web/app/dashboard/charge-points/page.tsx @@ -574,18 +574,22 @@ export default function ChargePointsPage() { {isAdmin && {""}} )} - {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 ( +
@@ -604,11 +608,7 @@ export default function ChargePointsPage() {
- {cp.lastHeartbeatAt - ? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120 - ? "在线" - : "离线" - : "从未连接"} + {online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"} @@ -751,8 +751,9 @@ export default function ChargePointsPage() {
)} -
- ))} +
+ ); + })} diff --git a/apps/web/app/dashboard/charge/page.tsx b/apps/web/app/dashboard/charge/page.tsx index b191cff..6d3814b 100644 --- a/apps/web/app/dashboard/charge/page.tsx +++ b/apps/web/app/dashboard/charge/page.tsx @@ -363,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") ) { @@ -596,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; @@ -644,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(" ")} > - {online ? "在线" : "离线"} + {online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"} {/* Bottom row: connectors + fee */} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index d0d8933..f47da78 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -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; } diff --git a/apps/web/app/dashboard/topology/topology-flow.tsx b/apps/web/app/dashboard/topology/topology-flow.tsx index 53b31ce..4e067bf 100644 --- a/apps/web/app/dashboard/topology/topology-flow.tsx +++ b/apps/web/app/dashboard/topology/topology-flow.tsx @@ -26,7 +26,8 @@ import { Clock, EvCharger, Plug, Zap } from "lucide-react"; type ConnectionStatus = "online" | "stale" | "offline"; function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus { - if (!connected.includes(cp.chargePointIdentifier)) return "offline"; + 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"; } @@ -36,7 +37,7 @@ const STATUS_CONFIG: Record< { color: string; edgeColor: string; label: string; animated: boolean } > = { online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true }, - stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "心跳超时", animated: true }, + stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "通道异常", animated: true }, offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false }, }; diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 6f2b1c9..ef72d19 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -72,6 +72,8 @@ export type ConnectionsStatus = { connectedIdentifiers: string[]; }; +export type ChargePointConnectionStatus = "online" | "unavailable" | "offline"; + export type ChargePoint = { id: string; chargePointIdentifier: string; @@ -79,7 +81,12 @@ export type ChargePoint = { 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"; @@ -102,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"; @@ -134,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 = {