diff --git a/apps/csms/src/lib/db.ts b/apps/csms/src/lib/db.ts index e0b5e7e..9ae54a4 100644 --- a/apps/csms/src/lib/db.ts +++ b/apps/csms/src/lib/db.ts @@ -9,6 +9,8 @@ export const useDrizzle = () => { if (!pgPoolInstance || !drizzleInstance) { pgPoolInstance = new Pool({ connectionString: process.env.DATABASE_CONNECTION_STRING, + connectionTimeoutMillis: 3000, + idleTimeoutMillis: 10000, }) drizzleInstance = drizzle({ client: pgPoolInstance, schema }) } diff --git a/apps/csms/src/ocpp/actions/boot-notification.ts b/apps/csms/src/ocpp/actions/boot-notification.ts index c3a9985..d5a2150 100644 --- a/apps/csms/src/ocpp/actions/boot-notification.ts +++ b/apps/csms/src/ocpp/actions/boot-notification.ts @@ -8,58 +8,86 @@ import type { } from '../types.ts' const DEFAULT_HEARTBEAT_INTERVAL = 60 +const BOOT_NOTIFICATION_DB_TIMEOUT_MS = 3000 + +function withTimeout(promise: Promise, timeoutMs: number, label: string) { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs) + }), + ]) +} export async function handleBootNotification( payload: BootNotificationRequest, ctx: OcppConnectionContext, ): Promise { const db = useDrizzle() + const currentTime = dayjs().toISOString() - const [cp] = await db - .insert(chargePoint) - .values({ - id: crypto.randomUUID(), - chargePointIdentifier: ctx.chargePointIdentifier, - chargePointVendor: payload.chargePointVendor, - chargePointModel: payload.chargePointModel, - chargePointSerialNumber: payload.chargePointSerialNumber ?? null, - firmwareVersion: payload.firmwareVersion ?? null, - iccid: payload.iccid ?? null, - imsi: payload.imsi ?? null, - meterType: payload.meterType ?? null, - meterSerialNumber: payload.meterSerialNumber ?? null, - // New, unknown devices start as Pending — admin must manually accept them - registrationStatus: 'Pending', - heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, - lastBootNotificationAt: dayjs().toDate(), - }) - .onConflictDoUpdate({ - target: chargePoint.chargePointIdentifier, - set: { - chargePointVendor: payload.chargePointVendor, - chargePointModel: payload.chargePointModel, - chargePointSerialNumber: payload.chargePointSerialNumber ?? null, - firmwareVersion: payload.firmwareVersion ?? null, - iccid: payload.iccid ?? null, - imsi: payload.imsi ?? null, - meterType: payload.meterType ?? null, - meterSerialNumber: payload.meterSerialNumber ?? null, - // Do NOT override registrationStatus — preserve whatever the admin set - heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, - lastBootNotificationAt: dayjs().toDate(), - updatedAt: dayjs().toDate(), - }, - }) - .returning() + console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} received`) - const status = cp.registrationStatus - ctx.isRegistered = status === 'Accepted' + try { + const [cp] = await withTimeout( + db + .insert(chargePoint) + .values({ + id: crypto.randomUUID(), + chargePointIdentifier: ctx.chargePointIdentifier, + chargePointVendor: payload.chargePointVendor, + chargePointModel: payload.chargePointModel, + chargePointSerialNumber: payload.chargePointSerialNumber ?? null, + firmwareVersion: payload.firmwareVersion ?? null, + iccid: payload.iccid ?? null, + imsi: payload.imsi ?? null, + meterType: payload.meterType ?? null, + meterSerialNumber: payload.meterSerialNumber ?? null, + // New, unknown devices start as Pending — admin must manually accept them + registrationStatus: 'Pending', + heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, + lastBootNotificationAt: dayjs().toDate(), + }) + .onConflictDoUpdate({ + target: chargePoint.chargePointIdentifier, + set: { + chargePointVendor: payload.chargePointVendor, + chargePointModel: payload.chargePointModel, + chargePointSerialNumber: payload.chargePointSerialNumber ?? null, + firmwareVersion: payload.firmwareVersion ?? null, + iccid: payload.iccid ?? null, + imsi: payload.imsi ?? null, + meterType: payload.meterType ?? null, + meterSerialNumber: payload.meterSerialNumber ?? null, + // Do NOT override registrationStatus — preserve whatever the admin set + heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, + lastBootNotificationAt: dayjs().toDate(), + updatedAt: dayjs().toDate(), + }, + }) + .returning(), + BOOT_NOTIFICATION_DB_TIMEOUT_MS, + `BootNotification persistence for ${ctx.chargePointIdentifier}`, + ) - console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`) + const status = cp.registrationStatus + ctx.isRegistered = status === 'Accepted' - return { - currentTime: dayjs().toISOString(), - interval: DEFAULT_HEARTBEAT_INTERVAL, - status, + console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`) + + return { + currentTime, + interval: DEFAULT_HEARTBEAT_INTERVAL, + status, + } + } catch (error) { + ctx.isRegistered = false + console.error(`[OCPP] BootNotification ${ctx.chargePointIdentifier} persistence failed:`, error) + + return { + currentTime, + interval: DEFAULT_HEARTBEAT_INTERVAL, + status: 'Pending', + } } }