3 Commits

23 changed files with 2421 additions and 118 deletions

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

View File

@@ -50,6 +50,13 @@
"when": 1773682931777, "when": 1773682931777,
"tag": "0006_spooky_skin", "tag": "0006_spooky_skin",
"breakpoints": true "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), heartbeatInterval: integer('heartbeat_interval').default(60),
/** 最后一次收到 Heartbeat.req 的时间UTC */ /** 最后一次收到 Heartbeat.req 的时间UTC */
lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }), lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }),
/** 最近一次 WebSocket 连接建立时间UTC */
lastWsConnectedAt: timestamp('last_ws_connected_at', { withTimezone: true }),
/** 最近一次 WebSocket 连接关闭时间UTC */
lastWsDisconnectedAt: timestamp('last_ws_disconnected_at', { withTimezone: true }),
/** 最近一次活跃 WebSocket 会话 ID用于区分重连代次 */
connectionSessionId: varchar('connection_session_id', { length: 64 }),
/**
* OCPP 传输通道状态:
* online = 当前实例持有活动 WebSocket
* unavailable = 最近有心跳,但当前无可用下行通道
* offline = 无活动通道
*/
transportStatus: varchar('transport_status', {
enum: ['online', 'unavailable', 'offline'],
})
.notNull()
.default('offline'),
/** 最近一次下行命令确认结果 */
lastCommandStatus: varchar('last_command_status', {
enum: ['Accepted', 'Rejected', 'Error', 'Timeout'],
}),
/** 最近一次下行命令确认时间UTC */
lastCommandAt: timestamp('last_command_at', { withTimezone: true }),
/** 最后一次收到 BootNotification.req 的时间UTC */ /** 最后一次收到 BootNotification.req 的时间UTC */
lastBootNotificationAt: timestamp('last_boot_notification_at', { lastBootNotificationAt: timestamp('last_boot_notification_at', {
withTimezone: true, withTimezone: true,
@@ -398,6 +421,16 @@ export const transaction = pgTable(
'DeAuthorized', 'DeAuthorized',
], ],
}), }),
/** 管理端发起远程停止的请求时间 */
remoteStopRequestedAt: timestamp('remote_stop_requested_at', {
withTimezone: true,
}),
/** 管理端发起远程停止的 OCPP uniqueId */
remoteStopRequestId: varchar('remote_stop_request_id', { length: 64 }),
/** 最近一次远程停止请求的结果 */
remoteStopStatus: varchar('remote_stop_status', {
enum: ['Requested', 'Accepted', 'Rejected', 'Error', 'Timeout'],
}),
/** /**
* 本次充电扣费金额(单位:分) * 本次充电扣费金额(单位:分)
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入 * 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入

View File

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

View File

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

View File

@@ -26,6 +26,11 @@ export async function handleStopTransaction(
stopMeterValue: payload.meterStop, stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null, stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null, stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
remoteStopStatus: sql`case
when ${transaction.remoteStopRequestedAt} is not null and coalesce(${transaction.remoteStopStatus}, 'Requested') in ('Requested', 'Accepted')
then 'Accepted'
else ${transaction.remoteStopStatus}
end`,
updatedAt: dayjs().toDate(), updatedAt: dayjs().toDate(),
}) })
.where(eq(transaction.id, payload.transactionId)) .where(eq(transaction.id, payload.transactionId))

View File

@@ -1,11 +1,18 @@
import type { WSContext } from 'hono/ws' import type { WSContext } from 'hono/ws'
import dayjs from 'dayjs'
import { eq } from 'drizzle-orm'
import { isSupportedOCPP } from '@/constants.js' import { isSupportedOCPP } from '@/constants.js'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint } from '@/db/schema.js'
import { import {
OCPP_MESSAGE_TYPE, OCPP_MESSAGE_TYPE,
type OcppCall, type OcppCall,
type OcppCallErrorMessage,
type OcppCallResultMessage,
type OcppErrorCode, type OcppErrorCode,
type OcppMessage, type OcppMessage,
type OcppConnectionContext, type OcppConnectionContext,
type CommandChannelStatus,
type AuthorizeRequest, type AuthorizeRequest,
type AuthorizeResponse, type AuthorizeResponse,
type BootNotificationRequest, type BootNotificationRequest,
@@ -24,9 +31,26 @@ import {
/** /**
* Global registry of active OCPP WebSocket connections. * Global registry of active OCPP WebSocket connections.
* Key = chargePointIdentifier, Value = WSContext * Key = chargePointIdentifier, Value = connection entry
*/ */
export const ocppConnections = new Map<string, WSContext>() export type OcppConnectionEntry = {
ws: WSContext
sessionId: string
openedAt: Date
lastMessageAt: Date
}
type PendingCall =
| {
chargePointIdentifier: string
action: string
resolve: (payload: unknown) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
export const ocppConnections = new Map<string, OcppConnectionEntry>()
const pendingCalls = new Map<string, PendingCall>()
import { handleAuthorize } from './actions/authorize.ts' import { handleAuthorize } from './actions/authorize.ts'
import { handleBootNotification } from './actions/boot-notification.ts' import { handleBootNotification } from './actions/boot-notification.ts'
import { handleHeartbeat } from './actions/heartbeat.ts' import { handleHeartbeat } from './actions/heartbeat.ts'
@@ -92,6 +116,80 @@ function sendCallError(
) )
} }
async function updateTransportState(
chargePointIdentifier: string,
values: Partial<typeof chargePoint.$inferInsert>,
): Promise<void> {
const db = useDrizzle()
await db
.update(chargePoint)
.set({
...values,
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, chargePointIdentifier))
}
function getCommandChannelStatus(chargePointIdentifier: string): CommandChannelStatus {
return ocppConnections.has(chargePointIdentifier) ? 'online' : 'unavailable'
}
export async function sendOcppCall<TPayload extends Record<string, unknown>, TResult = unknown>(
chargePointIdentifier: string,
action: string,
payload: TPayload,
timeoutOrOptions: number | { timeoutMs?: number; uniqueId?: string } = 15000,
): Promise<TResult> {
const entry = ocppConnections.get(chargePointIdentifier)
if (!entry) {
await updateTransportState(chargePointIdentifier, { transportStatus: 'unavailable' })
throw new Error('TransportUnavailable')
}
const timeoutMs =
typeof timeoutOrOptions === 'number' ? timeoutOrOptions : (timeoutOrOptions.timeoutMs ?? 15000)
const uniqueId =
typeof timeoutOrOptions === 'number' ? crypto.randomUUID() : (timeoutOrOptions.uniqueId ?? crypto.randomUUID())
const resultPromise = new Promise<TResult>((resolve, reject) => {
const timeout = setTimeout(async () => {
pendingCalls.delete(uniqueId)
await updateTransportState(chargePointIdentifier, {
transportStatus: getCommandChannelStatus(chargePointIdentifier),
lastCommandStatus: 'Timeout',
lastCommandAt: dayjs().toDate(),
})
reject(new Error('CommandTimeout'))
}, timeoutMs)
pendingCalls.set(uniqueId, {
chargePointIdentifier,
action,
resolve: (response) => resolve(response as TResult),
reject,
timeout,
})
})
try {
entry.ws.send(JSON.stringify([OCPP_MESSAGE_TYPE.CALL, uniqueId, action, payload]))
} catch (error) {
const pending = pendingCalls.get(uniqueId)
if (pending) {
clearTimeout(pending.timeout)
pendingCalls.delete(uniqueId)
}
await updateTransportState(chargePointIdentifier, {
transportStatus: 'unavailable',
lastCommandStatus: 'Error',
lastCommandAt: dayjs().toDate(),
})
throw error instanceof Error ? error : new Error('CommandSendFailed')
}
return resultPromise
}
/** /**
* Factory that produces a hono-ws event handler object for a single * Factory that produces a hono-ws event handler object for a single
* OCPP WebSocket connection. * OCPP WebSocket connection.
@@ -104,15 +202,26 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
chargePointIdentifier, chargePointIdentifier,
isRegistered: false, isRegistered: false,
} }
const sessionId = crypto.randomUUID()
return { return {
onOpen(_evt: Event, ws: WSContext) { async onOpen(_evt: Event, ws: WSContext) {
const subProtocol = ws.protocol ?? 'unknown' const subProtocol = ws.protocol ?? 'unknown'
if (!isSupportedOCPP(subProtocol)) { if (!isSupportedOCPP(subProtocol)) {
ws.close(1002, 'Unsupported subprotocol') ws.close(1002, 'Unsupported subprotocol')
return return
} }
ocppConnections.set(chargePointIdentifier, ws) ocppConnections.set(chargePointIdentifier, {
ws,
sessionId,
openedAt: new Date(),
lastMessageAt: new Date(),
})
await updateTransportState(chargePointIdentifier, {
transportStatus: 'online',
connectionSessionId: sessionId,
lastWsConnectedAt: dayjs().toDate(),
})
console.log( console.log(
`[OCPP] ${chargePointIdentifier} connected` + `[OCPP] ${chargePointIdentifier} connected` +
(remoteAddr ? ` from ${remoteAddr}` : ''), (remoteAddr ? ` from ${remoteAddr}` : ''),
@@ -122,6 +231,11 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
async onMessage(evt: MessageEvent, ws: WSContext) { async onMessage(evt: MessageEvent, ws: WSContext) {
let uniqueId = '(unknown)' let uniqueId = '(unknown)'
try { try {
const current = ocppConnections.get(chargePointIdentifier)
if (current) {
current.lastMessageAt = new Date()
}
const raw = evt.data const raw = evt.data
if (typeof raw !== 'string') return if (typeof raw !== 'string') return
@@ -141,7 +255,36 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
const [messageType, msgUniqueId] = message const [messageType, msgUniqueId] = message
uniqueId = String(msgUniqueId) uniqueId = String(msgUniqueId)
// CSMS only handles CALL messages from the charge point if (messageType === OCPP_MESSAGE_TYPE.CALLRESULT) {
const [, responseUniqueId, payload] = message as OcppCallResultMessage
const pending = pendingCalls.get(responseUniqueId)
if (!pending) return
clearTimeout(pending.timeout)
pendingCalls.delete(responseUniqueId)
await updateTransportState(pending.chargePointIdentifier, {
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
lastCommandStatus: 'Accepted',
lastCommandAt: dayjs().toDate(),
})
pending.resolve(payload)
return
}
if (messageType === OCPP_MESSAGE_TYPE.CALLERROR) {
const [, responseUniqueId, errorCode, errorDescription] = message as OcppCallErrorMessage
const pending = pendingCalls.get(responseUniqueId)
if (!pending) return
clearTimeout(pending.timeout)
pendingCalls.delete(responseUniqueId)
await updateTransportState(pending.chargePointIdentifier, {
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
lastCommandStatus: errorCode === 'InternalError' ? 'Error' : 'Rejected',
lastCommandAt: dayjs().toDate(),
})
pending.reject(new Error(`${errorCode}:${errorDescription}`))
return
}
if (messageType !== OCPP_MESSAGE_TYPE.CALL) return if (messageType !== OCPP_MESSAGE_TYPE.CALL) return
const [, , action, payload] = message as OcppCall const [, , action, payload] = message as OcppCall
@@ -174,8 +317,15 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
} }
}, },
onClose(evt: CloseEvent, _ws: WSContext) { async onClose(evt: CloseEvent, _ws: WSContext) {
ocppConnections.delete(chargePointIdentifier) const current = ocppConnections.get(chargePointIdentifier)
if (current?.sessionId === sessionId) {
ocppConnections.delete(chargePointIdentifier)
await updateTransportState(chargePointIdentifier, {
transportStatus: 'offline',
lastWsDisconnectedAt: dayjs().toDate(),
})
}
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`) console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
}, },
} }

View File

@@ -34,6 +34,11 @@ export type OcppConnectionContext = {
isRegistered: boolean isRegistered: boolean
} }
export type OcppCallResultMessage = [3, string, Record<string, unknown>]
export type OcppCallErrorMessage = [4, string, OcppErrorCode, string, Record<string, unknown>]
export type CommandChannelStatus = 'online' | 'unavailable' | 'offline'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Action payload types (OCPP 1.6-J Section 4.x) // Action payload types (OCPP 1.6-J Section 4.x)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,5 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { desc, eq, sql, inArray } from "drizzle-orm"; import { desc, eq, sql } from "drizzle-orm";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js"; import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js"; import { chargePoint, connector } from "@/db/schema.js";
@@ -81,12 +81,12 @@ app.post("/", async (c) => {
if (body.feePerKwh !== undefined && (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0)) { if (body.feePerKwh !== undefined && (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0)) {
return c.json({ error: "feePerKwh must be a non-negative integer" }, 400); return c.json({ error: "feePerKwh must be a non-negative integer" }, 400);
} }
if (body.pricingMode !== undefined && !['fixed', 'tou'].includes(body.pricingMode)) { if (body.pricingMode !== undefined && !["fixed", "tou"].includes(body.pricingMode)) {
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400); return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
} }
const plainPassword = generateOcppPassword() const plainPassword = generateOcppPassword();
const passwordHash = await hashOcppPassword(plainPassword) const passwordHash = await hashOcppPassword(plainPassword);
const [created] = await db const [created] = await db
.insert(chargePoint) .insert(chargePoint)
@@ -175,21 +175,19 @@ app.patch("/:id", async (c) => {
} }
set.registrationStatus = body.registrationStatus as "Accepted" | "Pending" | "Rejected"; set.registrationStatus = body.registrationStatus as "Accepted" | "Pending" | "Rejected";
} }
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown"; if (body.chargePointVendor !== undefined)
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown"; 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 ("deviceName" in body) set.deviceName = body.deviceName?.trim() || null;
if (body.pricingMode !== undefined) { if (body.pricingMode !== undefined) {
if (!['fixed', 'tou'].includes(body.pricingMode)) { if (!["fixed", "tou"].includes(body.pricingMode)) {
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400); return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
} }
set.pricingMode = body.pricingMode; set.pricingMode = body.pricingMode;
} }
const [updated] = await db const [updated] = await db.update(chargePoint).set(set).where(eq(chargePoint.id, id)).returning();
.update(chargePoint)
.set(set)
.where(eq(chargePoint.id, id))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404); if (!updated) return c.json({ error: "Not found" }, 404);

View File

@@ -3,7 +3,6 @@ import { desc, eq } from "drizzle-orm";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js"; import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js"; import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod"; import { z } from "zod";
import type { HonoEnv } from "@/types/hono.ts"; import type { HonoEnv } from "@/types/hono.ts";

View File

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

View File

@@ -5,8 +5,7 @@ import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import type { SampledValue } from "@/db/schema.js"; import type { SampledValue } from "@/db/schema.js";
import { user } from "@/db/auth-schema.js"; import { user } from "@/db/auth-schema.js";
import { ocppConnections } from "@/ocpp/handler.js"; import { sendOcppCall } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js"; import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js";
import type { HonoEnv } from "@/types/hono.ts"; 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); return c.json({ error: "ChargePoint is not accepted" }, 400);
} }
// Require the charge point to be online try {
const ws = ocppConnections.get(body.chargePointIdentifier.trim()); const response = await sendOcppCall<
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503); { connectorId: number; idTag: string },
{ status?: string }
>(body.chargePointIdentifier.trim(), "RemoteStartTransaction", {
connectorId: body.connectorId,
idTag: body.idTag.trim(),
})
const uniqueId = crypto.randomUUID(); if (response?.status && response.status !== "Accepted") {
ws.send( return c.json({ error: `RemoteStartTransaction ${response.status}` }, 409)
JSON.stringify([ }
OCPP_MESSAGE_TYPE.CALL, } catch (error) {
uniqueId, const message = error instanceof Error ? error.message : "CommandSendFailed"
"RemoteStartTransaction", if (message === "TransportUnavailable") {
{ connectorId: body.connectorId, idTag: body.idTag.trim() }, return c.json({ error: "ChargePoint command channel is unavailable" }, 503)
]), }
); if (message === "CommandTimeout") {
return c.json({ error: "ChargePoint did not confirm RemoteStartTransaction in time" }, 504)
}
return c.json({ error: `RemoteStartTransaction failed: ${message}` }, 502)
}
console.log( console.log(
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` + `[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
@@ -311,9 +319,8 @@ app.get("/:id", async (c) => {
/** /**
* POST /api/transactions/:id/stop * POST /api/transactions/:id/stop
* Manually stop an active transaction. * Manually stop an active transaction.
* 1. If the charge point is connected, send OCPP RemoteStopTransaction. * 1. If the charge point is connected, send OCPP RemoteStopTransaction and wait for confirmation.
* 2. In either case (online or offline), settle the transaction in the DB immediately * 2. Record the stop request state in DB; final settlement still happens on StopTransaction.
* so the record is always finalised from the admin side.
*/ */
app.post("/:id/stop", async (c) => { app.post("/:id/stop", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
@@ -324,7 +331,6 @@ app.post("/:id/stop", async (c) => {
.select({ .select({
transaction, transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier, chargePointIdentifier: chargePoint.chargePointIdentifier,
feePerKwh: chargePoint.feePerKwh,
}) })
.from(transaction) .from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
@@ -336,55 +342,74 @@ app.post("/:id/stop", async (c) => {
const now = dayjs(); const now = dayjs();
// Try to send RemoteStopTransaction via OCPP if the charge point is online let remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" = "Requested";
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null; let remoteStopRequestId: string | null = null;
if (ws) { let online = false;
const uniqueId = crypto.randomUUID();
ws.send( if (row.chargePointIdentifier) {
JSON.stringify([ remoteStopRequestId = crypto.randomUUID();
OCPP_MESSAGE_TYPE.CALL, try {
uniqueId, const response = await sendOcppCall<{ transactionId: number }, { status?: string }>(
row.chargePointIdentifier,
"RemoteStopTransaction", "RemoteStopTransaction",
{ transactionId: row.transaction.id }, { transactionId: row.transaction.id },
]), { uniqueId: remoteStopRequestId },
); )
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`); online = true;
remoteStopStatus = response?.status === "Accepted" ? "Accepted" : "Rejected";
console.log(`[OCPP] RemoteStopTransaction txId=${id} status=${response?.status ?? "unknown"} to ${row.chargePointIdentifier}`);
} catch (error) {
const message = error instanceof Error ? error.message : "CommandSendFailed";
remoteStopStatus = message === "CommandTimeout" ? "Timeout" : "Error";
online = message !== "TransportUnavailable";
console.warn(`[OCPP] RemoteStopTransaction txId=${id} failed for ${row.chargePointIdentifier}: ${message}`);
if (message === "TransportUnavailable") {
return c.json(
{
error: "ChargePoint command channel is unavailable",
online: false,
remoteStopStatus,
},
503,
);
}
if (message === "CommandTimeout") {
return c.json(
{
error: "ChargePoint did not confirm RemoteStopTransaction in time",
online: true,
remoteStopStatus,
},
504,
);
}
return c.json(
{
error: `RemoteStopTransaction failed: ${message}`,
online,
remoteStopStatus,
},
502,
);
}
} }
// Settle in DB regardless (charge point may be offline or slow to respond)
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
const stopMeterValue = row.transaction.startMeterValue;
const energyWh = 0; // cannot know actual energy without stop meter value
const feePerKwh = row.feePerKwh ?? 0;
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
const [updated] = await db const [updated] = await db
.update(transaction) .update(transaction)
.set({ .set({
stopTimestamp: now.toDate(), remoteStopRequestedAt: now.toDate(),
stopMeterValue, remoteStopRequestId,
stopReason: "Remote", remoteStopStatus,
chargeAmount: feeFen,
updatedAt: now.toDate(), updatedAt: now.toDate(),
}) })
.where(eq(transaction.id, id)) .where(eq(transaction.id, id))
.returning(); .returning();
if (feeFen > 0) {
await db
.update(idTag)
.set({
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
updatedAt: now.toDate(),
})
.where(eq(idTag.idTag, row.transaction.idTag));
}
return c.json({ return c.json({
...updated, ...updated,
chargePointIdentifier: row.chargePointIdentifier, chargePointIdentifier: row.chargePointIdentifier,
online: !!ws, online,
energyWh, remoteStopStatus,
}); });
}); });

View File

@@ -180,8 +180,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// Online if last heartbeat within 3× interval // Online if last heartbeat within 3× interval
const isOnline = const isOnline =
cp?.transportStatus === "online" &&
cp?.lastHeartbeatAt != null && cp?.lastHeartbeatAt != null &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3; dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const commandChannelUnavailable = cp?.transportStatus === "unavailable";
const statusLabel = isOnline ? "在线" : commandChannelUnavailable ? "通道异常" : "离线";
const transportStatusDotClass = isOnline
? "bg-success animate-pulse"
: commandChannelUnavailable
? "bg-warning"
: "bg-muted";
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin"; const isAdmin = sessionData?.user?.role === "admin";
@@ -245,9 +253,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Chip> </Chip>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`} className={`size-2 rounded-full ${transportStatusDotClass}`}
/> />
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span> <span className="text-xs text-muted">{statusLabel}</span>
</div> </div>
</div> </div>
{(cp.chargePointVendor || cp.chargePointModel) && ( {(cp.chargePointVendor || cp.chargePointModel) && (
@@ -437,9 +445,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dd> <dd>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`} className={`size-2 rounded-full ${transportStatusDotClass}`}
/> />
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span> <span className="text-sm text-foreground">{statusLabel}</span>
</div> </div>
</dd> </dd>
</div> </div>

View File

@@ -574,18 +574,22 @@ export default function ChargePointsPage() {
{isAdmin && <Table.Cell>{""}</Table.Cell>} {isAdmin && <Table.Cell>{""}</Table.Cell>}
</Table.Row> </Table.Row>
)} )}
{chargePoints.map((cp) => ( {chargePoints.map((cp) => {
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}> const online =
cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
return (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
<Table.Cell> <Table.Cell>
<Tooltip delay={0}> <Tooltip delay={0}>
<Tooltip.Trigger> <Tooltip.Trigger>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`size-2 shrink-0 rounded-full ${ className={`size-2 shrink-0 rounded-full ${
cp.lastHeartbeatAt && online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-gray-300"
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "bg-success"
: "bg-gray-300"
}`} }`}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -604,11 +608,7 @@ export default function ChargePointsPage() {
</div> </div>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content placement="start"> <Tooltip.Content placement="start">
{cp.lastHeartbeatAt {online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"}
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "在线"
: "离线"
: "从未连接"}
</Tooltip.Content> </Tooltip.Content>
</Tooltip> </Tooltip>
</Table.Cell> </Table.Cell>
@@ -751,8 +751,9 @@ export default function ChargePointsPage() {
</div> </div>
</Table.Cell> </Table.Cell>
)} )}
</Table.Row> </Table.Row>
))} );
})}
</Table.Body> </Table.Body>
</Table.Content> </Table.Content>
</Table.ScrollContainer> </Table.ScrollContainer>

View File

@@ -363,8 +363,11 @@ function ChargePageContent() {
const msg = err.message ?? ""; const msg = err.message ?? "";
const lowerMsg = msg.toLowerCase(); const lowerMsg = msg.toLowerCase();
if (lowerMsg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试"); if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
else if ( setStartError("充电桩下行通道不可用,请稍后再试");
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
setStartError("充电桩未及时确认启动指令,请稍后重试");
} else if (
lowerMsg.includes("chargepoint is not accepted") || lowerMsg.includes("chargepoint is not accepted") ||
lowerMsg.includes("not accepted") lowerMsg.includes("not accepted")
) { ) {
@@ -596,7 +599,10 @@ function ChargePageContent() {
.filter((cp) => cp.registrationStatus === "Accepted") .filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => { .map((cp) => {
const online = const online =
!!cp.lastHeartbeatAt && dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120; cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
const availableCount = cp.connectors.filter( const availableCount = cp.connectors.filter(
(c) => c.status === "Available", (c) => c.status === "Available",
).length; ).length;
@@ -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", "shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
online online
? "bg-success/12 text-success" ? "bg-success/12 text-success"
: commandChannelUnavailable
? "bg-warning/12 text-warning"
: "bg-surface-tertiary text-muted", : "bg-surface-tertiary text-muted",
].join(" ")} ].join(" ")}
> >
<span <span
className={`size-1.5 rounded-full ${online ? "bg-success" : "bg-muted"}`} className={`size-1.5 rounded-full ${
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-muted"
}`}
/> />
{online ? "在线" : "离线"} {online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"}
</span> </span>
</div> </div>
{/* Bottom row: connectors + fee */} {/* Bottom row: connectors + fee */}

View File

@@ -35,7 +35,7 @@ function timeAgo(dateStr: string | null | undefined): string {
} }
function cpOnline(cp: ChargePoint): boolean { function cpOnline(cp: ChargePoint): boolean {
if (!cp.lastHeartbeatAt) return false; if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120; return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
} }

View File

@@ -184,7 +184,6 @@ export default function PricingPage() {
const [activeTier, setActiveTier] = useState<PriceTier>("peak"); const [activeTier, setActiveTier] = useState<PriceTier>("peak");
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showPayload, setShowPayload] = useState(false);
// Populate state once remote tariff loads // Populate state once remote tariff loads
useEffect(() => { useEffect(() => {
@@ -286,7 +285,6 @@ export default function PricingPage() {
// ── Derived values ─────────────────────────────────────────────────────── // ── Derived values ───────────────────────────────────────────────────────
const slots = scheduleToSlots(schedule); const slots = scheduleToSlots(schedule);
const apiPayload: TariffConfig = { slots, prices };
// ── Admin gate ─────────────────────────────────────────────────────────── // ── Admin gate ───────────────────────────────────────────────────────────
if (!isAdmin) { if (!isAdmin) {

View File

@@ -8,7 +8,6 @@ import {
Controls, Controls,
Handle, Handle,
MiniMap, MiniMap,
Panel,
Position, Position,
type Node, type Node,
type Edge, type Edge,
@@ -26,7 +25,9 @@ import { Clock, EvCharger, Plug, Zap } from "lucide-react";
type ConnectionStatus = "online" | "stale" | "offline"; type ConnectionStatus = "online" | "stale" | "offline";
function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus { 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"; if (!cp.lastHeartbeatAt) return "stale";
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "minute") < 5 ? "online" : "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 } { color: string; edgeColor: string; label: string; animated: boolean }
> = { > = {
online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true }, 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 }, offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false },
}; };

View File

@@ -3,18 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { import { CreditCard, Gear, TagDollar, Thunderbolt, Xmark, Bars } from "@gravity-ui/icons";
CreditCard,
Gear,
ListCheck,
Person,
PlugConnection,
TagDollar,
Thunderbolt,
ThunderboltFill,
Xmark,
Bars,
} from "@gravity-ui/icons";
import SidebarFooter from "@/components/sidebar-footer"; import SidebarFooter from "@/components/sidebar-footer";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { EvCharger, Gauge, Network, ReceiptText, UserCog, Users } from "lucide-react"; import { EvCharger, Gauge, Network, ReceiptText, UserCog, Users } from "lucide-react";

View File

@@ -72,6 +72,8 @@ export type ConnectionsStatus = {
connectedIdentifiers: string[]; connectedIdentifiers: string[];
}; };
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
export type ChargePoint = { export type ChargePoint = {
id: string; id: string;
chargePointIdentifier: string; chargePointIdentifier: string;
@@ -79,7 +81,12 @@ export type ChargePoint = {
chargePointVendor: string | null; chargePointVendor: string | null;
chargePointModel: string | null; chargePointModel: string | null;
registrationStatus: string; registrationStatus: string;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null; lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null; lastBootNotificationAt: string | null;
feePerKwh: number; feePerKwh: number;
pricingMode: "fixed" | "tou"; pricingMode: "fixed" | "tou";
@@ -102,7 +109,12 @@ export type ChargePointDetail = {
meterType: string | null; meterType: string | null;
registrationStatus: string; registrationStatus: string;
heartbeatInterval: number | null; heartbeatInterval: number | null;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null; lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null; lastBootNotificationAt: string | null;
feePerKwh: number; feePerKwh: number;
pricingMode: "fixed" | "tou"; pricingMode: "fixed" | "tou";
@@ -134,6 +146,9 @@ export type Transaction = {
chargeAmount: number | null; chargeAmount: number | null;
electricityFee: number | null; electricityFee: number | null;
serviceFee: number | null; serviceFee: number | null;
remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" | null;
remoteStopRequestedAt: string | null;
remoteStopRequestId: string | null;
}; };
export type IdTag = { export type IdTag = {

View File

@@ -4,7 +4,7 @@ const CSMS_INTERNAL_URL =
process.env.CSMS_INTERNAL_URL ?? process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001"; process.env.CSMS_INTERNAL_URL ?? process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
/** 检查 CSMS 是否已完成初始化(有用户存在)。 */ /** 检查 CSMS 是否已完成初始化(有用户存在)。 */
async function isInitialized(request: NextRequest): Promise<boolean> { async function isInitialized(): Promise<boolean> {
try { try {
const res = await fetch(`${CSMS_INTERNAL_URL}/api/setup`, { const res = await fetch(`${CSMS_INTERNAL_URL}/api/setup`, {
method: "GET", method: "GET",
@@ -25,7 +25,7 @@ export async function proxy(request: NextRequest) {
// /setup 页面:已初始化则跳转登录 // /setup 页面:已初始化则跳转登录
if (pathname === "/setup") { if (pathname === "/setup") {
if (await isInitialized(request)) { if (await isInitialized()) {
return NextResponse.redirect(new URL("/login", request.url)); return NextResponse.redirect(new URL("/login", request.url));
} }
return NextResponse.next(); return NextResponse.next();
@@ -33,7 +33,7 @@ export async function proxy(request: NextRequest) {
// /dashboard 路由:检查 session未登录跳转 /login // /dashboard 路由:检查 session未登录跳转 /login
if (pathname.startsWith("/dashboard")) { if (pathname.startsWith("/dashboard")) {
if (!(await isInitialized(request))) { if (!(await isInitialized())) {
return NextResponse.redirect(new URL("/setup", request.url)); return NextResponse.redirect(new URL("/setup", request.url));
} }
@@ -53,7 +53,7 @@ export async function proxy(request: NextRequest) {
// /login 路由:未初始化则跳转 /setup // /login 路由:未初始化则跳转 /setup
if (pathname === "/login") { if (pathname === "/login") {
if (!(await isInitialized(request))) { if (!(await isInitialized())) {
return NextResponse.redirect(new URL("/setup", request.url)); return NextResponse.redirect(new URL("/setup", request.url));
} }
return NextResponse.next(); return NextResponse.next();