@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.
package/dist/meter.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * RFC-024 §5.7 — metering and settlement evidence.
3
+ *
4
+ * Meter values must come from the charger through the transport, never from the
5
+ * caller. The evidence digest is canonical so a settlement action can cite the
6
+ * charging operation id and prove which meter readings backed it.
7
+ */
8
+ import { hashCanonical } from '@totemsdk/act-core';
9
+ export function meterEvidenceDigest(evidence) {
10
+ return hashCanonical('TOTEM_ACT_OCPP_METER_EVIDENCE_V1', {
11
+ operationId: evidence.operationId,
12
+ connectorId: evidence.connectorId,
13
+ transactionId: evidence.transactionId,
14
+ energyWattHours: evidence.energyWattHours,
15
+ readings: evidence.readings
16
+ .map((reading) => ({
17
+ transactionId: reading.transactionId,
18
+ connectorId: reading.connectorId,
19
+ timestamp: reading.timestamp,
20
+ energyWattHours: reading.energyWattHours,
21
+ measurand: reading.measurand ?? null,
22
+ }))
23
+ .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)),
24
+ });
25
+ }
26
+ function latestReading(readings) {
27
+ return readings
28
+ .slice()
29
+ .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
30
+ .at(-1);
31
+ }
32
+ /**
33
+ * Reconcile an accepted charging operation against the charger.
34
+ *
35
+ * - `confirmed` only when the charger reports the transaction ended (an energy
36
+ * register alone is progress, not completion);
37
+ * - `running` while the transaction is active or meter values advance;
38
+ * - `unknown` when the charger cannot be read — the reservation must stay held.
39
+ */
40
+ export async function reconcileChargingSession(transport, query) {
41
+ const readings = await transport.readMeterValues({
42
+ connectorId: query.connectorId,
43
+ transactionId: query.transactionId,
44
+ });
45
+ const scoped = readings.filter((reading) => reading.transactionId === query.transactionId);
46
+ const latest = latestReading(scoped);
47
+ let state = { status: 'unknown' };
48
+ if (transport.readTransactionState) {
49
+ state = await transport.readTransactionState({
50
+ connectorId: query.connectorId,
51
+ transactionId: query.transactionId,
52
+ });
53
+ }
54
+ const energyWattHours = state.energyWattHours ?? latest?.energyWattHours;
55
+ if (state.status === 'ended' && energyWattHours !== undefined) {
56
+ const base = {
57
+ operationId: query.operationId,
58
+ connectorId: query.connectorId,
59
+ transactionId: query.transactionId,
60
+ energyWattHours,
61
+ readings: scoped,
62
+ };
63
+ return {
64
+ status: 'confirmed',
65
+ energyWattHours,
66
+ evidence: { ...base, digest: meterEvidenceDigest(base) },
67
+ };
68
+ }
69
+ if (state.status === 'active' || scoped.length > 0) {
70
+ return {
71
+ status: 'running',
72
+ ...(energyWattHours !== undefined ? { energyWattHours } : {}),
73
+ detail: state.status === 'active' ? 'transaction active' : 'meter values observed',
74
+ };
75
+ }
76
+ return { status: 'unknown', detail: 'no transaction state or meter readback' };
77
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * RFC-024 §5.4 / §5.9 — OCPP charging profile.
3
+ *
4
+ * Connectors are resources; the safe state is `stop session` (never leave an
5
+ * active transaction). Interlocks enforce the connector's declared physical
6
+ * ceiling before authorization, and the error taxonomy classifies OCPP status
7
+ * codes so a safety error is never retried.
8
+ */
9
+ import { type DeviceErrorTaxonomy, type IndustrialProfile, type Interlock, type Resource, type ResourceId } from '@totemsdk/act-core';
10
+ import type { OcppCapabilitySet } from './capabilities.js';
11
+ import { OCPP_CORE_PROFILES } from './capabilities.js';
12
+ export declare const OCPP_PROTOCOL = "ocpp";
13
+ export interface ChargingConnector {
14
+ connectorId: number;
15
+ /** Physical ceiling at this connector, in W (decimal string). */
16
+ maxPowerWatts: string;
17
+ /** Contract/session energy ceiling, in Wh. */
18
+ maxEnergyWattHours?: string;
19
+ /** Maximum session duration, in seconds. */
20
+ maxSessionSeconds?: string;
21
+ phases?: 1 | 3;
22
+ connectorType?: string;
23
+ }
24
+ export declare function chargingResourceId(site: string, connectorId: number, asset?: string): ResourceId;
25
+ export declare function chargingResource(site: string, connector: ChargingConnector): Resource;
26
+ /** Interlocks that enforce a connector's declared ceiling before authorization. */
27
+ export declare function chargingInterlocks(site: string, connector: ChargingConnector): Interlock[];
28
+ /**
29
+ * OCPP status → device error classification (RFC-011 §4.7).
30
+ * Safety codes require a safe state and are never retried.
31
+ */
32
+ export declare function ocppErrorTaxonomy(): DeviceErrorTaxonomy;
33
+ export interface OcppProfileOptions {
34
+ site: string;
35
+ connectors: readonly ChargingConnector[];
36
+ capabilities?: OcppCapabilitySet;
37
+ }
38
+ /** Data-only charger profile (definitions are supplied by the adapter). */
39
+ export declare function createOcppProfile(options: OcppProfileOptions): IndustrialProfile;
40
+ export { OCPP_CORE_PROFILES };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * RFC-024 §5.4 / §5.9 — OCPP charging profile.
3
+ *
4
+ * Connectors are resources; the safe state is `stop session` (never leave an
5
+ * active transaction). Interlocks enforce the connector's declared physical
6
+ * ceiling before authorization, and the error taxonomy classifies OCPP status
7
+ * codes so a safety error is never retried.
8
+ */
9
+ import { createDeviceErrorTaxonomy, } from '@totemsdk/act-core';
10
+ import { OCPP_CORE_PROFILES } from './capabilities.js';
11
+ export const OCPP_PROTOCOL = 'ocpp';
12
+ export function chargingResourceId(site, connectorId, asset = `EVSE-${connectorId}`) {
13
+ return { site, area: 'charging', asset, point: 'connector' };
14
+ }
15
+ export function chargingResource(site, connector) {
16
+ const id = chargingResourceId(site, connector.connectorId);
17
+ return {
18
+ id,
19
+ kind: 'actuator',
20
+ addresses: [{ protocol: OCPP_PROTOCOL, endpoint: `connector:${connector.connectorId}` }],
21
+ // Never leave an active transaction: the safe state stops the session.
22
+ safeState: {
23
+ resourceId: id,
24
+ command: { action: 'charging:stopSession', connectorId: connector.connectorId },
25
+ },
26
+ metadata: {
27
+ connectorId: connector.connectorId,
28
+ maxPowerWatts: connector.maxPowerWatts,
29
+ ...(connector.maxEnergyWattHours !== undefined
30
+ ? { maxEnergyWattHours: connector.maxEnergyWattHours }
31
+ : {}),
32
+ ...(connector.maxSessionSeconds !== undefined
33
+ ? { maxSessionSeconds: connector.maxSessionSeconds }
34
+ : {}),
35
+ ...(connector.phases !== undefined ? { phases: connector.phases } : {}),
36
+ ...(connector.connectorType !== undefined ? { connectorType: connector.connectorType } : {}),
37
+ },
38
+ };
39
+ }
40
+ function numeric(value) {
41
+ if (typeof value === 'number' && Number.isFinite(value))
42
+ return value;
43
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
44
+ return Number(value);
45
+ }
46
+ if (value && typeof value === 'object' && 'value' in value) {
47
+ return numeric(value.value);
48
+ }
49
+ return undefined;
50
+ }
51
+ /** Interlocks that enforce a connector's declared ceiling before authorization. */
52
+ export function chargingInterlocks(site, connector) {
53
+ const id = chargingResourceId(site, connector.connectorId);
54
+ const safeState = {
55
+ resourceId: id,
56
+ command: { action: 'charging:stopSession', connectorId: connector.connectorId },
57
+ };
58
+ const interlocks = [
59
+ {
60
+ id: `ocpp:power-ceiling:${connector.connectorId}`,
61
+ kind: 'precondition',
62
+ resources: [id],
63
+ safeState,
64
+ evaluate({ parameters }) {
65
+ const requested = numeric(parameters.maxPowerWatts);
66
+ if (requested === undefined)
67
+ return { satisfied: true };
68
+ const ceiling = Number(connector.maxPowerWatts);
69
+ return requested <= ceiling
70
+ ? { satisfied: true }
71
+ : { satisfied: false, reason: `requested ${requested} W exceeds connector ceiling ${ceiling} W` };
72
+ },
73
+ },
74
+ ];
75
+ if (connector.maxEnergyWattHours !== undefined) {
76
+ interlocks.push({
77
+ id: `ocpp:energy-ceiling:${connector.connectorId}`,
78
+ kind: 'precondition',
79
+ resources: [id],
80
+ safeState,
81
+ evaluate({ parameters }) {
82
+ const requested = numeric(parameters.maxEnergyWattHours);
83
+ if (requested === undefined)
84
+ return { satisfied: true };
85
+ const ceiling = Number(connector.maxEnergyWattHours);
86
+ return requested <= ceiling
87
+ ? { satisfied: true }
88
+ : { satisfied: false, reason: `requested ${requested} Wh exceeds connector ceiling ${ceiling} Wh` };
89
+ },
90
+ });
91
+ }
92
+ if (connector.maxSessionSeconds !== undefined) {
93
+ interlocks.push({
94
+ id: `ocpp:duration-ceiling:${connector.connectorId}`,
95
+ kind: 'precondition',
96
+ resources: [id],
97
+ safeState,
98
+ evaluate({ parameters }) {
99
+ const requested = numeric(parameters.maxSessionSeconds);
100
+ if (requested === undefined)
101
+ return { satisfied: true };
102
+ const ceiling = Number(connector.maxSessionSeconds);
103
+ return requested <= ceiling
104
+ ? { satisfied: true }
105
+ : { satisfied: false, reason: `requested ${requested} s exceeds connector ceiling ${ceiling} s` };
106
+ },
107
+ });
108
+ }
109
+ return interlocks;
110
+ }
111
+ /**
112
+ * OCPP status → device error classification (RFC-011 §4.7).
113
+ * Safety codes require a safe state and are never retried.
114
+ */
115
+ export function ocppErrorTaxonomy() {
116
+ const taxonomy = createDeviceErrorTaxonomy();
117
+ const classes = {
118
+ // Charger/connector protection — never retry.
119
+ ConnectorLockFailure: 'safety',
120
+ EVCommunicationError: 'safety',
121
+ GroundFailure: 'safety',
122
+ HighTemperature: 'safety',
123
+ InternalError: 'safety',
124
+ OverCurrentFailure: 'safety',
125
+ OverVoltage: 'safety',
126
+ PowerMeterFailure: 'safety',
127
+ PowerSwitchFailure: 'safety',
128
+ ResetFailure: 'safety',
129
+ UnderVoltage: 'safety',
130
+ Faulted: 'safety',
131
+ // Occupancy/state — retrying the same command cannot succeed.
132
+ Occupied: 'permanent',
133
+ Reserved: 'permanent',
134
+ Unavailable: 'permanent',
135
+ NotSupported: 'permanent',
136
+ Rejected: 'permanent',
137
+ Invalid: 'permanent',
138
+ // Link/availability — a retry with the same operation id is safe.
139
+ Offline: 'transient',
140
+ Timeout: 'transient',
141
+ WeakSignal: 'transient',
142
+ // Identity — never retry without a new authorization.
143
+ InvalidIdTag: 'auth',
144
+ UnknownIdTag: 'auth',
145
+ AuthorizationFailed: 'auth',
146
+ };
147
+ for (const [code, classification] of Object.entries(classes)) {
148
+ taxonomy.register(OCPP_PROTOCOL, code, classification);
149
+ }
150
+ return taxonomy;
151
+ }
152
+ /** Data-only charger profile (definitions are supplied by the adapter). */
153
+ export function createOcppProfile(options) {
154
+ const resources = options.connectors.map((connector) => chargingResource(options.site, connector));
155
+ const interlocks = options.connectors.flatMap((connector) => chargingInterlocks(options.site, connector));
156
+ return {
157
+ id: 'ocpp',
158
+ version: 1,
159
+ resources,
160
+ interlocks,
161
+ errorTaxonomy: ocppErrorTaxonomy(),
162
+ capabilities: ['industrial:action', 'industrial:read', 'transport:ocpp'],
163
+ recipes: [],
164
+ definitions: [],
165
+ };
166
+ }
167
+ export { OCPP_CORE_PROFILES };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * RFC-024 §5.2 — OCPP transport port.
3
+ *
4
+ * The caller injects a transport that talks to a CSMS or a local charger stack.
5
+ * This package owns the governed operation semantics only: it never opens a
6
+ * socket and never implements an OCPP stack. A deployment can back the port
7
+ * with a CSMS client, a charger stack, or a simulator.
8
+ *
9
+ * Every operation is idempotent at the port boundary: the prepared command and
10
+ * the operation id are stable across retries (RFC-022 §5.1), so a transport may
11
+ * safely deduplicate a replayed request.
12
+ */
13
+ export interface OcppResponse {
14
+ /** OCPP status (`Accepted`, `Rejected`, `Occupied`, `Faulted`, …). */
15
+ status?: string;
16
+ payload?: Record<string, unknown>;
17
+ errorCode?: string;
18
+ errorDescription?: string;
19
+ }
20
+ export interface OcppStartRequest {
21
+ connectorId: number;
22
+ idTag: string;
23
+ maxPowerWatts?: string;
24
+ maxEnergyWattHours?: string;
25
+ maxSessionSeconds?: string;
26
+ }
27
+ export interface OcppStopRequest {
28
+ connectorId: number;
29
+ transactionId: number;
30
+ }
31
+ export interface OcppChargingProfileRequest {
32
+ connectorId: number;
33
+ profileId: number;
34
+ maxPowerWatts: string;
35
+ maxEnergyWattHours?: string;
36
+ maxSessionSeconds?: string;
37
+ }
38
+ export interface OcppClearProfileRequest {
39
+ connectorId: number;
40
+ profileId?: number;
41
+ }
42
+ export interface OcppReserveRequest {
43
+ connectorId: number;
44
+ idTag: string;
45
+ reservationId: number;
46
+ expiresInSeconds: number;
47
+ }
48
+ export interface OcppMeterValue {
49
+ transactionId: number;
50
+ connectorId: number;
51
+ /** ISO-8601 timestamp from the charger. */
52
+ timestamp: string;
53
+ /** Cumulative energy register for the transaction, in Wh (decimal string). */
54
+ energyWattHours: string;
55
+ measurand?: string;
56
+ }
57
+ export type OcppTransactionEventType = 'Started' | 'Updated' | 'Ended';
58
+ export interface OcppTransactionEvent {
59
+ transactionId: number;
60
+ connectorId: number;
61
+ eventType: OcppTransactionEventType;
62
+ timestamp: string;
63
+ energyWattHours?: string;
64
+ }
65
+ export interface OcppMeterQuery {
66
+ connectorId: number;
67
+ transactionId?: number;
68
+ }
69
+ export interface OcppTransactionState {
70
+ status: 'active' | 'ended' | 'unknown';
71
+ /** Final cumulative energy register, in Wh. */
72
+ energyWattHours?: string;
73
+ endedAt?: string;
74
+ }
75
+ /**
76
+ * Injected OCPP port. Implementations map these calls onto their CSMS/charger
77
+ * stack (RemoteStartTransaction, SetChargingProfile, ReserveNow, …).
78
+ */
79
+ export interface OcppTransportPort {
80
+ remoteStartTransaction(request: OcppStartRequest): Promise<OcppResponse>;
81
+ remoteStopTransaction(request: OcppStopRequest): Promise<OcppResponse>;
82
+ /**
83
+ * Stop whatever session is active on a connector. Used as the connector's
84
+ * safe state, where the transaction id may not be known.
85
+ */
86
+ stopConnector?(connectorId: number): Promise<OcppResponse>;
87
+ setChargingProfile(request: OcppChargingProfileRequest): Promise<OcppResponse>;
88
+ clearChargingProfile(request: OcppClearProfileRequest): Promise<OcppResponse>;
89
+ reserveNow(request: OcppReserveRequest): Promise<OcppResponse>;
90
+ /** Readback path used for reconciliation; must originate from the charger. */
91
+ readMeterValues(query: OcppMeterQuery): Promise<OcppMeterValue[]>;
92
+ /** Charger/CSMS view of the transaction; required for definitive completion. */
93
+ readTransactionState?(query: OcppMeterQuery): Promise<OcppTransactionState>;
94
+ /** Optional push path for transaction events (Started/Updated/Ended). */
95
+ onTransactionEvent?(handler: (event: OcppTransactionEvent) => void): () => void;
96
+ }
97
+ /** Transport/OCPP rejection carrying the protocol code. */
98
+ export declare class OcppTransportError extends Error {
99
+ readonly code: string;
100
+ constructor(code: string, message: string);
101
+ }
102
+ export declare const OCPP_ACCEPTED = "Accepted";
103
+ /** Throw unless the response status is `Accepted`. */
104
+ export declare function assertAccepted(response: OcppResponse, action: string): void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * RFC-024 §5.2 — OCPP transport port.
3
+ *
4
+ * The caller injects a transport that talks to a CSMS or a local charger stack.
5
+ * This package owns the governed operation semantics only: it never opens a
6
+ * socket and never implements an OCPP stack. A deployment can back the port
7
+ * with a CSMS client, a charger stack, or a simulator.
8
+ *
9
+ * Every operation is idempotent at the port boundary: the prepared command and
10
+ * the operation id are stable across retries (RFC-022 §5.1), so a transport may
11
+ * safely deduplicate a replayed request.
12
+ */
13
+ /** Transport/OCPP rejection carrying the protocol code. */
14
+ export class OcppTransportError extends Error {
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.name = 'OcppTransportError';
18
+ this.code = code;
19
+ }
20
+ }
21
+ export const OCPP_ACCEPTED = 'Accepted';
22
+ /** Throw unless the response status is `Accepted`. */
23
+ export function assertAccepted(response, action) {
24
+ const status = response.status ?? OCPP_ACCEPTED;
25
+ if (status !== OCPP_ACCEPTED) {
26
+ throw new OcppTransportError(status, `${action} rejected with status '${status}'`);
27
+ }
28
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "@totemsdk/act-ocpp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Governed OCPP EV-charging profile for Totem — bounded sessions, charging limits, reservations and meter evidence on the act-core runtime",
5
+ "main": "dist/cjs/index.js",
6
+ "types": "dist/cjs/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "require": {
14
+ "types": "./dist/cjs/index.d.ts",
15
+ "default": "./dist/cjs/index.js"
16
+ }
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE",
23
+ "NOTICE"
24
+ ],
25
+ "dependencies": {
26
+ "@totemsdk/act-core": "^0.6.0",
27
+ "@totemsdk/edge": "^1.6.0",
28
+ "@totemsdk/agent-policy": "^0.5.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/jest": "^30.0.0",
32
+ "@types/node": "^20.0.0",
33
+ "jest": "^30.4.2",
34
+ "jest-environment-node": "^30.0.0",
35
+ "jest-runtime": "^30.5.2",
36
+ "ts-jest": "^29.0.0",
37
+ "typescript": "^7.0.2"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "license": "Apache-2.0",
46
+ "author": "Totem SDK",
47
+ "homepage": "https://docs.totem.ing",
48
+ "bugs": {
49
+ "url": "https://github.com/Totem-Edge/totem-sdk/issues"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/Totem-Edge/totem-sdk.git",
54
+ "directory": "packages/act-ocpp"
55
+ },
56
+ "keywords": [
57
+ "totem",
58
+ "totemsdk",
59
+ "ocpp",
60
+ "ev",
61
+ "charging",
62
+ "industrial",
63
+ "governed-action",
64
+ "act-core"
65
+ ],
66
+ "type": "module",
67
+ "scripts": {
68
+ "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'})+'\\n')\"",
69
+ "clean": "rm -rf dist",
70
+ "test": "jest --config ../../tests/jest/act-ocpp.cjs",
71
+ "test:integration": "node --test ../../tests/qualification/act-ocpp/integration.test.mjs",
72
+ "lint": "eslint src --max-warnings 0"
73
+ }
6
74
  }