node-red-contrib-s2 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/dist/lib/index.d.ts +4 -0
  5. package/dist/lib/index.js +17 -0
  6. package/dist/lib/index.js.map +1 -0
  7. package/dist/lib/s2/messages.d.ts +342 -0
  8. package/dist/lib/s2/messages.js +330 -0
  9. package/dist/lib/s2/messages.js.map +1 -0
  10. package/dist/lib/s2/schedule.d.ts +35 -0
  11. package/dist/lib/s2/schedule.js +109 -0
  12. package/dist/lib/s2/schedule.js.map +1 -0
  13. package/dist/lib/s2/session.d.ts +138 -0
  14. package/dist/lib/s2/session.js +304 -0
  15. package/dist/lib/s2/session.js.map +1 -0
  16. package/dist/lib/transport/websocket.d.ts +58 -0
  17. package/dist/lib/transport/websocket.js +149 -0
  18. package/dist/lib/transport/websocket.js.map +1 -0
  19. package/dist/nodes/s2-cem-config/index.d.ts +3 -0
  20. package/dist/nodes/s2-cem-config/index.html +54 -0
  21. package/dist/nodes/s2-cem-config/index.js +16 -0
  22. package/dist/nodes/s2-cem-config/index.js.map +1 -0
  23. package/dist/nodes/s2-rm/icons/s2-logo.svg +14 -0
  24. package/dist/nodes/s2-rm/index.d.ts +34 -0
  25. package/dist/nodes/s2-rm/index.html +167 -0
  26. package/dist/nodes/s2-rm/index.js +655 -0
  27. package/dist/nodes/s2-rm/index.js.map +1 -0
  28. package/dist/nodes/s2-rm-config/index.d.ts +3 -0
  29. package/dist/nodes/s2-rm-config/index.html +254 -0
  30. package/dist/nodes/s2-rm-config/index.js +20 -0
  31. package/dist/nodes/s2-rm-config/index.js.map +1 -0
  32. package/dist/nodes/s2-websocket/icons/s2-logo.svg +14 -0
  33. package/dist/nodes/s2-websocket/index.d.ts +22 -0
  34. package/dist/nodes/s2-websocket/index.html +83 -0
  35. package/dist/nodes/s2-websocket/index.js +127 -0
  36. package/dist/nodes/s2-websocket/index.js.map +1 -0
  37. package/dist/types/config-nodes.d.ts +39 -0
  38. package/dist/types/config-nodes.js +3 -0
  39. package/dist/types/config-nodes.js.map +1 -0
  40. package/package.json +84 -0
@@ -0,0 +1,655 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ const fs = __importStar(require("fs"));
36
+ const os = __importStar(require("os"));
37
+ const path = __importStar(require("path"));
38
+ const session_1 = require("../../lib/s2/session");
39
+ const messages_1 = require("../../lib/s2/messages");
40
+ const schedule_1 = require("../../lib/s2/schedule");
41
+ const PENDING_INSTRUCTIONS_KEY = 's2PendingInstructions';
42
+ const OMBC_STATUS_KEY = 's2OmbcStatus';
43
+ const PRUNE_GRACE_MS = 3600000; // 1 hour grace before a non-PEBC instruction is pruned
44
+ module.exports = function (RED) {
45
+ function S2RmNode(config) {
46
+ var _a;
47
+ RED.nodes.createNode(this, config);
48
+ const node = this;
49
+ const rmConfigNode = RED.nodes.getNode(config.rmConfig);
50
+ if (!rmConfigNode) {
51
+ node.error('s2-rm-config node is missing - please configure a Resource Manager config');
52
+ node.status({ fill: 'red', shape: 'dot', text: 'config missing' });
53
+ return;
54
+ }
55
+ // Map providesPowerMeasurement config to commodity quantity list
56
+ // Supports legacy boolean (true -> 3_PHASE_SYMMETRIC) and new string values
57
+ function parsePowerMeasurementTypes(value) {
58
+ if (value === true || value === '3_PHASE_SYMMETRIC')
59
+ return ['ELECTRIC.POWER.3_PHASE_SYMMETRIC'];
60
+ if (value === 'L1_L2_L3')
61
+ return ['ELECTRIC.POWER.L1', 'ELECTRIC.POWER.L2', 'ELECTRIC.POWER.L3'];
62
+ return [];
63
+ }
64
+ // Resolve {{global.xxx}} and {{flow.xxx}} context variable templates
65
+ function resolveTemplate(template) {
66
+ return template.replace(/\{\{(global|flow)\.([^}]+)\}\}/g, (_match, scope, key) => {
67
+ const val = node.context()[scope].get(key);
68
+ return val != null ? String(val) : '';
69
+ });
70
+ }
71
+ const rmDetails = {
72
+ resourceId: rmConfigNode.resourceId || (0, messages_1.generateId)(),
73
+ name: rmConfigNode.rmName || 'RM: Virtual',
74
+ roles: (rmConfigNode.roles || 'ENERGY_CONSUMER')
75
+ .split(',').map((s) => s.trim()).filter(Boolean)
76
+ .map((role) => ({ role, commodity: 'ELECTRICITY' })),
77
+ availableControlTypes: (rmConfigNode.controlTypes || 'OPERATION_MODE_BASED_CONTROL')
78
+ .split(',').map((s) => s.trim()).filter(Boolean),
79
+ providesForecast: config.providesForecast === true,
80
+ providesPowerMeasurementTypes: parsePowerMeasurementTypes(config.providesPowerMeasurement),
81
+ instructionProcessingDelay: 0,
82
+ manufacturer: rmConfigNode.manufacturer || 'Victron Energy',
83
+ model: rmConfigNode.model || 'Virtual RM',
84
+ serialNumber: rmConfigNode.serialNumber || node.id,
85
+ firmwareVersion: rmConfigNode.firmwareVersion || '1.0.0'
86
+ };
87
+ let controlTypeConfig = {};
88
+ if (config.controlTypeConfig) {
89
+ try {
90
+ controlTypeConfig = JSON.parse(config.controlTypeConfig);
91
+ }
92
+ catch (e) {
93
+ node.error('Invalid Control Type Config JSON: ' + e.message);
94
+ }
95
+ }
96
+ // Persist/restore schedule across restarts
97
+ const scheduleDir = path.join(((_a = RED.settings) === null || _a === void 0 ? void 0 : _a.userDir) || path.join(os.homedir(), '.node-red'), '.s2');
98
+ const scheduleFile = path.join(scheduleDir, `${node.id}-schedule.json`);
99
+ function saveSchedule(schedule) {
100
+ try {
101
+ fs.mkdirSync(scheduleDir, { recursive: true });
102
+ fs.writeFileSync(scheduleFile, JSON.stringify(schedule, null, 2));
103
+ }
104
+ catch (e) {
105
+ node.warn('Failed to persist S2 schedule: ' + e.message);
106
+ }
107
+ }
108
+ function loadPersistedSchedule() {
109
+ try {
110
+ const raw = fs.readFileSync(scheduleFile, 'utf8');
111
+ const schedule = JSON.parse(raw);
112
+ const now = Date.now();
113
+ const validElements = schedule.elements.filter(el => el.endMs > now);
114
+ if (validElements.length === 0)
115
+ return;
116
+ applySchedule({ ...schedule, elements: validElements });
117
+ node.log(`Restored S2 schedule for CEM ${schedule.cemId} with ${validElements.length} future element(s)`);
118
+ }
119
+ catch (e) {
120
+ if (e.code !== 'ENOENT') {
121
+ node.warn('Failed to load persisted S2 schedule: ' + e.message);
122
+ }
123
+ }
124
+ }
125
+ // Publish CEM REST API endpoint and auth header to flow context for use in downstream function nodes.
126
+ // Derived from the optional s2-cem-config reference: strips the WebSocket path and converts
127
+ // the protocol (wss->https, ws->http) to get the REST API base URL.
128
+ if (config.cem) {
129
+ const cemConfig = RED.nodes.getNode(config.cem);
130
+ if (cemConfig && cemConfig.url) {
131
+ const wsUrl = new URL(cemConfig.url);
132
+ const restProtocol = wsUrl.protocol === 'wss:' ? 'https:' : 'http:';
133
+ const apiPrefix = cemConfig.apiPrefix || '';
134
+ const baseUrl = `${restProtocol}//${wsUrl.host}${apiPrefix}`;
135
+ node.context().flow.set('cemFlexInstructionUrl', `${baseUrl}/resource_managers/${rmDetails.resourceId}/flex_instructions`);
136
+ const { username, password } = cemConfig.credentials || {};
137
+ if (username) {
138
+ const encoded = Buffer.from(`${username}:${password || ''}`).toString('base64');
139
+ node.context().flow.set('cemApiAuth', `Basic ${encoded}`);
140
+ }
141
+ }
142
+ }
143
+ // One session per connected CEM
144
+ const sessions = new Map();
145
+ // Derive default PEBC constraints from the grid connection config
146
+ const defaultMaxPowerW = (0, messages_1.gridConnectionToWatts)(rmConfigNode.gridConnection, rmConfigNode.customMaxPowerW);
147
+ let pendingPEBCConstraints = defaultMaxPowerW != null
148
+ ? { commodityQuantity: 'ELECTRIC.POWER.3_PHASE_SYMMETRIC', minPower: -defaultMaxPowerW, maxPower: defaultMaxPowerW }
149
+ : null;
150
+ let statusTimer = null;
151
+ // PEBC schedule dispatch
152
+ let scheduleTimer = null;
153
+ const SCHEDULE_CONTEXT_KEY = 's2PebcSchedule';
154
+ // Accumulated PEBC slots: keyed by slot start time (ms).
155
+ // Cleared when power_constraints_id changes (new planning period).
156
+ let pebcConstraintsId = null;
157
+ const pebcSlots = new Map();
158
+ function getPending() {
159
+ return node.context().flow.get(PENDING_INSTRUCTIONS_KEY) || [];
160
+ }
161
+ function setPending(pending) {
162
+ node.context().flow.set(PENDING_INSTRUCTIONS_KEY, pending);
163
+ }
164
+ function addToPending(item) {
165
+ const pending = getPending();
166
+ const idx = pending.findIndex(p => p.messageId === item.messageId);
167
+ if (idx >= 0) {
168
+ pending[idx] = item;
169
+ }
170
+ else {
171
+ pending.push(item);
172
+ }
173
+ setPending(pending);
174
+ }
175
+ const pollIntervalMs = rmConfigNode.instructionPollIntervalMs || 2000;
176
+ const isSkipInstructionStatus = rmConfigNode.skipInstructionStatus === true;
177
+ // Poll at the configured interval: dispatch due non-PEBC instructions, prune expired entries.
178
+ const pollTimer = setInterval(() => {
179
+ const pending = getPending();
180
+ if (pending.length === 0)
181
+ return;
182
+ const now = Date.now();
183
+ let changed = false;
184
+ const remaining = [];
185
+ for (const item of pending) {
186
+ const expired = item.isPebc
187
+ ? (item.scheduleEndMs !== undefined && item.scheduleEndMs < now)
188
+ : (item.executionTimeMs + PRUNE_GRACE_MS < now);
189
+ if (expired) {
190
+ changed = true;
191
+ continue;
192
+ }
193
+ if (!item.isPebc && item.executionTimeMs <= now) {
194
+ const session = sessions.get(item.cemId);
195
+ if (session && item.instructionId && !isSkipInstructionStatus) {
196
+ session.sendInstructionStatus(item.instructionId, messages_1.InstructionStatus.STARTED);
197
+ }
198
+ const rawItem = item.instruction;
199
+ const resolvedItemMode = resolveOMBCMode(rawItem);
200
+ const itemOutMsg = { payload: item.instruction, cemId: item.cemId };
201
+ if (resolvedItemMode) {
202
+ itemOutMsg.topic = resolvedItemMode.label;
203
+ itemOutMsg.operationMode = resolvedItemMode;
204
+ }
205
+ node.send([null, null, itemOutMsg, null]);
206
+ changed = true;
207
+ continue;
208
+ }
209
+ remaining.push(item);
210
+ }
211
+ if (changed)
212
+ setPending(remaining);
213
+ }, pollIntervalMs).unref();
214
+ // Deduplication: track last emitted active element to suppress identical re-emits from the CEM.
215
+ let lastEmittedActive = null;
216
+ let duplicateActiveCount = 0;
217
+ function emitActiveElement(schedule) {
218
+ const el = (0, schedule_1.getActiveElement)(schedule, Date.now());
219
+ if (!el)
220
+ return;
221
+ const isDuplicate = lastEmittedActive !== null &&
222
+ lastEmittedActive.startMs === el.startMs &&
223
+ lastEmittedActive.lowerBound === el.lowerBound &&
224
+ lastEmittedActive.upperBound === el.upperBound &&
225
+ lastEmittedActive.commodityQuantity === schedule.commodityQuantity;
226
+ if (isDuplicate) {
227
+ duplicateActiveCount++;
228
+ node.debug(`duplicate active element from CEM (${duplicateActiveCount} total) - suppressing port 3 emit`);
229
+ const count = sessions.size;
230
+ node.status({ fill: 'yellow', shape: 'ring', text: `${count} CEM${count > 1 ? 's' : ''} connected · ${duplicateActiveCount} dup.` });
231
+ return;
232
+ }
233
+ lastEmittedActive = { startMs: el.startMs, lowerBound: el.lowerBound, upperBound: el.upperBound, commodityQuantity: schedule.commodityQuantity };
234
+ duplicateActiveCount = 0;
235
+ updateStatus();
236
+ const slot = pebcSlots.get(el.startMs);
237
+ if (slot && !isSkipInstructionStatus) {
238
+ const session = sessions.get(slot.cemId);
239
+ if (session)
240
+ session.sendInstructionStatus(slot.instructionId, messages_1.InstructionStatus.STARTED);
241
+ }
242
+ node.send([null, null, {
243
+ cemId: schedule.cemId,
244
+ payload: {
245
+ startTime: new Date(el.startMs).toISOString(),
246
+ endTime: new Date(el.endMs).toISOString(),
247
+ duration: el.duration,
248
+ lowerBound: el.lowerBound,
249
+ upperBound: el.upperBound,
250
+ commodityQuantity: schedule.commodityQuantity
251
+ }
252
+ }, null]);
253
+ }
254
+ function scheduleNextDispatch(schedule) {
255
+ if (scheduleTimer) {
256
+ clearTimeout(scheduleTimer);
257
+ scheduleTimer = null;
258
+ }
259
+ const nextStart = (0, schedule_1.getNextElementStart)(schedule, Date.now());
260
+ if (nextStart === null)
261
+ return;
262
+ const delay = Math.max(0, nextStart - Date.now());
263
+ scheduleTimer = setTimeout(() => {
264
+ scheduleTimer = null;
265
+ emitActiveElement(schedule);
266
+ scheduleNextDispatch(schedule);
267
+ }, delay);
268
+ }
269
+ function applySchedule(schedule) {
270
+ node.context().flow.set(SCHEDULE_CONTEXT_KEY, schedule);
271
+ saveSchedule(schedule);
272
+ emitActiveElement(schedule);
273
+ scheduleNextDispatch(schedule);
274
+ node.send([null, null, null, {
275
+ cemId: schedule.cemId,
276
+ payload: {
277
+ commodityQuantity: schedule.commodityQuantity,
278
+ elements: schedule.elements.map(el => ({
279
+ startTime: new Date(el.startMs).toISOString(),
280
+ endTime: new Date(el.endMs).toISOString(),
281
+ durationSec: Math.round(el.duration / 1000),
282
+ lowerBound: el.lowerBound,
283
+ upperBound: el.upperBound
284
+ }))
285
+ }
286
+ }]);
287
+ }
288
+ function resolveOMBCMode(rawInstr) {
289
+ var _a, _b, _c;
290
+ if (rawInstr.message_type !== messages_1.MessageType.OMBC_INSTRUCTION)
291
+ return null;
292
+ const modeId = (rawInstr.operation_mode_id || rawInstr.operation_mode);
293
+ if (!modeId)
294
+ return null;
295
+ const factor = typeof rawInstr.operation_mode_factor === 'number' ? rawInstr.operation_mode_factor : 1;
296
+ const ctc = controlTypeConfig;
297
+ const modes = (_b = (_a = ctc.OMBC) === null || _a === void 0 ? void 0 : _a.systemDescription) === null || _b === void 0 ? void 0 : _b.operationModes;
298
+ const modeIndex = (_c = modes === null || modes === void 0 ? void 0 : modes.findIndex(m => m.id === modeId)) !== null && _c !== void 0 ? _c : -1;
299
+ const mode = modeIndex >= 0 ? modes[modeIndex] : undefined;
300
+ return {
301
+ id: modeId,
302
+ index: modeIndex,
303
+ label: (mode === null || mode === void 0 ? void 0 : mode.diagnostic_label) || modeId,
304
+ factor,
305
+ powerRanges: (mode === null || mode === void 0 ? void 0 : mode.power_ranges) || []
306
+ };
307
+ }
308
+ // Persist the committed OMBC mode to flow context so reconnecting CEMs see the correct status.
309
+ // Called at instruction accept time (in onInstruction, which runs inside _ackAndForward).
310
+ // The session has already updated _currentOMBCStatus and sent OMBC.Status by this point.
311
+ function persistOMBCStatusIfInstruction(session, rawInstr) {
312
+ if (rawInstr.message_type !== messages_1.MessageType.OMBC_INSTRUCTION)
313
+ return;
314
+ const status = session.currentOMBCStatus;
315
+ if (status) {
316
+ node.context().flow.set(OMBC_STATUS_KEY, status);
317
+ }
318
+ }
319
+ node.status({ fill: 'grey', shape: 'ring', text: 'no CEMs connected' });
320
+ function updateStatus() {
321
+ const count = sessions.size;
322
+ if (count === 0) {
323
+ node.status({ fill: 'grey', shape: 'ring', text: 'no CEMs connected' });
324
+ }
325
+ else {
326
+ node.status({ fill: 'green', shape: 'dot', text: `${count} CEM${count > 1 ? 's' : ''} connected` });
327
+ }
328
+ }
329
+ function createSession(cemId) {
330
+ // Resolve context variable templates at connect time (not at init)
331
+ const resolvedDetails = rmDetails.serialNumber.includes('{{')
332
+ ? { ...rmDetails, serialNumber: resolveTemplate(rmDetails.serialNumber) || node.id }
333
+ : rmDetails;
334
+ const session = new session_1.S2Session({
335
+ cemId,
336
+ rmDetails: resolvedDetails,
337
+ controlTypeConfig,
338
+ skipInstructionStatus: isSkipInstructionStatus,
339
+ onSend: (msg) => {
340
+ const m = msg;
341
+ if (m.message_type === messages_1.MessageType.PEBC_POWER_CONSTRAINTS && typeof m.id === 'string') {
342
+ node.context().flow.set('pebcConstraintsId', m.id);
343
+ }
344
+ node.send([{ payload: { s2Signal: 'Message', message: msg }, cemId }, null, null]);
345
+ },
346
+ onStateChange: (state) => {
347
+ if (state === session_1.State.CONNECTED) {
348
+ node.log(`CEM ${cemId} handshake complete`);
349
+ }
350
+ updateStatus();
351
+ },
352
+ onMessage: (msg) => {
353
+ if (msg.message_type === messages_1.MessageType.RECEPTION_STATUS &&
354
+ msg.status && msg.status !== messages_1.ReceptionStatusResult.OK) {
355
+ const diag = msg.diagnostic_label ? `: ${msg.diagnostic_label}` : '';
356
+ node.warn(`CEM ${cemId} rejected message ${msg.subject_message_id || '?'} with ${msg.status}${diag}`);
357
+ if (statusTimer)
358
+ clearTimeout(statusTimer);
359
+ node.status({ fill: 'yellow', shape: 'dot', text: `CEM rejection: ${msg.status}` });
360
+ statusTimer = setTimeout(() => { statusTimer = null; updateStatus(); }, 5000);
361
+ }
362
+ if (msg.message_type === messages_1.MessageType.REVOKE_OBJECT) {
363
+ const revokedId = msg.object_id;
364
+ if (revokedId) {
365
+ const pending = getPending();
366
+ const revokedItem = pending.find(p => p.instructionId === revokedId);
367
+ if (revokedItem) {
368
+ setPending(pending.filter(p => p.instructionId !== revokedId));
369
+ if (revokedItem.isPebc) {
370
+ for (const [key, slot] of pebcSlots.entries()) {
371
+ if (slot.instructionId === revokedId)
372
+ pebcSlots.delete(key);
373
+ }
374
+ if (pebcSlots.size === 0) {
375
+ if (scheduleTimer) {
376
+ clearTimeout(scheduleTimer);
377
+ scheduleTimer = null;
378
+ }
379
+ node.context().flow.set(SCHEDULE_CONTEXT_KEY, null);
380
+ }
381
+ else {
382
+ const sorted = [...pebcSlots.values()].sort((a, b) => a.element.startMs - b.element.startMs);
383
+ const rebuilt = {
384
+ receivedAt: Date.now(),
385
+ cemId: sorted[0].cemId,
386
+ instructionId: sorted[0].instructionId,
387
+ commodityQuantity: sorted[0].commodityQuantity,
388
+ elements: sorted.map(s => s.element)
389
+ };
390
+ applySchedule(rebuilt);
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+ node.send([null, { payload: msg, cemId, topic: msg.message_type }, null]);
397
+ if (msg.message_type === messages_1.MessageType.SELECT_CONTROL_TYPE &&
398
+ rmDetails.providesPowerMeasurementTypes.length > 0) {
399
+ node.send([{
400
+ payload: {
401
+ s2Signal: 'PowerMeasurementStart',
402
+ commodityQuantities: rmDetails.providesPowerMeasurementTypes
403
+ },
404
+ cemId
405
+ }, null, null]);
406
+ }
407
+ },
408
+ onInstruction: (msg) => {
409
+ const rawMsg = msg;
410
+ const messageId = rawMsg.message_id;
411
+ const instructionId = rawMsg.id;
412
+ const executionTimeStr = rawMsg.execution_time;
413
+ const executionTimeMs = executionTimeStr ? new Date(executionTimeStr).getTime() : Date.now();
414
+ // Persist the OMBC status committed by the session in _ackAndForward.
415
+ // Done at accept time so reconnecting CEMs always see the correct mode,
416
+ // even if the CEM disconnects before the instruction's execution_time.
417
+ const sess = sessions.get(cemId);
418
+ if (sess) {
419
+ persistOMBCStatusIfInstruction(sess, rawMsg);
420
+ }
421
+ if (msg.message_type === messages_1.MessageType.PEBC_INSTRUCTION) {
422
+ const parsed = (0, schedule_1.parsePebcInstruction)(rawMsg, Date.now(), cemId);
423
+ if (parsed && parsed.elements.length > 0) {
424
+ const constraintsId = rawMsg.power_constraints_id;
425
+ if (constraintsId && constraintsId !== pebcConstraintsId) {
426
+ pebcSlots.clear();
427
+ pebcConstraintsId = constraintsId;
428
+ }
429
+ for (const el of parsed.elements) {
430
+ pebcSlots.set(el.startMs, { element: el, commodityQuantity: parsed.commodityQuantity, cemId, instructionId: parsed.instructionId });
431
+ }
432
+ const sorted = [...pebcSlots.values()].sort((a, b) => a.element.startMs - b.element.startMs);
433
+ const combined = {
434
+ receivedAt: Date.now(),
435
+ cemId: sorted[0].cemId,
436
+ instructionId: parsed.instructionId,
437
+ commodityQuantity: sorted[0].commodityQuantity,
438
+ elements: sorted.map(s => s.element)
439
+ };
440
+ if (messageId) {
441
+ addToPending({
442
+ messageId,
443
+ instructionId,
444
+ instruction: rawMsg,
445
+ cemId,
446
+ executionTimeMs,
447
+ isPebc: true,
448
+ scheduleEndMs: combined.elements[combined.elements.length - 1].endMs
449
+ });
450
+ }
451
+ applySchedule(combined);
452
+ return;
453
+ }
454
+ }
455
+ // Non-PEBC (or PEBC with no valid schedule): dispatch immediately or queue.
456
+ const now = Date.now();
457
+ if (executionTimeMs <= now) {
458
+ const session = sessions.get(cemId);
459
+ if (session && instructionId && !isSkipInstructionStatus) {
460
+ session.sendInstructionStatus(instructionId, messages_1.InstructionStatus.STARTED);
461
+ }
462
+ const resolvedMode = resolveOMBCMode(rawMsg);
463
+ const outMsg = { payload: msg, cemId };
464
+ if (resolvedMode) {
465
+ outMsg.topic = resolvedMode.label;
466
+ outMsg.operationMode = resolvedMode;
467
+ }
468
+ node.send([null, null, outMsg, null]);
469
+ }
470
+ else {
471
+ if (messageId) {
472
+ addToPending({
473
+ messageId,
474
+ instructionId,
475
+ instruction: rawMsg,
476
+ cemId,
477
+ executionTimeMs,
478
+ isPebc: false,
479
+ scheduleEndMs: undefined
480
+ });
481
+ }
482
+ }
483
+ },
484
+ onError: (err) => {
485
+ node.error(`S2 session error for CEM ${cemId}: ${err.message}`);
486
+ }
487
+ });
488
+ sessions.set(cemId, session);
489
+ if (pendingPEBCConstraints) {
490
+ session.setPEBCPowerConstraints(pendingPEBCConstraints);
491
+ }
492
+ return session;
493
+ }
494
+ node.on('input', (msg, _send, done) => {
495
+ var _a;
496
+ if (!msg.payload || typeof msg.payload !== 'object') {
497
+ done(new Error('msg.payload must be an object'));
498
+ return;
499
+ }
500
+ const { command, cemId, message, keepAliveInterval } = msg.payload;
501
+ if (!command) {
502
+ done(new Error("msg.payload must have a 'command' field"));
503
+ return;
504
+ }
505
+ if (!cemId) {
506
+ done(new Error("msg.payload must have a 'cemId' field"));
507
+ return;
508
+ }
509
+ switch (command) {
510
+ case 'Connect': {
511
+ if (sessions.has(cemId)) {
512
+ node.warn(`CEM ${cemId} connected again without prior Disconnect - replacing session`);
513
+ sessions.get(cemId).dispose();
514
+ sessions.delete(cemId);
515
+ }
516
+ const session = createSession(cemId);
517
+ // Restore persisted OMBC status so this session reports the last known mode
518
+ // when SELECT_CONTROL_TYPE(OMBC) is received. Falls back to the static config.
519
+ const savedOmbcStatus = node.context().flow.get(OMBC_STATUS_KEY);
520
+ if (savedOmbcStatus) {
521
+ session.updateOMBCStatus(savedOmbcStatus);
522
+ }
523
+ session.start();
524
+ node.send([null, { topic: 'Connected', cemId }, null]);
525
+ node.log(`CEM ${cemId} connected (keepAliveInterval: ${keepAliveInterval}s)`);
526
+ updateStatus();
527
+ done();
528
+ break;
529
+ }
530
+ case 'Message': {
531
+ const session = sessions.get(cemId);
532
+ if (!session) {
533
+ done(new Error(`No session for CEM ${cemId} - missing Connect?`));
534
+ return;
535
+ }
536
+ if (message === undefined || message === null) {
537
+ done(new Error(`message is missing for CEM ${cemId}`));
538
+ return;
539
+ }
540
+ session.handleMessage(message);
541
+ done();
542
+ break;
543
+ }
544
+ case 'KeepAlive': {
545
+ const kaSession = sessions.get(cemId);
546
+ if (!kaSession) {
547
+ node.warn(`KeepAlive for unknown CEM ${cemId}`);
548
+ }
549
+ else {
550
+ kaSession.keepAlive();
551
+ }
552
+ done();
553
+ break;
554
+ }
555
+ case 'PowerMeasurement': {
556
+ const pmSession = sessions.get(cemId);
557
+ if (!pmSession) {
558
+ node.warn(`PowerMeasurement for unknown CEM ${cemId} - ignoring`);
559
+ done();
560
+ return;
561
+ }
562
+ const { values } = msg.payload;
563
+ if (!Array.isArray(values) || values.length === 0) {
564
+ done(new Error(`PowerMeasurement requires a non-empty values array for CEM ${cemId}`));
565
+ return;
566
+ }
567
+ pmSession.send((0, messages_1.makePowerMeasurement)(values));
568
+ done();
569
+ break;
570
+ }
571
+ case 'PowerConstraints': {
572
+ const { constraints } = msg.payload;
573
+ if (!constraints || typeof constraints !== 'object') {
574
+ done(new Error('PowerConstraints requires a constraints object'));
575
+ return;
576
+ }
577
+ pendingPEBCConstraints = constraints;
578
+ // Apply to all currently connected sessions
579
+ for (const session of sessions.values()) {
580
+ session.setPEBCPowerConstraints(constraints);
581
+ }
582
+ done();
583
+ break;
584
+ }
585
+ case 'Forecast': {
586
+ const { forecast } = msg.payload;
587
+ if (!forecast || !forecast.startTime || !Array.isArray(forecast.elements)) {
588
+ done(new Error('Forecast requires a forecast object with startTime and elements'));
589
+ return;
590
+ }
591
+ const fcSession = sessions.get(cemId);
592
+ if (!fcSession) {
593
+ node.warn(`Forecast for unknown CEM ${cemId} - ignoring`);
594
+ done();
595
+ return;
596
+ }
597
+ fcSession.send((0, messages_1.makePowerForecast)(forecast));
598
+ done();
599
+ break;
600
+ }
601
+ case 'Disconnect': {
602
+ (_a = sessions.get(cemId)) === null || _a === void 0 ? void 0 : _a.dispose();
603
+ sessions.delete(cemId);
604
+ node.send([null, { topic: 'Disconnected', cemId, reason: 'cem_initiated' }, null]);
605
+ node.log(`CEM ${cemId} disconnected`);
606
+ updateStatus();
607
+ done();
608
+ break;
609
+ }
610
+ case 'InstructionStatus': {
611
+ const { instructionId, status } = msg.payload;
612
+ if (!instructionId || typeof instructionId !== 'string') {
613
+ done(new Error('InstructionStatus requires an instructionId string'));
614
+ return;
615
+ }
616
+ if (!status || !Object.values(messages_1.InstructionStatus).includes(status)) {
617
+ done(new Error(`InstructionStatus requires a valid status: ${Object.values(messages_1.InstructionStatus).join(', ')}`));
618
+ return;
619
+ }
620
+ const isSession = sessions.get(cemId);
621
+ if (!isSession) {
622
+ node.warn(`InstructionStatus for unknown CEM ${cemId} - ignoring`);
623
+ done();
624
+ return;
625
+ }
626
+ isSession.sendInstructionStatus(instructionId, status);
627
+ done();
628
+ break;
629
+ }
630
+ default:
631
+ done(new Error(`Unknown command: ${command}`));
632
+ }
633
+ });
634
+ node.on('close', (done) => {
635
+ if (statusTimer) {
636
+ clearTimeout(statusTimer);
637
+ statusTimer = null;
638
+ }
639
+ clearInterval(pollTimer);
640
+ if (scheduleTimer) {
641
+ clearTimeout(scheduleTimer);
642
+ scheduleTimer = null;
643
+ }
644
+ for (const session of sessions.values()) {
645
+ session.dispose();
646
+ }
647
+ sessions.clear();
648
+ node.status({});
649
+ done();
650
+ });
651
+ loadPersistedSchedule();
652
+ }
653
+ RED.nodes.registerType('s2-rm', S2RmNode);
654
+ };
655
+ //# sourceMappingURL=index.js.map