feat(ocpp): implement BootNotification, Heartbeat, and StatusNotification actions with database integration

This commit is contained in:
2026-03-10 10:42:20 +08:00
parent 0f47b3d382
commit 4fce1c6bdd
11 changed files with 2837 additions and 136 deletions

View File

@@ -0,0 +1,142 @@
import type { WSContext } from 'hono/ws'
import { isSupportedOCPP } from '@/constants.js'
import {
OCPP_MESSAGE_TYPE,
type OcppCall,
type OcppErrorCode,
type OcppMessage,
type OcppConnectionContext,
type BootNotificationRequest,
type BootNotificationResponse,
type HeartbeatRequest,
type HeartbeatResponse,
type StatusNotificationRequest,
type StatusNotificationResponse,
} from './types.ts'
import { handleBootNotification } from './actions/boot-notification.ts'
import { handleHeartbeat } from './actions/heartbeat.ts'
import { handleStatusNotification } from './actions/status-notification.ts'
// Typed dispatch map — only registered actions are accepted
type ActionHandlerMap = {
BootNotification: (
payload: BootNotificationRequest,
ctx: OcppConnectionContext,
) => Promise<BootNotificationResponse>
Heartbeat: (
payload: HeartbeatRequest,
ctx: OcppConnectionContext,
) => Promise<HeartbeatResponse>
StatusNotification: (
payload: StatusNotificationRequest,
ctx: OcppConnectionContext,
) => Promise<StatusNotificationResponse>
}
const actionHandlers: ActionHandlerMap = {
BootNotification: handleBootNotification,
Heartbeat: handleHeartbeat,
StatusNotification: handleStatusNotification,
}
function sendCallResult(ws: WSContext, uniqueId: string, payload: unknown): void {
ws.send(JSON.stringify([OCPP_MESSAGE_TYPE.CALLRESULT, uniqueId, payload]))
}
function sendCallError(
ws: WSContext,
uniqueId: string,
errorCode: OcppErrorCode,
errorDescription: string,
): void {
ws.send(
JSON.stringify([OCPP_MESSAGE_TYPE.CALLERROR, uniqueId, errorCode, errorDescription, {}]),
)
}
/**
* Factory that produces a hono-ws event handler object for a single
* OCPP WebSocket connection.
*
* Usage in route:
* upgradeWebSocket((c) => createOcppHandler(c.req.param('chargePointId'), remoteAddr))
*/
export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: string) {
const ctx: OcppConnectionContext = {
chargePointIdentifier,
isRegistered: false,
}
return {
onOpen(_evt: Event, ws: WSContext) {
const subProtocol = ws.protocol ?? 'unknown'
if (!isSupportedOCPP(subProtocol)) {
ws.close(1002, 'Unsupported subprotocol')
return
}
console.log(
`[OCPP] ${chargePointIdentifier} connected` +
(remoteAddr ? ` from ${remoteAddr}` : ''),
)
},
async onMessage(evt: MessageEvent, ws: WSContext) {
let uniqueId = '(unknown)'
try {
const raw = evt.data
if (typeof raw !== 'string') return
let message: OcppMessage
try {
message = JSON.parse(raw) as OcppMessage
} catch {
sendCallError(ws, uniqueId, 'FormationViolation', 'Invalid JSON')
return
}
if (!Array.isArray(message) || message.length < 3) {
sendCallError(ws, uniqueId, 'FormationViolation', 'Message must be a JSON array')
return
}
const [messageType, msgUniqueId] = message
uniqueId = String(msgUniqueId)
// CSMS only handles CALL messages from the charge point
if (messageType !== OCPP_MESSAGE_TYPE.CALL) return
const [, , action, payload] = message as OcppCall
// Enforce BootNotification before any other action
if (!ctx.isRegistered && action !== 'BootNotification') {
sendCallError(
ws,
uniqueId,
'SecurityError',
'Charge point must send BootNotification first',
)
return
}
const handler = actionHandlers[action as keyof ActionHandlerMap]
if (!handler) {
sendCallError(ws, uniqueId, 'NotImplemented', `Action '${action}' is not implemented`)
return
}
const response = await (
handler as (payload: unknown, ctx: OcppConnectionContext) => Promise<unknown>
)(payload, ctx)
sendCallResult(ws, uniqueId, response)
} catch (err) {
console.error(`[OCPP] Error handling message from ${chargePointIdentifier} (uniqueId=${uniqueId}):`, err)
sendCallError(ws, uniqueId, 'InternalError', 'Internal server error')
}
},
onClose(evt: CloseEvent, _ws: WSContext) {
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
},
}
}