iobroker.prosoft_flow 0.1.0

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/main.js ADDED
@@ -0,0 +1,832 @@
1
+ 'use strict';
2
+
3
+ const utils = require('@iobroker/adapter-core');
4
+ const { load } = require('./lib/engineBridge');
5
+
6
+ class ProsoftFlow extends utils.Adapter {
7
+ /**
8
+ * @param {Partial<utils.AdapterOptions>} [options={}]
9
+ */
10
+ constructor(options = {}) {
11
+ super({
12
+ ...options,
13
+ name: 'prosoft_flow',
14
+ });
15
+
16
+ this.engine = null;
17
+ this.tagMap = new Map();
18
+ this.dailyTagMap = new Map();
19
+ this.hourlyTagMap = new Map();
20
+ this.snapshotTagMap = new Map();
21
+ this.customTagMap = new Map();
22
+
23
+ // AFC Clock and schedule tracking
24
+ this.afcClocks = new Map();
25
+ this.groupJobStates = new Map();
26
+ this.pollingTimers = new Map();
27
+ this.resetTimers = new Map();
28
+
29
+ this.on('ready', this.onReady.bind(this));
30
+ this.on('stateChange', this.onStateChange.bind(this));
31
+ this.on('unload', this.onUnload.bind(this));
32
+ }
33
+
34
+ /**
35
+ * Is called when databases are connected and adapter received configuration.
36
+ */
37
+ async onReady() {
38
+ this.log.info('Starting Prosoft Flow Computer Adapter...');
39
+
40
+ // 1. Load Native AOT Engine
41
+ try {
42
+ this.engine = load(this.log);
43
+ this.log.info(`Native Engine loaded successfully (version: ${this.engine.Version()})`);
44
+ } catch (err) {
45
+ this.log.error(`Failed to load native engine addon: ${err.message}`);
46
+ this.setState('info.connection', { val: false, ack: true });
47
+ return;
48
+ }
49
+
50
+ // 2. Machine Hardware ID & Environment & Licensing (RFC-005)
51
+ try {
52
+ const hwId = this.engine.GetHardwareId();
53
+ const envType = this.engine.GetEnvironmentType();
54
+ const licInfoRaw = this.engine.GetLicenseInfo(this.config.licenseKey || '');
55
+ const licInfo = JSON.parse(licInfoRaw);
56
+
57
+ await this.setState('info.hardwareId', { val: hwId, ack: true });
58
+ await this.setState('info.environmentType', { val: envType, ack: true });
59
+ await this.setState('info.licenseStatus', {
60
+ val: licInfo.valid ? `Valid (${licInfo.edition || 'licensed'})` : licInfo.message,
61
+ ack: true,
62
+ });
63
+ await this.setState('info.licenseKey', { val: this.config.licenseKey || '', ack: true });
64
+ await this.setState('info.licenseValid', { val: Boolean(licInfo.valid), ack: true });
65
+ await this.setState('info.licenseExpired', { val: Boolean(licInfo.expired), ack: true });
66
+ await this.setState('info.licenseExpiresAt', { val: licInfo.expiresAt || '', ack: true });
67
+ await this.setState('info.licenseTimeSource', { val: this.config.licenseTimeSource || 'afc', ack: true });
68
+ await this.setState('info.ready', { val: Boolean(licInfo.valid && !licInfo.expired), ack: true });
69
+
70
+ this.log.info(`RFC-005 Hardware ID: ${hwId} | Environment: ${envType}`);
71
+ if (licInfo.valid) {
72
+ this.log.info(`License valid: Edition=${licInfo.edition}, MaxTags=${licInfo.maxTags || 'unlimited'}`);
73
+ } else {
74
+ this.log.warn(`License notice: ${licInfo.message}`);
75
+ }
76
+ } catch (err) {
77
+ this.log.warn(`Error probing licensing information: ${err.message}`);
78
+ }
79
+
80
+ // 3. Register Engine Event Callback
81
+ this.engine.OnEvent((evtJson) => {
82
+ this.handleNativeEvent(evtJson);
83
+ });
84
+
85
+ // 4. Create State Tree for Polling Tags, Dynamic Groups and Reports
86
+ await this.initializeStateObjects();
87
+
88
+ // 5. Build Engine Configuration & Start
89
+ const engineConfig = this.buildEngineConfig();
90
+ try {
91
+ this.engine.Init(JSON.stringify(engineConfig));
92
+ this.engine.Start();
93
+ this.setState('info.pollActive', { val: true, ack: true });
94
+ this.setState('info.pollPaused', { val: false, ack: true });
95
+ this.log.info('Native Modbus Polling Engine started.');
96
+ } catch (err) {
97
+ this.log.error(`Failed to initialize/start engine: ${err.message}`);
98
+ }
99
+
100
+ // 6. Setup Polling Timers for groups configured with readMode === 'polling'
101
+ this.setupGroupPolling();
102
+
103
+ // 7. Subscribe to control states & writable licenseKey state
104
+ this.subscribeStates('info.licenseKey');
105
+ this.subscribeStates('reports.set*');
106
+
107
+ // Subscribe to any writable tag states
108
+ const tags = this.config.tags || [];
109
+ for (const tag of tags) {
110
+ const stateId = this.getTagStateId(tag);
111
+ this.subscribeStates(stateId);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Formats tag state ID with group hierarchy.
117
+ */
118
+ getTagStateId(tag) {
119
+ const group = (tag.group || '').trim().replace(/[^a-zA-Z0-9_-]/g, '_');
120
+ const name = (tag.name || '').trim().replace(/[^a-zA-Z0-9_-]/g, '_');
121
+ return group ? `${group}.${name}` : name;
122
+ }
123
+
124
+ /**
125
+ * Safe formula evaluator: x is replaced by raw numeric value.
126
+ */
127
+ applyFormula(formula, val) {
128
+ if (!formula || typeof formula !== 'string' || !formula.trim()) return val;
129
+ if (val === null || val === undefined || typeof val !== 'number') return val;
130
+ try {
131
+ const fn = new Function('x', 'Math', `"use strict"; return (${formula});`);
132
+ const res = fn(val, Math);
133
+ return typeof res === 'number' && !isNaN(res) ? res : val;
134
+ } catch (err) {
135
+ this.log.debug(`Formula evaluation error on '${formula}': ${err.message}`);
136
+ return val;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Maps Modbus data type to ioBroker common state definition.
142
+ */
143
+ getTypeDefinition(dt) {
144
+ const type = (dt || 'float').toLowerCase();
145
+ if (type === 'bool') {
146
+ return { type: 'boolean', role: 'indicator' };
147
+ }
148
+ if (type === 'dt' || type.startsWith('ascii')) {
149
+ return { type: 'string', role: 'text' };
150
+ }
151
+ return { type: 'number', role: 'value' };
152
+ }
153
+
154
+ /**
155
+ * Initializes all objects and state channels in ioBroker.
156
+ */
157
+ async initializeStateObjects() {
158
+ this.log.info('Initializing ioBroker state objects...');
159
+
160
+ // Polling Tags
161
+ const tags = this.config.tags || [];
162
+ const groupsCreated = new Set();
163
+
164
+ for (const tag of tags) {
165
+ if (!tag.name) continue;
166
+ this.tagMap.set(tag.name, tag);
167
+
168
+ const group = (tag.group || '').trim().replace(/[^a-zA-Z0-9_-]/g, '_');
169
+ if (group && !groupsCreated.has(group)) {
170
+ await this.setObjectNotExistsAsync(group, {
171
+ type: 'channel',
172
+ common: { name: tag.group || group },
173
+ native: {},
174
+ });
175
+ groupsCreated.add(group);
176
+ }
177
+
178
+ const stateId = this.getTagStateId(tag);
179
+ const typeDef = this.getTypeDefinition(tag.dataType);
180
+
181
+ await this.setObjectNotExistsAsync(stateId, {
182
+ type: 'state',
183
+ common: {
184
+ name: tag.description || tag.name,
185
+ type: typeDef.type,
186
+ role: typeDef.role,
187
+ read: true,
188
+ write: true,
189
+ unit: tag.unit || '',
190
+ },
191
+ native: {
192
+ address: tag.address,
193
+ dataType: tag.dataType,
194
+ formula: tag.formula || '',
195
+ },
196
+ });
197
+ }
198
+
199
+ // Ensure Dynamic Report Group Channels and Flags
200
+ const reportGroups = this.config.reportGroups || [];
201
+ for (const rg of reportGroups) {
202
+ if (!rg.name) continue;
203
+ const grpKey = rg.name.toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '_');
204
+
205
+ await this.setObjectNotExistsAsync(`reports.${grpKey}`, {
206
+ type: 'channel',
207
+ common: { name: `${rg.name} Report Channel` },
208
+ native: {},
209
+ });
210
+ await this.setObjectNotExistsAsync(`reports.${grpKey}.readStarted`, {
211
+ type: 'state',
212
+ common: { role: 'indicator', name: `${rg.name} read cycle started`, type: 'boolean', read: true, write: true, def: false },
213
+ native: {},
214
+ });
215
+ await this.setObjectNotExistsAsync(`reports.${grpKey}.readFinished`, {
216
+ type: 'state',
217
+ common: { role: 'indicator', name: `${rg.name} read cycle finished`, type: 'boolean', read: true, write: true, def: false },
218
+ native: {},
219
+ });
220
+ await this.setObjectNotExistsAsync(`reports.${grpKey}.status`, {
221
+ type: 'state',
222
+ common: { role: 'text', name: `${rg.name} lifecycle status`, type: 'string', read: true, write: false, def: 'idle' },
223
+ native: {},
224
+ });
225
+ await this.setObjectNotExistsAsync(`reports.${grpKey}.lastReadTime`, {
226
+ type: 'state',
227
+ common: { role: 'text', name: `${rg.name} last read timestamp`, type: 'string', read: true, write: false, def: '' },
228
+ native: {},
229
+ });
230
+ await this.setObjectNotExistsAsync(`reports.set${rg.name}`, {
231
+ type: 'state',
232
+ common: { role: 'button', name: `Trigger ${rg.name} Report Read`, type: 'boolean', read: true, write: true, def: false },
233
+ native: {},
234
+ });
235
+ }
236
+
237
+ // Daily report tags
238
+ const dailyTags = this.config.dailyTags || [];
239
+ for (const tag of dailyTags) {
240
+ if (!tag.name) continue;
241
+ this.dailyTagMap.set(tag.name, tag);
242
+ const sId = `reports.daily.${tag.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
243
+ const typeDef = this.getTypeDefinition(tag.dataType);
244
+ await this.setObjectNotExistsAsync(sId, {
245
+ type: 'state',
246
+ common: {
247
+ name: tag.name,
248
+ type: typeDef.type,
249
+ role: typeDef.role,
250
+ read: true,
251
+ write: false,
252
+ },
253
+ native: { ...tag },
254
+ });
255
+ }
256
+
257
+ // Hourly report tags
258
+ const hourlyTags = this.config.hourlyTags || [];
259
+ for (const tag of hourlyTags) {
260
+ if (!tag.name) continue;
261
+ this.hourlyTagMap.set(tag.name, tag);
262
+ const sId = `reports.hourly.${tag.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
263
+ const typeDef = this.getTypeDefinition(tag.dataType);
264
+ await this.setObjectNotExistsAsync(sId, {
265
+ type: 'state',
266
+ common: {
267
+ name: tag.name,
268
+ type: typeDef.type,
269
+ role: typeDef.role,
270
+ read: true,
271
+ write: false,
272
+ },
273
+ native: { ...tag },
274
+ });
275
+ }
276
+
277
+ // Snapshot report tags
278
+ const snapshotTags = this.config.snapshotTags || [];
279
+ for (const tag of snapshotTags) {
280
+ if (!tag.name) continue;
281
+ this.snapshotTagMap.set(tag.name, tag);
282
+ const sId = `reports.snapshot.${tag.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
283
+ const typeDef = this.getTypeDefinition(tag.dataType);
284
+ await this.setObjectNotExistsAsync(sId, {
285
+ type: 'state',
286
+ common: {
287
+ name: tag.name,
288
+ type: typeDef.type,
289
+ role: typeDef.role,
290
+ read: true,
291
+ write: false,
292
+ },
293
+ native: { ...tag },
294
+ });
295
+ }
296
+
297
+ // Custom report tags (Prover and user-added groups)
298
+ const customReportTags = this.config.customReportTags || [];
299
+ for (const tag of customReportTags) {
300
+ if (!tag.name || !tag.groupName) continue;
301
+ const grpKey = tag.groupName.toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '_');
302
+ this.customTagMap.set(`${grpKey}.${tag.name}`, tag);
303
+
304
+ const sId = `reports.${grpKey}.${tag.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
305
+ const typeDef = this.getTypeDefinition(tag.dataType);
306
+ await this.setObjectNotExistsAsync(sId, {
307
+ type: 'state',
308
+ common: {
309
+ name: tag.name,
310
+ type: typeDef.type,
311
+ role: typeDef.role,
312
+ read: true,
313
+ write: false,
314
+ },
315
+ native: { ...tag },
316
+ });
317
+ }
318
+
319
+ this.log.info(`Initialized ${tags.length} polling tags and report groups.`);
320
+ }
321
+
322
+ /**
323
+ * Builds native engine configuration JSON.
324
+ */
325
+ buildEngineConfig() {
326
+ const reportGroupsConfig = [];
327
+ const definedGroups = this.config.reportGroups || [];
328
+
329
+ for (const rg of definedGroups) {
330
+ if (!rg.name) continue;
331
+ const grpNameLower = rg.name.toLowerCase();
332
+
333
+ let tagsForGroup = [];
334
+ if (grpNameLower === 'daily') {
335
+ tagsForGroup = (this.config.dailyTags || []).map((t) => ({
336
+ name: t.name,
337
+ meterNumber: t.meter,
338
+ addressAge0: Number(t.addressAge0),
339
+ addressAgeOthers: Number(t.addressAgeOthers),
340
+ dataType: t.dataType,
341
+ formula: t.formula || '',
342
+ }));
343
+ } else if (grpNameLower === 'hourly') {
344
+ tagsForGroup = (this.config.hourlyTags || []).map((t) => ({
345
+ name: t.name,
346
+ meterNumber: t.meter,
347
+ addressAge0: Number(t.addressAge0),
348
+ addressAgeOthers: Number(t.addressAgeOthers),
349
+ dataType: t.dataType,
350
+ formula: t.formula || '',
351
+ }));
352
+ } else if (grpNameLower === 'snapshot') {
353
+ tagsForGroup = (this.config.snapshotTags || []).map((t) => ({
354
+ name: t.name,
355
+ meterNumber: t.meter,
356
+ addressAge0: Number(t.address),
357
+ addressAgeOthers: Number(t.address),
358
+ dataType: t.dataType,
359
+ formula: t.formula || '',
360
+ }));
361
+ } else {
362
+ // Filter customReportTags for this group
363
+ const customTags = (this.config.customReportTags || []).filter(
364
+ (t) => (t.groupName || '').equalsIgnoreCase ? t.groupName.equalsIgnoreCase(rg.name) : (t.groupName || '').toLowerCase() === grpNameLower
365
+ );
366
+ tagsForGroup = customTags.map((t) => ({
367
+ name: t.name,
368
+ meterNumber: t.meter,
369
+ addressAge0: Number(t.addressAge0),
370
+ addressAgeOthers: Number(t.addressAgeOthers),
371
+ dataType: t.dataType,
372
+ formula: t.formula || '',
373
+ }));
374
+ }
375
+
376
+ reportGroupsConfig.push({
377
+ name: rg.name,
378
+ ageRegisterM1: Number(rg.ageRegisterM1) || 0,
379
+ ageRegisterM2: Number(rg.ageRegisterM2) || 0,
380
+ ageRegisterM3: Number(rg.ageRegisterM3) || 0,
381
+ tags: tagsForGroup,
382
+ });
383
+ }
384
+
385
+ return {
386
+ connectionType: this.config.connectionType || 'serial',
387
+ serialPort: this.config.serialPort || 'COM1',
388
+ baudRate: Number(this.config.baudRate) || 9600,
389
+ dataBits: Number(this.config.dataBits) || 8,
390
+ stopBits: Number(this.config.stopBits) || 1,
391
+ parity: this.config.parity || 'none',
392
+ tcpHost: this.config.tcpHost || '127.0.0.1',
393
+ tcpPort: Number(this.config.tcpPort) || 502,
394
+ tcpTimeout: Number(this.config.tcpTimeout) || 2000,
395
+ reconnectInterval: Number(this.config.reconnectInterval) || 5000,
396
+ slaveId: Number(this.config.slaveId) || 244,
397
+ pollInterval: Number(this.config.pollInterval) || 1000,
398
+ licenseKey: this.config.licenseKey || '',
399
+ licenseTimeSource: this.config.licenseTimeSource || 'afc',
400
+ licenseTimeAddress: Number(this.config.licenseTimeAddress) || 9950,
401
+ tags: (this.config.tags || []).map((t) => ({
402
+ name: t.name,
403
+ address: Number(t.address),
404
+ registerType: t.dataType === 'bool' ? 'coil' : 'holding',
405
+ dataType: t.dataType || 'float',
406
+ poll: t.poll !== false,
407
+ formula: t.formula || '',
408
+ })),
409
+ reportGroups: reportGroupsConfig,
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Handles events emitted by the native C# AOT engine.
415
+ */
416
+ handleNativeEvent(jsonStr) {
417
+ try {
418
+ const evt = JSON.parse(jsonStr);
419
+ if (evt.type === 'state') {
420
+ this.setState('info.connection', { val: Boolean(evt.connected), ack: true });
421
+ } else if (evt.type === 'license_state') {
422
+ this.setState('info.licenseValid', { val: Boolean(evt.valid), ack: true });
423
+ this.setState('info.licenseExpired', { val: Boolean(evt.expired), ack: true });
424
+ this.setState('info.licenseStatus', { val: evt.status || '', ack: true });
425
+ if (evt.expiresAt !== undefined) {
426
+ this.setState('info.licenseExpiresAt', { val: evt.expiresAt || '', ack: true });
427
+ }
428
+ if (evt.referenceTime) {
429
+ this.setState('info.referenceTime', { val: evt.referenceTime, ack: true });
430
+ }
431
+ if (evt.timeSource) {
432
+ this.setState('info.licenseTimeSource', { val: evt.timeSource, ack: true });
433
+ }
434
+ const isReady = Boolean(evt.valid && !evt.expired);
435
+ this.setState('info.ready', { val: isReady, ack: true });
436
+ this.setState('info.pollPaused', { val: !isReady, ack: true });
437
+ } else if (evt.type === 'values' && Array.isArray(evt.data)) {
438
+ for (const item of evt.data) {
439
+ const tag = this.tagMap.get(item.id);
440
+ if (!tag) continue;
441
+ const stateId = this.getTagStateId(tag);
442
+ let val = item.val;
443
+ if (tag.formula && typeof val === 'number') {
444
+ val = this.applyFormula(tag.formula, val);
445
+ }
446
+ this.setState(stateId, { val, ack: true, ts: item.ts });
447
+ }
448
+ } else if (evt.type === 'afc_time') {
449
+ // AFC Clock Event: address + time string
450
+ const addr = evt.address ? Number(evt.address) : 9900;
451
+ const timeStr = evt.time || '';
452
+ this.processAfcClock(addr, timeStr);
453
+ } else if (evt.type === 'report_ready') {
454
+ this.handleReportReady(evt.data);
455
+ } else if (evt.type === 'log') {
456
+ const lvl = evt.level === 'error' ? 'error' : evt.level === 'warn' ? 'warn' : 'info';
457
+ this.log[lvl](`[Native Engine] ${evt.msg}`);
458
+ }
459
+ } catch (err) {
460
+ this.log.error(`Error parsing native event: ${err.message}`);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Parses DateTime string formatted as "YYYY-MM-DD HH:mm:ss"
466
+ */
467
+ parseTimeString(timeStr) {
468
+ if (!timeStr || typeof timeStr !== 'string') return null;
469
+ const parts = timeStr.trim().split(' ');
470
+ if (parts.length < 2) return null;
471
+
472
+ const dateParts = parts[0].split('-');
473
+ const timeParts = parts[1].split(':');
474
+ if (dateParts.length < 3 || timeParts.length < 3) return null;
475
+
476
+ const year = parseInt(dateParts[0], 10);
477
+ const month = parseInt(dateParts[1], 10);
478
+ const day = parseInt(dateParts[2], 10);
479
+ const hour = parseInt(timeParts[0], 10);
480
+ const minute = parseInt(timeParts[1], 10);
481
+ const second = parseInt(timeParts[2], 10);
482
+
483
+ if (isNaN(hour) || isNaN(minute) || isNaN(second)) return null;
484
+
485
+ return { year, month, day, hour, minute, second, timeStr };
486
+ }
487
+
488
+ /**
489
+ * Processes AFC real-time clock updates and drives schedules.
490
+ */
491
+ processAfcClock(address, timeStr) {
492
+ const parsed = this.parseTimeString(timeStr);
493
+ if (!parsed) return;
494
+
495
+ this.afcClocks.set(address, parsed);
496
+
497
+ const defaultReg = Number(this.config.afcTimeRegister || 9900);
498
+ if (address === defaultReg) {
499
+ this.setState('info.afcTime', { val: timeStr, ack: true });
500
+ }
501
+
502
+ // Check schedule for all enabled groups that bind to this register
503
+ const groups = this.config.reportGroups || [];
504
+ for (const group of groups) {
505
+ if (!group.enabled) continue;
506
+ if (group.readMode !== 'schedule') continue;
507
+
508
+ const targetReg = Number(group.timeRegister || defaultReg);
509
+ if (targetReg === address) {
510
+ this.evaluateGroupSchedule(group, parsed);
511
+ }
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Evaluates schedule matching condition for a group against the bound AFC clock.
517
+ */
518
+ evaluateGroupSchedule(group, afcClock) {
519
+ const { hour, minute, second, day } = afcClock;
520
+ const grpKey = group.name.toLowerCase();
521
+
522
+ const targetMinute = Number(group.scheduleMinute ?? 0);
523
+ const targetHour = Number(group.scheduleHour ?? 0);
524
+ const targetDay = Number(group.scheduleDay ?? 1);
525
+ const schedType = group.scheduleType || 'daily';
526
+
527
+ let isMatch = false;
528
+
529
+ if (schedType === 'hourly') {
530
+ isMatch = (minute === targetMinute && second >= 0);
531
+ } else if (schedType === 'daily') {
532
+ isMatch = (hour === targetHour && minute === targetMinute && second >= 0);
533
+ } else if (schedType === 'monthly') {
534
+ isMatch = (day === targetDay && hour === targetHour && minute === targetMinute && second >= 0);
535
+ }
536
+
537
+ const state = this.groupJobStates.get(grpKey) || 0;
538
+
539
+ if (isMatch) {
540
+ if (state === 0) {
541
+ this.groupJobStates.set(grpKey, 1);
542
+ const age = group.age !== undefined ? Number(group.age) : 1;
543
+ this.log.info(`[AFC Schedule Match] Group '${group.name}' matched bound AFC clock (Reg=${group.timeRegister || this.config.afcTimeRegister}, Day=${day}, Hour=${hour}, Min=${minute}). Reading Age ${age}...`);
544
+ this.executeReportRead(group.name, age);
545
+ }
546
+ } else {
547
+ // Reset edge trigger once the minute advances
548
+ if (minute !== targetMinute && state > 0) {
549
+ this.groupJobStates.set(grpKey, 0);
550
+ }
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Starts interval timers for groups configured with readMode === 'polling'.
556
+ */
557
+ setupGroupPolling() {
558
+ // Clear previous timers
559
+ for (const timer of this.pollingTimers.values()) {
560
+ clearInterval(timer);
561
+ }
562
+ this.pollingTimers.clear();
563
+
564
+ const groups = this.config.reportGroups || [];
565
+ for (const group of groups) {
566
+ if (!group.name || !group.enabled) continue;
567
+ if (group.readMode === 'polling') {
568
+ const intervalSec = Math.max(2, Number(group.pollInterval) || 30);
569
+ this.log.info(`[Group Polling] Starting periodic polling for '${group.name}' every ${intervalSec}s (Age: ${group.age || 0}).`);
570
+
571
+ const timer = setInterval(() => {
572
+ this.log.info(`[Group Polling] Periodic trigger for '${group.name}'...`);
573
+ this.executeReportRead(group.name, Number(group.age || 0));
574
+ }, intervalSec * 1000);
575
+
576
+ this.pollingTimers.set(group.name.toLowerCase(), timer);
577
+ }
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Sets lifecycle flags when reading starts.
583
+ */
584
+ async setReportStartFlags(groupName) {
585
+ const grp = groupName.toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '_');
586
+
587
+ if (this.resetTimers.has(grp)) {
588
+ clearTimeout(this.resetTimers.get(grp));
589
+ this.resetTimers.delete(grp);
590
+ }
591
+
592
+ // Global flags
593
+ await this.setState('reports.readStarted', { val: true, ack: true });
594
+ await this.setState('reports.readFinished', { val: false, ack: true });
595
+ await this.setState('reports.status', { val: 'reading', ack: true });
596
+ await this.setState('reports.activeGroup', { val: groupName, ack: true });
597
+ await this.setState('reports.lastStartedGroup', { val: groupName, ack: true });
598
+ await this.setState('info.pollPaused', { val: true, ack: true });
599
+
600
+ // Per-group flags
601
+ await this.setState(`reports.${grp}.readStarted`, { val: true, ack: true });
602
+ await this.setState(`reports.${grp}.readFinished`, { val: false, ack: true });
603
+ await this.setState(`reports.${grp}.status`, { val: 'reading', ack: true });
604
+ }
605
+
606
+ /**
607
+ * Sets lifecycle flags when reading finishes, with 3-second auto-pulse reset.
608
+ */
609
+ async setReportFinishFlags(groupName, isSuccess) {
610
+ const grp = groupName.toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '_');
611
+ const nowStr = new Date().toISOString();
612
+
613
+ // Global finish
614
+ await this.setState('reports.readStarted', { val: false, ack: true });
615
+ await this.setState('reports.readFinished', { val: isSuccess, ack: true });
616
+ await this.setState('reports.status', { val: isSuccess ? 'success' : 'error', ack: true });
617
+ await this.setState('reports.lastFinishedGroup', { val: groupName, ack: true });
618
+ await this.setState('info.pollPaused', { val: false, ack: true });
619
+
620
+ // Per-group finish
621
+ await this.setState(`reports.${grp}.readStarted`, { val: false, ack: true });
622
+ await this.setState(`reports.${grp}.readFinished`, { val: isSuccess, ack: true });
623
+ await this.setState(`reports.${grp}.status`, { val: isSuccess ? 'success' : 'error', ack: true });
624
+ if (isSuccess) {
625
+ await this.setState(`reports.${grp}.lastReadTime`, { val: nowStr, ack: true });
626
+ }
627
+
628
+ // Reset Logic (Pulse Handshake):
629
+ // After 3 seconds, automatically reset readFinished to false and status to 'idle'
630
+ const timer = setTimeout(async () => {
631
+ try {
632
+ await this.setState(`reports.${grp}.readFinished`, { val: false, ack: true });
633
+ await this.setState(`reports.${grp}.status`, { val: 'idle', ack: true });
634
+ await this.setState('reports.readFinished', { val: false, ack: true });
635
+ await this.setState('reports.status', { val: 'idle', ack: true });
636
+ await this.setState('reports.activeGroup', { val: '', ack: true });
637
+ this.resetTimers.delete(grp);
638
+ } catch {
639
+ // Ignore if shutting down
640
+ }
641
+ }, 3000);
642
+
643
+ this.resetTimers.set(grp, timer);
644
+ }
645
+
646
+ /**
647
+ * Manual or scheduled report read.
648
+ * Polling is automatically paused inside native engine (_ioLock).
649
+ */
650
+ async executeReportRead(groupName, age) {
651
+ if (!this.engine) {
652
+ this.log.warn('Engine not available to read report.');
653
+ return;
654
+ }
655
+
656
+ this.log.info(`[Report Execution] Reading '${groupName}' (Age: ${age}). Polling paused, flags: started=true, finished=false`);
657
+ await this.setReportStartFlags(groupName);
658
+
659
+ try {
660
+ const resJson = this.engine.ReadReportWithAge(groupName, Number(age));
661
+ const res = JSON.parse(resJson);
662
+ if (res.ok) {
663
+ await this.handleReportReady(res);
664
+ await this.setReportFinishFlags(groupName, true);
665
+ this.log.info(`[Report Execution] '${groupName}' read successfully! Flags: started=false, finished=true (auto-resets in 3s)`);
666
+ } else {
667
+ this.log.error(`[Report Execution] '${groupName}' read failed: ${res.error}`);
668
+ await this.setReportFinishFlags(groupName, false);
669
+ }
670
+ } catch (err) {
671
+ this.log.error(`[Report Execution] Exception during ReadReportWithAge: ${err.message}`);
672
+ await this.setReportFinishFlags(groupName, false);
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Populates report values into ioBroker states.
678
+ */
679
+ async handleReportReady(reportData) {
680
+ if (!reportData || !reportData.ok) {
681
+ return;
682
+ }
683
+
684
+ const group = (reportData.group || '').toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '_');
685
+ const items = reportData.items || [];
686
+ const age = reportData.age;
687
+ const ts = reportData.timestamp;
688
+
689
+ await this.setState('reports.lastReportGroup', { val: reportData.group, ack: true });
690
+ await this.setState('reports.lastReportTime', { val: ts || new Date().toISOString(), ack: true });
691
+
692
+ for (const item of items) {
693
+ const sanitizedName = (item.name || '').replace(/[^a-zA-Z0-9_-]/g, '_');
694
+ const stateId = `reports.${group}.${sanitizedName}`;
695
+
696
+ let val = item.value;
697
+ // Check formula if configured
698
+ let map = null;
699
+ if (group === 'daily') map = this.dailyTagMap;
700
+ else if (group === 'hourly') map = this.hourlyTagMap;
701
+ else if (group === 'snapshot') map = this.snapshotTagMap;
702
+
703
+ let cfg = null;
704
+ if (map && map.has(item.name)) {
705
+ cfg = map.get(item.name);
706
+ } else {
707
+ const customKey = `${group}.${item.name}`;
708
+ if (this.customTagMap.has(customKey)) {
709
+ cfg = this.customTagMap.get(customKey);
710
+ }
711
+ }
712
+
713
+ if (cfg && cfg.formula && typeof val === 'number') {
714
+ val = this.applyFormula(cfg.formula, val);
715
+ }
716
+
717
+ await this.setState(stateId, { val, ack: true });
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Is called if a subscribed state changes.
723
+ */
724
+ async onStateChange(id, state) {
725
+ if (!state || state.ack) return;
726
+
727
+ // Dynamic License Key / Expired Hash Update (Writable state: info.licenseKey)
728
+ if (id.endsWith('info.licenseKey')) {
729
+ const newKey = String(state.val || '').trim();
730
+ this.log.info(`[License Update] Processing submitted key/hash (${newKey.slice(0, 20)}...)...`);
731
+ if (this.engine) {
732
+ try {
733
+ const resJson = this.engine.SetLicenseKey(newKey);
734
+ const res = JSON.parse(resJson);
735
+ await this.setState('info.licenseValid', { val: Boolean(res.valid), ack: true });
736
+ await this.setState('info.licenseExpired', { val: Boolean(res.expired), ack: true });
737
+ await this.setState('info.licenseStatus', { val: res.status, ack: true });
738
+ await this.setState('info.licenseExpiresAt', { val: res.expiresAt || '', ack: true });
739
+ await this.setState('info.referenceTime', { val: res.referenceTime || '', ack: true });
740
+ await this.setState('info.licenseTimeSource', { val: res.timeSource || 'afc', ack: true });
741
+ const isReady = Boolean(res.valid && !res.expired);
742
+ await this.setState('info.ready', { val: isReady, ack: true });
743
+
744
+ if (isReady) {
745
+ await this.setState('info.connection', { val: true, ack: true });
746
+ await this.setState('info.pollActive', { val: true, ack: true });
747
+ await this.setState('info.pollPaused', { val: false, ack: true });
748
+ this.log.info(`[License Activated] Key valid! Status: ${res.status}. Modbus polling & scheduled reports resumed.`);
749
+ } else {
750
+ await this.setState('info.pollPaused', { val: true, ack: true });
751
+ this.log.warn(`[License Inactive] Status: ${res.status} - ${res.message}. Modbus I/O halted.`);
752
+ }
753
+ await this.setState(id, { val: newKey, ack: true });
754
+ } catch (err) {
755
+ this.log.error(`[License Error] Failed to evaluate license key: ${err.message}`);
756
+ }
757
+ }
758
+ return;
759
+ }
760
+
761
+ // Dynamic Manual Report Triggers for any group: "reports.set<GroupName>"
762
+ if (id.includes('.reports.set')) {
763
+ const match = id.match(/reports\.set([a-zA-Z0-9_-]+)$/);
764
+ if (match && state.val) {
765
+ const groupName = match[1];
766
+ if (groupName !== 'AgeNumber') {
767
+ const ageState = await this.getStateAsync('reports.setAgeNumber');
768
+ const age = ageState && ageState.val !== undefined ? Number(ageState.val) : 1;
769
+ await this.executeReportRead(groupName, age);
770
+ await this.setState(id, { val: false, ack: true });
771
+ return;
772
+ }
773
+ }
774
+ }
775
+
776
+ // Write to AFC Modbus Tag
777
+ if (this.engine) {
778
+ const stateParts = id.split('.');
779
+ const tagName = stateParts[stateParts.length - 1];
780
+ if (this.tagMap.has(tagName)) {
781
+ this.log.info(`Writing value ${state.val} to tag ${tagName}...`);
782
+ const resJson = this.engine.Write(tagName, JSON.stringify(state.val));
783
+ try {
784
+ const res = JSON.parse(resJson);
785
+ if (res.ok) {
786
+ await this.setState(id, { ack: true });
787
+ } else {
788
+ this.log.error(`Write to tag ${tagName} failed: ${res.error}`);
789
+ }
790
+ } catch {
791
+ // Raw response
792
+ }
793
+ }
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Is called when adapter shuts down.
799
+ */
800
+ onUnload(callback) {
801
+ try {
802
+ for (const timer of this.pollingTimers.values()) {
803
+ clearInterval(timer);
804
+ }
805
+ this.pollingTimers.clear();
806
+
807
+ for (const timer of this.resetTimers.values()) {
808
+ clearTimeout(timer);
809
+ }
810
+ this.resetTimers.clear();
811
+
812
+ if (this.engine) {
813
+ this.engine.Stop();
814
+ this.engine = null;
815
+ }
816
+ this.setState('info.connection', { val: false, ack: true });
817
+ this.setState('info.pollActive', { val: false, ack: true });
818
+ this.setState('info.pollPaused', { val: false, ack: true });
819
+ this.log.info('Prosoft Flow adapter cleanly stopped.');
820
+ callback();
821
+ } catch {
822
+ callback();
823
+ }
824
+ }
825
+ }
826
+
827
+ if (require.main !== module) {
828
+ module.exports = (options) => new ProsoftFlow(options);
829
+ } else {
830
+ new ProsoftFlow();
831
+ }
832
+