Compare commits
3 Commits
codex/anal
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 524de66ad3 | |||
| 63349a17ed | |||
| ff5b92986f |
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal file
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "charge_point" ADD COLUMN "last_ws_connected_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "charge_point" ADD COLUMN "last_ws_disconnected_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "charge_point" ADD COLUMN "connection_session_id" varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE "charge_point" ADD COLUMN "transport_status" varchar DEFAULT 'offline' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "charge_point" ADD COLUMN "last_command_status" varchar;--> statement-breakpoint
|
||||
ALTER TABLE "charge_point" ADD COLUMN "last_command_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "transaction" ADD COLUMN "remote_stop_requested_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "transaction" ADD COLUMN "remote_stop_request_id" varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE "transaction" ADD COLUMN "remote_stop_status" varchar;
|
||||
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1773682931777,
|
||||
"tag": "0006_spooky_skin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1773819865056,
|
||||
"tag": "0007_unusual_squadron_supreme",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -62,6 +62,29 @@ export const chargePoint = pgTable('charge_point', {
|
||||
heartbeatInterval: integer('heartbeat_interval').default(60),
|
||||
/** 最后一次收到 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 处理时根据实际用电量和充电桩电价计算写入
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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})`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { desc, eq, sql, inArray } from "drizzle-orm";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import { useDrizzle } from "@/lib/db.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)) {
|
||||
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);
|
||||
}
|
||||
|
||||
const plainPassword = generateOcppPassword()
|
||||
const passwordHash = await hashOcppPassword(plainPassword)
|
||||
const plainPassword = generateOcppPassword();
|
||||
const passwordHash = await hashOcppPassword(plainPassword);
|
||||
|
||||
const [created] = await db
|
||||
.insert(chargePoint)
|
||||
@@ -175,21 +175,19 @@ app.patch("/:id", async (c) => {
|
||||
}
|
||||
set.registrationStatus = body.registrationStatus as "Accepted" | "Pending" | "Rejected";
|
||||
}
|
||||
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown";
|
||||
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown";
|
||||
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)) {
|
||||
if (!["fixed", "tou"].includes(body.pricingMode)) {
|
||||
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
||||
}
|
||||
set.pricingMode = body.pricingMode;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(chargePoint)
|
||||
.set(set)
|
||||
.where(eq(chargePoint.id, id))
|
||||
.returning();
|
||||
const [updated] = await db.update(chargePoint).set(set).where(eq(chargePoint.id, id)).returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { desc, eq } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
import { idTag } from "@/db/schema.js";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import type { HonoEnv } from "@/types/hono.ts";
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 transportStatusDotClass = 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
|
||||
</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 ${transportStatusDotClass}`}
|
||||
/>
|
||||
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span>
|
||||
<span className="text-xs text-muted">{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||
@@ -437,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 ${transportStatusDotClass}`}
|
||||
/>
|
||||
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
|
||||
<span className="text-sm text-foreground">{statusLabel}</span>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -574,18 +574,22 @@ export default function ChargePointsPage() {
|
||||
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
||||
</Table.Row>
|
||||
)}
|
||||
{chargePoints.map((cp) => (
|
||||
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
|
||||
{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}>
|
||||
<Tooltip.Trigger>
|
||||
<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">
|
||||
@@ -604,11 +608,7 @@ export default function ChargePointsPage() {
|
||||
</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>
|
||||
@@ -751,8 +751,9 @@ export default function ChargePointsPage() {
|
||||
</div>
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -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(" ")}
|
||||
>
|
||||
<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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,6 @@ export default function PricingPage() {
|
||||
const [activeTier, setActiveTier] = useState<PriceTier>("peak");
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPayload, setShowPayload] = useState(false);
|
||||
|
||||
// Populate state once remote tariff loads
|
||||
useEffect(() => {
|
||||
@@ -286,7 +285,6 @@ export default function PricingPage() {
|
||||
|
||||
// ── Derived values ───────────────────────────────────────────────────────
|
||||
const slots = scheduleToSlots(schedule);
|
||||
const apiPayload: TariffConfig = { slots, prices };
|
||||
|
||||
// ── Admin gate ───────────────────────────────────────────────────────────
|
||||
if (!isAdmin) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Controls,
|
||||
Handle,
|
||||
MiniMap,
|
||||
Panel,
|
||||
Position,
|
||||
type Node,
|
||||
type Edge,
|
||||
@@ -26,7 +25,9 @@ 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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -3,18 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CreditCard,
|
||||
Gear,
|
||||
ListCheck,
|
||||
Person,
|
||||
PlugConnection,
|
||||
TagDollar,
|
||||
Thunderbolt,
|
||||
ThunderboltFill,
|
||||
Xmark,
|
||||
Bars,
|
||||
} from "@gravity-ui/icons";
|
||||
import { CreditCard, Gear, TagDollar, Thunderbolt, Xmark, Bars } from "@gravity-ui/icons";
|
||||
import SidebarFooter from "@/components/sidebar-footer";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { EvCharger, Gauge, Network, ReceiptText, UserCog, Users } from "lucide-react";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -4,7 +4,7 @@ const CSMS_INTERNAL_URL =
|
||||
process.env.CSMS_INTERNAL_URL ?? process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
|
||||
|
||||
/** 检查 CSMS 是否已完成初始化(有用户存在)。 */
|
||||
async function isInitialized(request: NextRequest): Promise<boolean> {
|
||||
async function isInitialized(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${CSMS_INTERNAL_URL}/api/setup`, {
|
||||
method: "GET",
|
||||
@@ -25,7 +25,7 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
// /setup 页面:已初始化则跳转登录
|
||||
if (pathname === "/setup") {
|
||||
if (await isInitialized(request)) {
|
||||
if (await isInitialized()) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
@@ -33,7 +33,7 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
// /dashboard 路由:检查 session,未登录跳转 /login
|
||||
if (pathname.startsWith("/dashboard")) {
|
||||
if (!(await isInitialized(request))) {
|
||||
if (!(await isInitialized())) {
|
||||
return NextResponse.redirect(new URL("/setup", request.url));
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
// /login 路由:未初始化则跳转 /setup
|
||||
if (pathname === "/login") {
|
||||
if (!(await isInitialized(request))) {
|
||||
if (!(await isInitialized())) {
|
||||
return NextResponse.redirect(new URL("/setup", request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
|
||||
Reference in New Issue
Block a user