333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
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,
|
|
type BootNotificationResponse,
|
|
type HeartbeatRequest,
|
|
type HeartbeatResponse,
|
|
type MeterValuesRequest,
|
|
type MeterValuesResponse,
|
|
type StartTransactionRequest,
|
|
type StartTransactionResponse,
|
|
type StatusNotificationRequest,
|
|
type StatusNotificationResponse,
|
|
type StopTransactionRequest,
|
|
type StopTransactionResponse,
|
|
} from './types.ts'
|
|
|
|
/**
|
|
* Global registry of active OCPP WebSocket connections.
|
|
* Key = chargePointIdentifier, Value = connection entry
|
|
*/
|
|
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'
|
|
import { handleMeterValues } from './actions/meter-values.ts'
|
|
import { handleStartTransaction } from './actions/start-transaction.ts'
|
|
import { handleStatusNotification } from './actions/status-notification.ts'
|
|
import { handleStopTransaction } from './actions/stop-transaction.ts'
|
|
|
|
// Typed dispatch map — only registered actions are accepted
|
|
type ActionHandlerMap = {
|
|
Authorize: (
|
|
payload: AuthorizeRequest,
|
|
ctx: OcppConnectionContext,
|
|
) => Promise<AuthorizeResponse>
|
|
BootNotification: (
|
|
payload: BootNotificationRequest,
|
|
ctx: OcppConnectionContext,
|
|
) => Promise<BootNotificationResponse>
|
|
Heartbeat: (
|
|
payload: HeartbeatRequest,
|
|
ctx: OcppConnectionContext,
|
|
) => Promise<HeartbeatResponse>
|
|
MeterValues: (
|
|
payload: MeterValuesRequest,
|
|
ctx: OcppConnectionContext,
|
|
) => Promise<MeterValuesResponse>
|
|
StartTransaction: (
|
|
payload: StartTransactionRequest,
|
|
ctx: OcppConnectionContext,
|
|
) => Promise<StartTransactionResponse>
|
|
StatusNotification: (
|
|
payload: StatusNotificationRequest,
|
|
ctx: OcppConnectionContext,
|
|
) => Promise<StatusNotificationResponse>
|
|
StopTransaction: (
|
|
payload: StopTransactionRequest,
|
|
ctx: OcppConnectionContext,
|
|
) => Promise<StopTransactionResponse>
|
|
}
|
|
|
|
const actionHandlers: ActionHandlerMap = {
|
|
Authorize: handleAuthorize,
|
|
BootNotification: handleBootNotification,
|
|
Heartbeat: handleHeartbeat,
|
|
MeterValues: handleMeterValues,
|
|
StartTransaction: handleStartTransaction,
|
|
StatusNotification: handleStatusNotification,
|
|
StopTransaction: handleStopTransaction,
|
|
}
|
|
|
|
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, {}]),
|
|
)
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Usage in route:
|
|
* upgradeWebSocket((c) => createOcppHandler(c.req.param('chargePointId'), remoteAddr))
|
|
*/
|
|
export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: string) {
|
|
const ctx: OcppConnectionContext = {
|
|
chargePointIdentifier,
|
|
isRegistered: false,
|
|
}
|
|
const sessionId = crypto.randomUUID()
|
|
|
|
return {
|
|
async onOpen(_evt: Event, ws: WSContext) {
|
|
const subProtocol = ws.protocol ?? 'unknown'
|
|
if (!isSupportedOCPP(subProtocol)) {
|
|
ws.close(1002, 'Unsupported subprotocol')
|
|
return
|
|
}
|
|
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}` : ''),
|
|
)
|
|
},
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
// 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')
|
|
}
|
|
},
|
|
|
|
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})`)
|
|
},
|
|
}
|
|
}
|