@totemsdk/act-ocpp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,533 @@
1
+ /**
2
+ * RFC-024 §5.3 — governed OCPP operations.
3
+ *
4
+ * Each definition prepares a deterministic, commitment-bound OCPP command and
5
+ * derives its physical effects from that prepared command (never from hints).
6
+ * `startSession`/`stopSession` are asynchronous: acceptance is not completion,
7
+ * and completion is established by charger meter/transaction readback.
8
+ *
9
+ * Configuration writes (`setLimit`, `clearProfile`, `reserveConnector`) use
10
+ * `confirmation: 'completed'` because the CSMS acknowledgement *is* the terminal
11
+ * state for a profile/ reservation change; only a session has a later physical
12
+ * outcome to observe.
13
+ */
14
+ import { defaultUnitRegistry, hashCanonical, resourceIdKey, } from '@totemsdk/act-core';
15
+ import { deriveChargingEffects } from './effects.js';
16
+ import { reconcileChargingSession } from './meter.js';
17
+ import { chargingResourceId, ocppErrorTaxonomy, OCPP_PROTOCOL, } from './profile.js';
18
+ import { assertAccepted, OcppTransportError, } from './transport.js';
19
+ function decimal(value) {
20
+ return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(9)));
21
+ }
22
+ function quantityValue(value, unit, units) {
23
+ if (value === undefined || value === null)
24
+ return undefined;
25
+ if (typeof value === 'number' && Number.isFinite(value))
26
+ return value;
27
+ if (typeof value === 'object' && 'value' in value && 'unit' in value) {
28
+ return units.convert(value, unit).value;
29
+ }
30
+ return undefined;
31
+ }
32
+ function numberParameter(value) {
33
+ if (typeof value === 'number' && Number.isFinite(value))
34
+ return value;
35
+ throw new OcppTransportError('Invalid', `expected a number, got '${String(value)}'`);
36
+ }
37
+ function stringParameter(value) {
38
+ if (typeof value === 'string' && value.length > 0)
39
+ return value;
40
+ throw new OcppTransportError('Invalid', `expected a non-empty string, got '${String(value)}'`);
41
+ }
42
+ function commandOf(op) {
43
+ return op.command;
44
+ }
45
+ function findConnector(options, connectorId) {
46
+ const connector = options.connectors.find((candidate) => candidate.connectorId === connectorId);
47
+ if (!connector) {
48
+ throw new OcppTransportError('Invalid', `connector ${connectorId} is not registered`);
49
+ }
50
+ return connector;
51
+ }
52
+ function responseData(response) {
53
+ return { status: response.status ?? 'Accepted', ...(response.payload ?? {}) };
54
+ }
55
+ function acceptedResult(response) {
56
+ assertAccepted(response, 'OCPP operation');
57
+ return { ok: true, data: responseData(response) };
58
+ }
59
+ function transactionIdOf(record) {
60
+ const data = record.result;
61
+ return typeof data?.transactionId === 'number' ? data.transactionId : undefined;
62
+ }
63
+ export function createOcppDefinitions(options) {
64
+ const units = options.units ?? defaultUnitRegistry();
65
+ const taxonomy = ocppErrorTaxonomy();
66
+ const transport = options.transport;
67
+ function profileDigest(command) {
68
+ if (command.maxPowerWatts === undefined)
69
+ return undefined;
70
+ return hashCanonical('TOTEM_ACT_OCPP_CHARGING_PROFILE_V1', {
71
+ connectorId: command.connectorId,
72
+ maxPowerWatts: command.maxPowerWatts,
73
+ maxEnergyWattHours: command.maxEnergyWattHours ?? null,
74
+ maxSessionSeconds: command.maxSessionSeconds ?? null,
75
+ });
76
+ }
77
+ function resourceFor(connectorId) {
78
+ return resourceIdKey(chargingResourceId(options.site, connectorId));
79
+ }
80
+ const definitions = [
81
+ {
82
+ kind: 'charging:startSession',
83
+ description: 'Start a bounded charging session on a connector through the governed runtime.',
84
+ version: 1,
85
+ capability: 'industrial:action',
86
+ effect: 'write',
87
+ protocol: OCPP_PROTOCOL,
88
+ errorTaxonomy: taxonomy,
89
+ schema: {
90
+ parameters: [
91
+ { name: 'connectorId', type: 'number', required: true, description: 'Target connector id.' },
92
+ { name: 'idTag', type: 'string', required: true, description: 'Contract/authorization tag.' },
93
+ {
94
+ name: 'maxPowerWatts', type: 'quantity', required: true,
95
+ dimension: 'power', unit: 'W', description: 'Instantaneous power ceiling.',
96
+ },
97
+ {
98
+ name: 'maxEnergyWattHours', type: 'quantity', required: false,
99
+ dimension: 'energy', unit: 'Wh', description: 'Session energy ceiling.',
100
+ },
101
+ {
102
+ name: 'maxSessionSeconds', type: 'quantity', required: false,
103
+ dimension: 'time', unit: 's', description: 'Session duration ceiling.',
104
+ },
105
+ ],
106
+ context: [],
107
+ },
108
+ policy: { failureMode: 'fail-safe', timeoutMs: 30000 },
109
+ safeState: async (op) => {
110
+ const command = commandOf(op);
111
+ if (!transport.stopConnector) {
112
+ return { ok: false, error: 'transport cannot stop an active connector session' };
113
+ }
114
+ return acceptedResult(await transport.stopConnector(command.connectorId));
115
+ },
116
+ async prepare(params) {
117
+ const connectorId = numberParameter(params.connectorId);
118
+ findConnector(options, connectorId);
119
+ const maxPowerWatts = quantityValue(params.maxPowerWatts, 'W', units);
120
+ if (maxPowerWatts === undefined) {
121
+ throw new OcppTransportError('Invalid', 'maxPowerWatts is required');
122
+ }
123
+ const command = {
124
+ action: 'RemoteStartTransaction',
125
+ connectorId,
126
+ idTag: stringParameter(params.idTag),
127
+ maxPowerWatts: decimal(maxPowerWatts),
128
+ };
129
+ const maxEnergy = quantityValue(params.maxEnergyWattHours, 'Wh', units);
130
+ if (maxEnergy !== undefined)
131
+ command.maxEnergyWattHours = decimal(maxEnergy);
132
+ const maxSeconds = quantityValue(params.maxSessionSeconds, 's', units);
133
+ if (maxSeconds !== undefined)
134
+ command.maxSessionSeconds = decimal(maxSeconds);
135
+ return { resourceId: resourceFor(connectorId), command };
136
+ },
137
+ deriveEffects(op) {
138
+ const command = commandOf(op);
139
+ return deriveChargingEffects({
140
+ resource: resourceFor(command.connectorId),
141
+ maxPowerWatts: String(command.maxPowerWatts),
142
+ ...(command.maxEnergyWattHours !== undefined
143
+ ? { maxEnergyWattHours: String(command.maxEnergyWattHours) }
144
+ : {}),
145
+ ...(command.maxSessionSeconds !== undefined
146
+ ? { maxSessionSeconds: String(command.maxSessionSeconds) }
147
+ : {}),
148
+ profileId: `connector:${command.connectorId}`,
149
+ profileDigest: profileDigest(command) ?? 'none',
150
+ });
151
+ },
152
+ async actuate(op) {
153
+ const command = commandOf(op);
154
+ const response = await transport.remoteStartTransaction({
155
+ connectorId: command.connectorId,
156
+ idTag: String(command.idTag),
157
+ ...(command.maxPowerWatts !== undefined ? { maxPowerWatts: String(command.maxPowerWatts) } : {}),
158
+ ...(command.maxEnergyWattHours !== undefined
159
+ ? { maxEnergyWattHours: String(command.maxEnergyWattHours) }
160
+ : {}),
161
+ ...(command.maxSessionSeconds !== undefined
162
+ ? { maxSessionSeconds: String(command.maxSessionSeconds) }
163
+ : {}),
164
+ });
165
+ return acceptedResult(response);
166
+ },
167
+ async: {
168
+ confirmation: 'accepted',
169
+ async reconcile(op, record) {
170
+ const command = commandOf(op);
171
+ const transactionId = transactionIdOf(record);
172
+ if (transactionId === undefined) {
173
+ return { status: 'unknown', error: 'no transaction id available for reconciliation' };
174
+ }
175
+ const reading = await reconcileChargingSession(transport, {
176
+ operationId: record.operationId,
177
+ connectorId: command.connectorId,
178
+ transactionId,
179
+ });
180
+ if (reading.status === 'confirmed') {
181
+ return {
182
+ status: 'confirmed',
183
+ handle: { transactionId },
184
+ progress: { fraction: 1, detail: 'transaction ended', at: Date.now() },
185
+ result: reading.evidence,
186
+ };
187
+ }
188
+ if (reading.status === 'running') {
189
+ return {
190
+ status: 'running',
191
+ handle: { transactionId },
192
+ progress: { detail: reading.detail ?? 'charging', at: Date.now() },
193
+ };
194
+ }
195
+ return { status: 'unknown', error: reading.detail };
196
+ },
197
+ async cancel(op, record) {
198
+ const command = commandOf(op);
199
+ const transactionId = transactionIdOf(record);
200
+ if (transactionId !== undefined) {
201
+ await stopSession(command.connectorId, transactionId);
202
+ return;
203
+ }
204
+ if (!transport.stopConnector) {
205
+ throw new OcppTransportError('Invalid', 'cannot cancel without a transaction id');
206
+ }
207
+ await transport.stopConnector(command.connectorId);
208
+ },
209
+ },
210
+ },
211
+ {
212
+ kind: 'charging:stopSession',
213
+ description: 'Stop a charging session; completion is confirmed by meter readback.',
214
+ version: 1,
215
+ capability: 'industrial:action',
216
+ effect: 'write',
217
+ protocol: OCPP_PROTOCOL,
218
+ errorTaxonomy: taxonomy,
219
+ schema: {
220
+ parameters: [
221
+ { name: 'connectorId', type: 'number', required: true },
222
+ { name: 'transactionId', type: 'number', required: true },
223
+ ],
224
+ context: [],
225
+ },
226
+ policy: { failureMode: 'fail-safe', timeoutMs: 30000 },
227
+ safeState: async (op) => {
228
+ const command = commandOf(op);
229
+ return stopSession(command.connectorId, numberParameter(command.transactionId));
230
+ },
231
+ async prepare(params) {
232
+ const connectorId = numberParameter(params.connectorId);
233
+ findConnector(options, connectorId);
234
+ const command = {
235
+ action: 'RemoteStopTransaction',
236
+ connectorId,
237
+ transactionId: numberParameter(params.transactionId),
238
+ };
239
+ return { resourceId: resourceFor(connectorId), command };
240
+ },
241
+ deriveEffects(op) {
242
+ const command = commandOf(op);
243
+ return { spends: [], physical: { duration: [{ resource: resourceFor(command.connectorId), seconds: '0' }] } };
244
+ },
245
+ async actuate(op) {
246
+ const command = commandOf(op);
247
+ const response = await transport.remoteStopTransaction({
248
+ connectorId: command.connectorId,
249
+ transactionId: numberParameter(command.transactionId),
250
+ });
251
+ return acceptedResult(response);
252
+ },
253
+ async: {
254
+ confirmation: 'accepted',
255
+ async reconcile(op, record) {
256
+ const command = commandOf(op);
257
+ const transactionId = numberParameter(command.transactionId);
258
+ const reading = await reconcileChargingSession(transport, {
259
+ operationId: record.operationId,
260
+ connectorId: command.connectorId,
261
+ transactionId,
262
+ });
263
+ if (reading.status === 'confirmed') {
264
+ return {
265
+ status: 'confirmed',
266
+ handle: { transactionId },
267
+ progress: { fraction: 1, detail: 'transaction ended', at: Date.now() },
268
+ result: reading.evidence,
269
+ };
270
+ }
271
+ return {
272
+ status: reading.status === 'running' ? 'running' : 'unknown',
273
+ handle: { transactionId },
274
+ ...(reading.detail !== undefined ? { error: reading.detail } : {}),
275
+ };
276
+ },
277
+ async cancel(op) {
278
+ const command = commandOf(op);
279
+ await stopSession(command.connectorId, numberParameter(command.transactionId));
280
+ },
281
+ },
282
+ },
283
+ {
284
+ kind: 'charging:setLimit',
285
+ description: 'Apply a bounded charging profile to a connector.',
286
+ version: 1,
287
+ capability: 'industrial:action',
288
+ effect: 'write',
289
+ protocol: OCPP_PROTOCOL,
290
+ errorTaxonomy: taxonomy,
291
+ schema: {
292
+ parameters: [
293
+ { name: 'connectorId', type: 'number', required: true },
294
+ { name: 'profileId', type: 'number', required: true },
295
+ {
296
+ name: 'maxPowerWatts', type: 'quantity', required: true,
297
+ dimension: 'power', unit: 'W',
298
+ },
299
+ {
300
+ name: 'maxEnergyWattHours', type: 'quantity', required: false,
301
+ dimension: 'energy', unit: 'Wh',
302
+ },
303
+ {
304
+ name: 'maxSessionSeconds', type: 'quantity', required: false,
305
+ dimension: 'time', unit: 's',
306
+ },
307
+ ],
308
+ context: [],
309
+ },
310
+ policy: { failureMode: 'fail-safe', timeoutMs: 30000 },
311
+ safeState: async (op) => {
312
+ const command = commandOf(op);
313
+ return clearProfile(command.connectorId, Number(command.profileId));
314
+ },
315
+ async prepare(params) {
316
+ const connectorId = numberParameter(params.connectorId);
317
+ findConnector(options, connectorId);
318
+ const maxPowerWatts = quantityValue(params.maxPowerWatts, 'W', units);
319
+ if (maxPowerWatts === undefined) {
320
+ throw new OcppTransportError('Invalid', 'maxPowerWatts is required');
321
+ }
322
+ const command = {
323
+ action: 'SetChargingProfile',
324
+ connectorId,
325
+ profileId: numberParameter(params.profileId),
326
+ maxPowerWatts: decimal(maxPowerWatts),
327
+ };
328
+ const maxEnergy = quantityValue(params.maxEnergyWattHours, 'Wh', units);
329
+ if (maxEnergy !== undefined)
330
+ command.maxEnergyWattHours = decimal(maxEnergy);
331
+ const maxSeconds = quantityValue(params.maxSessionSeconds, 's', units);
332
+ if (maxSeconds !== undefined)
333
+ command.maxSessionSeconds = decimal(maxSeconds);
334
+ return { resourceId: resourceFor(connectorId), command };
335
+ },
336
+ deriveEffects(op) {
337
+ const command = commandOf(op);
338
+ return deriveChargingEffects({
339
+ resource: resourceFor(command.connectorId),
340
+ maxPowerWatts: String(command.maxPowerWatts),
341
+ ...(command.maxEnergyWattHours !== undefined
342
+ ? { maxEnergyWattHours: String(command.maxEnergyWattHours) }
343
+ : {}),
344
+ ...(command.maxSessionSeconds !== undefined
345
+ ? { maxSessionSeconds: String(command.maxSessionSeconds) }
346
+ : {}),
347
+ profileId: String(command.profileId),
348
+ profileDigest: profileDigest(command) ?? 'none',
349
+ });
350
+ },
351
+ async actuate(op) {
352
+ const command = commandOf(op);
353
+ const response = await transport.setChargingProfile({
354
+ connectorId: command.connectorId,
355
+ profileId: numberParameter(command.profileId),
356
+ maxPowerWatts: String(command.maxPowerWatts),
357
+ ...(command.maxEnergyWattHours !== undefined
358
+ ? { maxEnergyWattHours: String(command.maxEnergyWattHours) }
359
+ : {}),
360
+ ...(command.maxSessionSeconds !== undefined
361
+ ? { maxSessionSeconds: String(command.maxSessionSeconds) }
362
+ : {}),
363
+ });
364
+ return acceptedResult(response);
365
+ },
366
+ async: { confirmation: 'completed' },
367
+ },
368
+ {
369
+ kind: 'charging:reserveConnector',
370
+ description: 'Reserve a connector for a bounded window before a session starts.',
371
+ version: 1,
372
+ capability: 'industrial:action',
373
+ effect: 'write',
374
+ protocol: OCPP_PROTOCOL,
375
+ errorTaxonomy: taxonomy,
376
+ schema: {
377
+ parameters: [
378
+ { name: 'connectorId', type: 'number', required: true },
379
+ { name: 'idTag', type: 'string', required: true },
380
+ { name: 'reservationId', type: 'number', required: true },
381
+ {
382
+ name: 'expiresInSeconds', type: 'quantity', required: true,
383
+ dimension: 'time', unit: 's',
384
+ },
385
+ ],
386
+ context: [],
387
+ },
388
+ // A reservation that cannot be proven is refused; the window is bounded.
389
+ policy: { failureMode: 'fail-closed', timeoutMs: 30000 },
390
+ async prepare(params) {
391
+ const connectorId = numberParameter(params.connectorId);
392
+ findConnector(options, connectorId);
393
+ const expiresInSeconds = quantityValue(params.expiresInSeconds, 's', units);
394
+ if (expiresInSeconds === undefined) {
395
+ throw new OcppTransportError('Invalid', 'expiresInSeconds is required');
396
+ }
397
+ const command = {
398
+ action: 'ReserveNow',
399
+ connectorId,
400
+ idTag: stringParameter(params.idTag),
401
+ reservationId: numberParameter(params.reservationId),
402
+ expiresInSeconds: decimal(expiresInSeconds),
403
+ };
404
+ return { resourceId: resourceFor(connectorId), command };
405
+ },
406
+ deriveEffects(op) {
407
+ const command = commandOf(op);
408
+ return {
409
+ spends: [],
410
+ physical: {
411
+ duration: [{ resource: resourceFor(command.connectorId), seconds: String(command.expiresInSeconds) }],
412
+ },
413
+ };
414
+ },
415
+ async actuate(op) {
416
+ const command = commandOf(op);
417
+ const response = await transport.reserveNow({
418
+ connectorId: command.connectorId,
419
+ idTag: String(command.idTag),
420
+ reservationId: numberParameter(command.reservationId),
421
+ expiresInSeconds: Number(command.expiresInSeconds),
422
+ });
423
+ return acceptedResult(response);
424
+ },
425
+ async: { confirmation: 'completed' },
426
+ },
427
+ {
428
+ kind: 'charging:clearProfile',
429
+ description: 'Clear a charging profile from a connector.',
430
+ version: 1,
431
+ capability: 'industrial:action',
432
+ effect: 'write',
433
+ protocol: OCPP_PROTOCOL,
434
+ errorTaxonomy: taxonomy,
435
+ schema: {
436
+ parameters: [
437
+ { name: 'connectorId', type: 'number', required: true },
438
+ { name: 'profileId', type: 'number', required: false },
439
+ ],
440
+ context: [],
441
+ },
442
+ policy: { failureMode: 'fail-safe', timeoutMs: 30000 },
443
+ safeState: async (op) => {
444
+ const command = commandOf(op);
445
+ return clearProfile(command.connectorId, Number(command.profileId ?? 0));
446
+ },
447
+ async prepare(params) {
448
+ const connectorId = numberParameter(params.connectorId);
449
+ findConnector(options, connectorId);
450
+ const command = { action: 'ClearChargingProfile', connectorId };
451
+ if (params.profileId !== undefined)
452
+ command.profileId = numberParameter(params.profileId);
453
+ return { resourceId: resourceFor(connectorId), command };
454
+ },
455
+ deriveEffects(op) {
456
+ const command = commandOf(op);
457
+ return { spends: [], physical: { power: [{ resource: resourceFor(command.connectorId), watts: '0' }] } };
458
+ },
459
+ async actuate(op) {
460
+ const command = commandOf(op);
461
+ const response = await transport.clearChargingProfile({
462
+ connectorId: command.connectorId,
463
+ ...(command.profileId !== undefined
464
+ ? { profileId: numberParameter(command.profileId) }
465
+ : {}),
466
+ });
467
+ return acceptedResult(response);
468
+ },
469
+ async: { confirmation: 'completed' },
470
+ },
471
+ {
472
+ kind: 'charging:readMeter',
473
+ description: 'Read the charger meter values for a connector or transaction.',
474
+ version: 1,
475
+ capability: 'industrial:read',
476
+ effect: 'read',
477
+ protocol: OCPP_PROTOCOL,
478
+ errorTaxonomy: taxonomy,
479
+ schema: {
480
+ parameters: [
481
+ { name: 'connectorId', type: 'number', required: true },
482
+ { name: 'transactionId', type: 'number', required: false },
483
+ ],
484
+ context: [],
485
+ },
486
+ async prepare(params) {
487
+ const connectorId = numberParameter(params.connectorId);
488
+ findConnector(options, connectorId);
489
+ const command = { action: 'MeterValues', connectorId };
490
+ if (params.transactionId !== undefined) {
491
+ command.transactionId = numberParameter(params.transactionId);
492
+ }
493
+ return { resourceId: resourceFor(connectorId), command };
494
+ },
495
+ deriveEffects() {
496
+ return { spends: [] };
497
+ },
498
+ async actuate(op) {
499
+ const command = commandOf(op);
500
+ const readings = await transport.readMeterValues({
501
+ connectorId: command.connectorId,
502
+ ...(command.transactionId !== undefined
503
+ ? { transactionId: numberParameter(command.transactionId) }
504
+ : {}),
505
+ });
506
+ return { ok: true, data: { readings } };
507
+ },
508
+ },
509
+ ];
510
+ // Capability-gated definitions are not selectable when the negotiated set
511
+ // does not include them (RFC-024 §5.8); registration fails closed.
512
+ for (const definition of definitions) {
513
+ const capability = options.capabilities;
514
+ if (capability && !capability.profiles.includes(definition.kind)) {
515
+ throw new Error(`OCPP ${capability.version} does not provide '${definition.kind}'; refusing to register a capability-gated definition`);
516
+ }
517
+ }
518
+ async function stopSession(connectorId, transactionId) {
519
+ const response = await transport.remoteStopTransaction({ connectorId, transactionId });
520
+ return acceptedResult(response);
521
+ }
522
+ async function clearProfile(connectorId, profileId) {
523
+ const response = await transport.clearChargingProfile({
524
+ connectorId,
525
+ ...(profileId > 0 ? { profileId } : {}),
526
+ });
527
+ return acceptedResult(response);
528
+ }
529
+ return definitions;
530
+ }
531
+ export function errorCodeOf(error) {
532
+ return error instanceof OcppTransportError ? error.code : undefined;
533
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * RFC-024 §5.8 — explicit, versioned OCPP capability negotiation.
3
+ *
4
+ * A capability change is a new profile version, never a silent upgrade. A
5
+ * definition that needs a feature which was not negotiated is not selectable.
6
+ */
7
+ export type OcppVersion = '1.6J' | '2.0.1' | '2.1';
8
+ /** Highest first; negotiation picks the newest mutually supported version. */
9
+ export declare const OCPP_VERSION_PREFERENCE: readonly OcppVersion[];
10
+ export interface OcppCapabilitySet {
11
+ readonly version: OcppVersion;
12
+ /** Negotiated profile/feature names. */
13
+ readonly profiles: readonly string[];
14
+ }
15
+ /** Definition kinds provided by this package. */
16
+ export declare const OCPP_CORE_PROFILES: readonly ["charging:startSession", "charging:stopSession", "charging:setLimit", "charging:reserveConnector", "charging:clearProfile", "charging:readMeter"];
17
+ export type OcppProfileName = (typeof OCPP_CORE_PROFILES)[number];
18
+ export interface NegotiateOptions {
19
+ /** Versions the deployment can offer (CSMS/charger). */
20
+ offered: readonly OcppVersion[];
21
+ /** Versions this adapter supports. Defaults to 1.6J and 2.0.1. */
22
+ supported?: readonly OcppVersion[];
23
+ /** Profiles the deployment requires; absent features fail closed. */
24
+ requiredProfiles?: readonly string[];
25
+ }
26
+ /**
27
+ * Select the newest mutually supported version and assert the required profiles
28
+ * are available. Throws `ActionDefinitionError` (registration-time failure)
29
+ * rather than silently degrading.
30
+ */
31
+ export declare function negotiateOcppCapabilities(options: NegotiateOptions): OcppCapabilitySet;
32
+ export declare function supportsOcppProfile(capabilities: OcppCapabilitySet, profile: string): boolean;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * RFC-024 §5.8 — explicit, versioned OCPP capability negotiation.
3
+ *
4
+ * A capability change is a new profile version, never a silent upgrade. A
5
+ * definition that needs a feature which was not negotiated is not selectable.
6
+ */
7
+ import { ActionDefinitionError } from '@totemsdk/act-core';
8
+ /** Highest first; negotiation picks the newest mutually supported version. */
9
+ export const OCPP_VERSION_PREFERENCE = ['2.1', '2.0.1', '1.6J'];
10
+ /** Definition kinds provided by this package. */
11
+ export const OCPP_CORE_PROFILES = [
12
+ 'charging:startSession',
13
+ 'charging:stopSession',
14
+ 'charging:setLimit',
15
+ 'charging:reserveConnector',
16
+ 'charging:clearProfile',
17
+ 'charging:readMeter',
18
+ ];
19
+ /** Version-gated features. */
20
+ const VERSION_FEATURES = {
21
+ // 1.6J and 2.0.1 expose the same governed operations under different action
22
+ // names; the adapter binds them through the transport port.
23
+ '1.6J': [...OCPP_CORE_PROFILES],
24
+ '2.0.1': [...OCPP_CORE_PROFILES],
25
+ '2.1': [...OCPP_CORE_PROFILES, 'charging:bidirectional'],
26
+ };
27
+ /**
28
+ * Select the newest mutually supported version and assert the required profiles
29
+ * are available. Throws `ActionDefinitionError` (registration-time failure)
30
+ * rather than silently degrading.
31
+ */
32
+ export function negotiateOcppCapabilities(options) {
33
+ const supported = options.supported ?? ['2.0.1', '1.6J'];
34
+ const version = OCPP_VERSION_PREFERENCE.find((candidate) => options.offered.includes(candidate) && supported.includes(candidate));
35
+ if (!version) {
36
+ throw new ActionDefinitionError(`no mutually supported OCPP version (offered: ${options.offered.join(', ') || 'none'}; supported: ${supported.join(', ')})`);
37
+ }
38
+ const profiles = VERSION_FEATURES[version];
39
+ for (const required of options.requiredProfiles ?? []) {
40
+ if (!profiles.includes(required)) {
41
+ throw new ActionDefinitionError(`OCPP ${version} does not provide required profile '${required}'`);
42
+ }
43
+ }
44
+ return { version, profiles };
45
+ }
46
+ export function supportsOcppProfile(capabilities, profile) {
47
+ return capabilities.profiles.includes(profile);
48
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * RFC-024 §5.3 — governed OCPP operations.
3
+ *
4
+ * Each definition prepares a deterministic, commitment-bound OCPP command and
5
+ * derives its physical effects from that prepared command (never from hints).
6
+ * `startSession`/`stopSession` are asynchronous: acceptance is not completion,
7
+ * and completion is established by charger meter/transaction readback.
8
+ *
9
+ * Configuration writes (`setLimit`, `clearProfile`, `reserveConnector`) use
10
+ * `confirmation: 'completed'` because the CSMS acknowledgement *is* the terminal
11
+ * state for a profile/ reservation change; only a session has a later physical
12
+ * outcome to observe.
13
+ */
14
+ import { type IndustrialActionDefinition, type UnitRegistry } from '@totemsdk/act-core';
15
+ import type { OcppCapabilitySet } from './capabilities.js';
16
+ import { type ChargingConnector } from './profile.js';
17
+ import { type OcppTransportPort } from './transport.js';
18
+ export interface OcppAdapterOptions {
19
+ transport: OcppTransportPort;
20
+ site: string;
21
+ connectors: readonly ChargingConnector[];
22
+ capabilities?: OcppCapabilitySet;
23
+ units?: UnitRegistry;
24
+ }
25
+ interface ChargingCommand {
26
+ action: string;
27
+ connectorId: number;
28
+ [key: string]: unknown;
29
+ }
30
+ export declare function createOcppDefinitions(options: OcppAdapterOptions): IndustrialActionDefinition[];
31
+ export declare function errorCodeOf(error: unknown): string | undefined;
32
+ export type { ChargingCommand };