@enyo-energy/sunspec-sdk 0.0.88 → 0.0.90
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/cjs/sunspec-devices.cjs +5 -53
- package/dist/cjs/sunspec-devices.d.cts +0 -5
- package/dist/cjs/sunspec-interfaces.d.cts +8 -5
- package/dist/cjs/sunspec-modbus-client.cjs +48 -18
- package/dist/cjs/sunspec-modbus-client.d.cts +12 -0
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/sunspec-devices.d.ts +0 -5
- package/dist/sunspec-devices.js +5 -53
- package/dist/sunspec-interfaces.d.ts +8 -5
- package/dist/sunspec-modbus-client.d.ts +12 -0
- package/dist/sunspec-modbus-client.js +48 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -351,15 +351,15 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
351
351
|
* non-fault read).
|
|
352
352
|
*/
|
|
353
353
|
static OPERATING_FAULT_THRESHOLD = 3;
|
|
354
|
-
storage;
|
|
355
354
|
consecutiveOperatingFaults = 0;
|
|
355
|
+
// In-memory only — never persisted. See SunspecInverterErrorState.
|
|
356
356
|
errorState = { activeCodes: [], lastStatus: 'healthy' };
|
|
357
357
|
snapshotService;
|
|
358
358
|
// Whether we've emitted a status update at least once since (re)connecting.
|
|
359
|
-
//
|
|
360
|
-
// (e.g. a faulted
|
|
361
|
-
// healthy), so we force one re-assert of the current status on
|
|
362
|
-
// successful read of each session to converge the core.
|
|
359
|
+
// Our fresh in-memory errorState can diverge from what the core still shows
|
|
360
|
+
// across an SDK restart (e.g. a faulted the core persisted while our local
|
|
361
|
+
// state reads healthy), so we force one re-assert of the current status on
|
|
362
|
+
// the first successful read of each session to converge the core.
|
|
363
363
|
statusReassertedThisSession = false;
|
|
364
364
|
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
|
|
365
365
|
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
|
|
@@ -438,23 +438,13 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
438
438
|
if (mpptModel) {
|
|
439
439
|
console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
|
|
440
440
|
}
|
|
441
|
-
await this.loadErrorState();
|
|
442
441
|
// Fresh session: force the next successful read to re-assert the true
|
|
443
442
|
// status so a stale faulted in the core is cleared on (re)start.
|
|
444
443
|
this.statusReassertedThisSession = false;
|
|
445
444
|
this.startDataBusListening();
|
|
446
|
-
// Cold-start recovery: if the persisted state says we were stuck in
|
|
447
|
-
// connection_lost but connect() just succeeded (we read commonBlock,
|
|
448
|
-
// settings, controls and MPPT above), the Modbus path is provably
|
|
449
|
-
// healthy. Clear the stale fault and emit Healthy via the same hook
|
|
450
|
-
// the in-process reconnect path uses.
|
|
451
|
-
if (this.errorState.lastStatus === 'connection_lost') {
|
|
452
|
-
await this.onConnectionRestored();
|
|
453
|
-
}
|
|
454
445
|
}
|
|
455
446
|
async disconnect() {
|
|
456
447
|
this.stopDataBusListening();
|
|
457
|
-
await this.removePersistedErrorState();
|
|
458
448
|
if (this.applianceId) {
|
|
459
449
|
try {
|
|
460
450
|
await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
|
|
@@ -621,41 +611,6 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
621
611
|
}
|
|
622
612
|
};
|
|
623
613
|
}
|
|
624
|
-
storageKey() {
|
|
625
|
-
return `sunspec-inverter-error-state-${this.applianceId}`;
|
|
626
|
-
}
|
|
627
|
-
async loadErrorState() {
|
|
628
|
-
try {
|
|
629
|
-
this.storage = this.energyApp.useStorage();
|
|
630
|
-
const loaded = await this.storage.load(this.storageKey());
|
|
631
|
-
if (loaded) {
|
|
632
|
-
this.errorState = loaded;
|
|
633
|
-
console.log(`Inverter ${this.applianceId}: loaded persisted error state (lastStatus=${loaded.lastStatus}, codes=[${loaded.activeCodes.join(', ')}])`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
catch (error) {
|
|
637
|
-
console.error(`Inverter ${this.applianceId}: failed to load persisted error state: ${error}`);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
async persistErrorState() {
|
|
641
|
-
if (!this.storage)
|
|
642
|
-
return;
|
|
643
|
-
try {
|
|
644
|
-
await this.storage.save(this.storageKey(), this.errorState);
|
|
645
|
-
}
|
|
646
|
-
catch (error) {
|
|
647
|
-
console.error(`Inverter ${this.applianceId}: failed to persist error state: ${error}`);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
async removePersistedErrorState() {
|
|
651
|
-
const storage = this.storage ?? this.energyApp.useStorage();
|
|
652
|
-
try {
|
|
653
|
-
await storage.remove(this.storageKey());
|
|
654
|
-
}
|
|
655
|
-
catch (error) {
|
|
656
|
-
console.error(`Inverter ${this.applianceId}: failed to remove persisted error state: ${error}`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
614
|
async detectAndEmitStatusTransition(data, timestamp) {
|
|
660
615
|
if (!this.applianceId || !this.dataBus)
|
|
661
616
|
return;
|
|
@@ -702,7 +657,6 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
702
657
|
activeCodes: codeIds,
|
|
703
658
|
lastStatus: newLastStatus,
|
|
704
659
|
};
|
|
705
|
-
await this.persistErrorState();
|
|
706
660
|
}
|
|
707
661
|
async onConnectionFailure(consecutiveFailures) {
|
|
708
662
|
if (!this.applianceId || !this.dataBus)
|
|
@@ -725,7 +679,6 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
725
679
|
activeCodes: [sunspec_interfaces_js_1.SUNSPEC_CONNECTION_LOST_CODE],
|
|
726
680
|
lastStatus: 'connection_lost',
|
|
727
681
|
};
|
|
728
|
-
await this.persistErrorState();
|
|
729
682
|
}
|
|
730
683
|
async onConnectionRestored() {
|
|
731
684
|
if (!this.applianceId || !this.dataBus)
|
|
@@ -739,7 +692,6 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
739
692
|
activeCodes: [],
|
|
740
693
|
lastStatus: 'healthy',
|
|
741
694
|
};
|
|
742
|
-
await this.persistErrorState();
|
|
743
695
|
}
|
|
744
696
|
/**
|
|
745
697
|
* Compute the currently active feed-in / production limit in Watts from the
|
|
@@ -137,7 +137,6 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
137
137
|
* non-fault read).
|
|
138
138
|
*/
|
|
139
139
|
private static readonly OPERATING_FAULT_THRESHOLD;
|
|
140
|
-
private storage?;
|
|
141
140
|
private consecutiveOperatingFaults;
|
|
142
141
|
private errorState;
|
|
143
142
|
private snapshotService?;
|
|
@@ -179,10 +178,6 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
179
178
|
protected mapVendorEventBit(_registerIndex: 1 | 2 | 3 | 4, _bit: number): EnyoApplianceErrorCode | undefined;
|
|
180
179
|
private hasErrorSetChanged;
|
|
181
180
|
private buildStatusMessage;
|
|
182
|
-
private storageKey;
|
|
183
|
-
private loadErrorState;
|
|
184
|
-
private persistErrorState;
|
|
185
|
-
private removePersistedErrorState;
|
|
186
181
|
private detectAndEmitStatusTransition;
|
|
187
182
|
protected onConnectionFailure(consecutiveFailures: number): Promise<void>;
|
|
188
183
|
protected onConnectionRestored(): Promise<void>;
|
|
@@ -148,11 +148,14 @@ export declare enum SunspecInverterEvent1 {
|
|
|
148
148
|
*/
|
|
149
149
|
export declare const SUNSPEC_CONNECTION_LOST_CODE = "modbus_connection_lost";
|
|
150
150
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* re-emitting `faulted` for the same
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
* In-memory error/status-tracking state for a SunSpec inverter. Held only for
|
|
152
|
+
* the lifetime of the process (never written to storage) so the status reporter
|
|
153
|
+
* can detect transitions and avoid re-emitting `faulted` for the same
|
|
154
|
+
* already-known errors on every poll. Because it is not persisted, a transient
|
|
155
|
+
* error clears the moment the next read no longer sees it, and a restart never
|
|
156
|
+
* resurrects a stale fault.
|
|
157
|
+
*/
|
|
158
|
+
export interface SunspecInverterErrorState {
|
|
156
159
|
evt1?: number;
|
|
157
160
|
evt2?: number;
|
|
158
161
|
evtVnd1?: number;
|
|
@@ -103,6 +103,14 @@ class SunspecModbusClient {
|
|
|
103
103
|
// Serializes connection-state transitions so concurrent callers cannot open duplicate
|
|
104
104
|
// sockets for the same unit ID while one is still alive.
|
|
105
105
|
operationChain = Promise.resolve();
|
|
106
|
+
// Serializes data transactions per unit so only one Modbus request is in flight on a
|
|
107
|
+
// unit's socket at a time. Reads go through the fault-tolerant reader and writes go
|
|
108
|
+
// straight to the instance — two paths to the same socket. Without this, a concurrent
|
|
109
|
+
// poll + control write cross their responses on the wire and the transport raises
|
|
110
|
+
// "OutOfSync: request fc and response fc does not match". Kept separate from
|
|
111
|
+
// operationChain (the connection lock) to avoid re-entrancy: a transaction never waits
|
|
112
|
+
// on a connect/disconnect, and vice versa.
|
|
113
|
+
txChains = new Map();
|
|
106
114
|
constructor(energyApp) {
|
|
107
115
|
this.energyApp = energyApp;
|
|
108
116
|
this.connectionHealth = new EnergyAppModbusConnectionHealth_js_1.EnergyAppModbusConnectionHealth();
|
|
@@ -333,6 +341,22 @@ class SunspecModbusClient {
|
|
|
333
341
|
this.operationChain = run.catch(() => undefined);
|
|
334
342
|
return run;
|
|
335
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Run `fn` with exclusive access to a single unit's socket, so at most one Modbus
|
|
346
|
+
* transaction is in flight per unit at a time. Subsequent calls for the same unit queue
|
|
347
|
+
* behind any in-flight one; different units run in parallel (independent chains). A
|
|
348
|
+
* rejected `fn` does not poison the chain for later callers.
|
|
349
|
+
*
|
|
350
|
+
* IMPORTANT: only wrap leaf transactions (a single reader/instance read or write). Never
|
|
351
|
+
* call this around a method that itself calls a runExclusive-wrapped method for the same
|
|
352
|
+
* unit — that re-enters the unit's chain and deadlocks.
|
|
353
|
+
*/
|
|
354
|
+
runExclusive(unitId, fn) {
|
|
355
|
+
const prev = this.txChains.get(unitId) ?? Promise.resolve();
|
|
356
|
+
const run = prev.then(fn, fn);
|
|
357
|
+
this.txChains.set(unitId, run.catch(() => undefined));
|
|
358
|
+
return run;
|
|
359
|
+
}
|
|
336
360
|
recordOpen() {
|
|
337
361
|
this.openCount++;
|
|
338
362
|
const expected = this.modbusInstances.size;
|
|
@@ -433,7 +457,7 @@ class SunspecModbusClient {
|
|
|
433
457
|
console.log(`Detect models for custom base address '${customBaseAddress}' ...`);
|
|
434
458
|
// Try 0-based at custom address
|
|
435
459
|
try {
|
|
436
|
-
const sunspecId = await instance.readRegisterStringValue(customBaseAddress, 2);
|
|
460
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(customBaseAddress, 2));
|
|
437
461
|
if (sunspecId.includes('SunS')) {
|
|
438
462
|
console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
|
|
439
463
|
return {
|
|
@@ -448,7 +472,7 @@ class SunspecModbusClient {
|
|
|
448
472
|
}
|
|
449
473
|
// Try 1-based at custom address (customBaseAddress + 1)
|
|
450
474
|
try {
|
|
451
|
-
const sunspecId = await instance.readRegisterStringValue(customBaseAddress + 1, 2);
|
|
475
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(customBaseAddress + 1, 2));
|
|
452
476
|
if (sunspecId.includes('SunS')) {
|
|
453
477
|
console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
|
|
454
478
|
return {
|
|
@@ -465,7 +489,7 @@ class SunspecModbusClient {
|
|
|
465
489
|
else {
|
|
466
490
|
// Try 1-based addressing first (most common)
|
|
467
491
|
try {
|
|
468
|
-
const sunspecId = await instance.readRegisterStringValue(40001, 2);
|
|
492
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(40001, 2));
|
|
469
493
|
if (sunspecId.includes('SunS')) {
|
|
470
494
|
console.log('Detected 1-based addressing mode (base address: 40001)');
|
|
471
495
|
return {
|
|
@@ -480,7 +504,7 @@ class SunspecModbusClient {
|
|
|
480
504
|
}
|
|
481
505
|
// Try 0-based addressing
|
|
482
506
|
try {
|
|
483
|
-
const sunspecId = await instance.readRegisterStringValue(40000, 2);
|
|
507
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(40000, 2));
|
|
484
508
|
if (sunspecId.includes('SunS')) {
|
|
485
509
|
console.log('Detected 0-based addressing mode (base address: 40000)');
|
|
486
510
|
return {
|
|
@@ -531,7 +555,7 @@ class SunspecModbusClient {
|
|
|
531
555
|
currentAddress = addressInfo.nextAddress;
|
|
532
556
|
try {
|
|
533
557
|
while (currentAddress < maxAddress) {
|
|
534
|
-
const buffer = await instance.readHoldingRegisters(currentAddress, 2);
|
|
558
|
+
const buffer = await this.runExclusive(unitId, () => instance.readHoldingRegisters(currentAddress, 2));
|
|
535
559
|
const modelData = [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
|
|
536
560
|
if (!modelData || modelData.length < 2) {
|
|
537
561
|
console.debug(`No data at address ${currentAddress}, ending discovery`);
|
|
@@ -673,15 +697,20 @@ class SunspecModbusClient {
|
|
|
673
697
|
// length 124, which combined with the 2-register header (126) exceeds the limit,
|
|
674
698
|
// so chunk reads larger than 125 registers and concatenate the results.
|
|
675
699
|
const MAX_REGISTERS_PER_READ = 125;
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
700
|
+
// Serialize the whole (possibly multi-chunk) block read on the unit's socket so a
|
|
701
|
+
// concurrent poll or control write can't interleave its frames between our chunks.
|
|
702
|
+
const chunks = await this.runExclusive(unitId, async () => {
|
|
703
|
+
const acc = [];
|
|
704
|
+
for (let offset = 0; offset < totalRegisters; offset += MAX_REGISTERS_PER_READ) {
|
|
705
|
+
const quantity = Math.min(MAX_REGISTERS_PER_READ, totalRegisters - offset);
|
|
706
|
+
const result = await reader.readHoldingRegisters(model.address + offset, quantity);
|
|
707
|
+
if (!result.success || !result.value) {
|
|
708
|
+
throw new Error(`Failed to read model block ${model.id} at address ${model.address + offset} (unit ${unitId}): ${result.error?.message || 'Unknown error'}`);
|
|
709
|
+
}
|
|
710
|
+
acc.push(result.value);
|
|
682
711
|
}
|
|
683
|
-
|
|
684
|
-
}
|
|
712
|
+
return acc;
|
|
713
|
+
});
|
|
685
714
|
this.connectionHealth.recordSuccess();
|
|
686
715
|
return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
|
|
687
716
|
}
|
|
@@ -708,7 +737,7 @@ class SunspecModbusClient {
|
|
|
708
737
|
async readRegisterValue(unitId, address, quantity = 1, dataType) {
|
|
709
738
|
const reader = this.getReader(unitId);
|
|
710
739
|
try {
|
|
711
|
-
const result = await reader.readHoldingRegisters(address, quantity);
|
|
740
|
+
const result = await this.runExclusive(unitId, () => reader.readHoldingRegisters(address, quantity));
|
|
712
741
|
// Check if the read was successful
|
|
713
742
|
if (!result.success || !result.value) {
|
|
714
743
|
throw new Error(`Failed to read register at address ${address}: ${result.error?.message || 'Unknown error'}`);
|
|
@@ -758,8 +787,9 @@ class SunspecModbusClient {
|
|
|
758
787
|
break;
|
|
759
788
|
}
|
|
760
789
|
}
|
|
761
|
-
// Write to holding registers
|
|
762
|
-
|
|
790
|
+
// Write to holding registers. Serialized on the unit's socket so the write can't
|
|
791
|
+
// collide with a concurrent poll's read (which would cross their response frames).
|
|
792
|
+
await this.runExclusive(unitId, () => instance.writeMultipleRegisters(address, registerValues));
|
|
763
793
|
this.connectionHealth.recordSuccess();
|
|
764
794
|
// Per-register write success — fires for every parameter inside higher-level
|
|
765
795
|
// writers (writeBatteryControls / writeInverterControls). Demoted to debug.
|
|
@@ -2375,7 +2405,7 @@ class SunspecModbusClient {
|
|
|
2375
2405
|
// Note: This is a simplified implementation. In production, you'd batch writes
|
|
2376
2406
|
if (settings.WMax !== undefined) {
|
|
2377
2407
|
// Need to read scale factor first if not provided
|
|
2378
|
-
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 22, 1);
|
|
2408
|
+
const sfBuffer = await this.runExclusive(unitId, () => instance.readHoldingRegisters(baseAddr + 22, 1));
|
|
2379
2409
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
2380
2410
|
const scaledValue = Math.round(settings.WMax / Math.pow(10, scaleFactor));
|
|
2381
2411
|
// Writing registers needs to be implemented in EnergyAppModbusInstance
|
|
@@ -2383,7 +2413,7 @@ class SunspecModbusClient {
|
|
|
2383
2413
|
console.debug(`Would write value ${scaledValue} to register ${baseAddr}`);
|
|
2384
2414
|
}
|
|
2385
2415
|
if (settings.VRef !== undefined) {
|
|
2386
|
-
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 23, 1);
|
|
2416
|
+
const sfBuffer = await this.runExclusive(unitId, () => instance.readHoldingRegisters(baseAddr + 23, 1));
|
|
2387
2417
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
2388
2418
|
const scaledValue = Math.round(settings.VRef / Math.pow(10, scaleFactor));
|
|
2389
2419
|
console.debug(`Would write value ${scaledValue} to register ${baseAddr + 1}`);
|
|
@@ -47,6 +47,7 @@ export declare class SunspecModbusClient {
|
|
|
47
47
|
private openCount;
|
|
48
48
|
private closeCount;
|
|
49
49
|
private operationChain;
|
|
50
|
+
private txChains;
|
|
50
51
|
constructor(energyApp: EnergyApp);
|
|
51
52
|
/**
|
|
52
53
|
* Connect to a Modbus device unit. Multiple unit IDs on the same network device share this
|
|
@@ -107,6 +108,17 @@ export declare class SunspecModbusClient {
|
|
|
107
108
|
* behind any in-flight one. A rejected `fn` does not poison the chain for later callers.
|
|
108
109
|
*/
|
|
109
110
|
private withConnectionLock;
|
|
111
|
+
/**
|
|
112
|
+
* Run `fn` with exclusive access to a single unit's socket, so at most one Modbus
|
|
113
|
+
* transaction is in flight per unit at a time. Subsequent calls for the same unit queue
|
|
114
|
+
* behind any in-flight one; different units run in parallel (independent chains). A
|
|
115
|
+
* rejected `fn` does not poison the chain for later callers.
|
|
116
|
+
*
|
|
117
|
+
* IMPORTANT: only wrap leaf transactions (a single reader/instance read or write). Never
|
|
118
|
+
* call this around a method that itself calls a runExclusive-wrapped method for the same
|
|
119
|
+
* unit — that re-enters the unit's chain and deadlocks.
|
|
120
|
+
*/
|
|
121
|
+
private runExclusive;
|
|
110
122
|
private recordOpen;
|
|
111
123
|
private recordClose;
|
|
112
124
|
/**
|
package/dist/cjs/version.cjs
CHANGED
|
@@ -9,7 +9,7 @@ exports.getSdkVersion = getSdkVersion;
|
|
|
9
9
|
/**
|
|
10
10
|
* Current version of the enyo Energy App SDK.
|
|
11
11
|
*/
|
|
12
|
-
exports.SDK_VERSION = '0.0.
|
|
12
|
+
exports.SDK_VERSION = '0.0.90';
|
|
13
13
|
/**
|
|
14
14
|
* Gets the current SDK version.
|
|
15
15
|
* @returns The semantic version string of the SDK
|
package/dist/cjs/version.d.cts
CHANGED
|
@@ -137,7 +137,6 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
137
137
|
* non-fault read).
|
|
138
138
|
*/
|
|
139
139
|
private static readonly OPERATING_FAULT_THRESHOLD;
|
|
140
|
-
private storage?;
|
|
141
140
|
private consecutiveOperatingFaults;
|
|
142
141
|
private errorState;
|
|
143
142
|
private snapshotService?;
|
|
@@ -179,10 +178,6 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
179
178
|
protected mapVendorEventBit(_registerIndex: 1 | 2 | 3 | 4, _bit: number): EnyoApplianceErrorCode | undefined;
|
|
180
179
|
private hasErrorSetChanged;
|
|
181
180
|
private buildStatusMessage;
|
|
182
|
-
private storageKey;
|
|
183
|
-
private loadErrorState;
|
|
184
|
-
private persistErrorState;
|
|
185
|
-
private removePersistedErrorState;
|
|
186
181
|
private detectAndEmitStatusTransition;
|
|
187
182
|
protected onConnectionFailure(consecutiveFailures: number): Promise<void>;
|
|
188
183
|
protected onConnectionRestored(): Promise<void>;
|
package/dist/sunspec-devices.js
CHANGED
|
@@ -344,15 +344,15 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
344
344
|
* non-fault read).
|
|
345
345
|
*/
|
|
346
346
|
static OPERATING_FAULT_THRESHOLD = 3;
|
|
347
|
-
storage;
|
|
348
347
|
consecutiveOperatingFaults = 0;
|
|
348
|
+
// In-memory only — never persisted. See SunspecInverterErrorState.
|
|
349
349
|
errorState = { activeCodes: [], lastStatus: 'healthy' };
|
|
350
350
|
snapshotService;
|
|
351
351
|
// Whether we've emitted a status update at least once since (re)connecting.
|
|
352
|
-
//
|
|
353
|
-
// (e.g. a faulted
|
|
354
|
-
// healthy), so we force one re-assert of the current status on
|
|
355
|
-
// successful read of each session to converge the core.
|
|
352
|
+
// Our fresh in-memory errorState can diverge from what the core still shows
|
|
353
|
+
// across an SDK restart (e.g. a faulted the core persisted while our local
|
|
354
|
+
// state reads healthy), so we force one re-assert of the current status on
|
|
355
|
+
// the first successful read of each session to converge the core.
|
|
356
356
|
statusReassertedThisSession = false;
|
|
357
357
|
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
|
|
358
358
|
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
|
|
@@ -431,23 +431,13 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
431
431
|
if (mpptModel) {
|
|
432
432
|
console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
|
|
433
433
|
}
|
|
434
|
-
await this.loadErrorState();
|
|
435
434
|
// Fresh session: force the next successful read to re-assert the true
|
|
436
435
|
// status so a stale faulted in the core is cleared on (re)start.
|
|
437
436
|
this.statusReassertedThisSession = false;
|
|
438
437
|
this.startDataBusListening();
|
|
439
|
-
// Cold-start recovery: if the persisted state says we were stuck in
|
|
440
|
-
// connection_lost but connect() just succeeded (we read commonBlock,
|
|
441
|
-
// settings, controls and MPPT above), the Modbus path is provably
|
|
442
|
-
// healthy. Clear the stale fault and emit Healthy via the same hook
|
|
443
|
-
// the in-process reconnect path uses.
|
|
444
|
-
if (this.errorState.lastStatus === 'connection_lost') {
|
|
445
|
-
await this.onConnectionRestored();
|
|
446
|
-
}
|
|
447
438
|
}
|
|
448
439
|
async disconnect() {
|
|
449
440
|
this.stopDataBusListening();
|
|
450
|
-
await this.removePersistedErrorState();
|
|
451
441
|
if (this.applianceId) {
|
|
452
442
|
try {
|
|
453
443
|
await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
|
|
@@ -614,41 +604,6 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
614
604
|
}
|
|
615
605
|
};
|
|
616
606
|
}
|
|
617
|
-
storageKey() {
|
|
618
|
-
return `sunspec-inverter-error-state-${this.applianceId}`;
|
|
619
|
-
}
|
|
620
|
-
async loadErrorState() {
|
|
621
|
-
try {
|
|
622
|
-
this.storage = this.energyApp.useStorage();
|
|
623
|
-
const loaded = await this.storage.load(this.storageKey());
|
|
624
|
-
if (loaded) {
|
|
625
|
-
this.errorState = loaded;
|
|
626
|
-
console.log(`Inverter ${this.applianceId}: loaded persisted error state (lastStatus=${loaded.lastStatus}, codes=[${loaded.activeCodes.join(', ')}])`);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
catch (error) {
|
|
630
|
-
console.error(`Inverter ${this.applianceId}: failed to load persisted error state: ${error}`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
async persistErrorState() {
|
|
634
|
-
if (!this.storage)
|
|
635
|
-
return;
|
|
636
|
-
try {
|
|
637
|
-
await this.storage.save(this.storageKey(), this.errorState);
|
|
638
|
-
}
|
|
639
|
-
catch (error) {
|
|
640
|
-
console.error(`Inverter ${this.applianceId}: failed to persist error state: ${error}`);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
async removePersistedErrorState() {
|
|
644
|
-
const storage = this.storage ?? this.energyApp.useStorage();
|
|
645
|
-
try {
|
|
646
|
-
await storage.remove(this.storageKey());
|
|
647
|
-
}
|
|
648
|
-
catch (error) {
|
|
649
|
-
console.error(`Inverter ${this.applianceId}: failed to remove persisted error state: ${error}`);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
607
|
async detectAndEmitStatusTransition(data, timestamp) {
|
|
653
608
|
if (!this.applianceId || !this.dataBus)
|
|
654
609
|
return;
|
|
@@ -695,7 +650,6 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
695
650
|
activeCodes: codeIds,
|
|
696
651
|
lastStatus: newLastStatus,
|
|
697
652
|
};
|
|
698
|
-
await this.persistErrorState();
|
|
699
653
|
}
|
|
700
654
|
async onConnectionFailure(consecutiveFailures) {
|
|
701
655
|
if (!this.applianceId || !this.dataBus)
|
|
@@ -718,7 +672,6 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
718
672
|
activeCodes: [SUNSPEC_CONNECTION_LOST_CODE],
|
|
719
673
|
lastStatus: 'connection_lost',
|
|
720
674
|
};
|
|
721
|
-
await this.persistErrorState();
|
|
722
675
|
}
|
|
723
676
|
async onConnectionRestored() {
|
|
724
677
|
if (!this.applianceId || !this.dataBus)
|
|
@@ -732,7 +685,6 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
732
685
|
activeCodes: [],
|
|
733
686
|
lastStatus: 'healthy',
|
|
734
687
|
};
|
|
735
|
-
await this.persistErrorState();
|
|
736
688
|
}
|
|
737
689
|
/**
|
|
738
690
|
* Compute the currently active feed-in / production limit in Watts from the
|
|
@@ -148,11 +148,14 @@ export declare enum SunspecInverterEvent1 {
|
|
|
148
148
|
*/
|
|
149
149
|
export declare const SUNSPEC_CONNECTION_LOST_CODE = "modbus_connection_lost";
|
|
150
150
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* re-emitting `faulted` for the same
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
* In-memory error/status-tracking state for a SunSpec inverter. Held only for
|
|
152
|
+
* the lifetime of the process (never written to storage) so the status reporter
|
|
153
|
+
* can detect transitions and avoid re-emitting `faulted` for the same
|
|
154
|
+
* already-known errors on every poll. Because it is not persisted, a transient
|
|
155
|
+
* error clears the moment the next read no longer sees it, and a restart never
|
|
156
|
+
* resurrects a stale fault.
|
|
157
|
+
*/
|
|
158
|
+
export interface SunspecInverterErrorState {
|
|
156
159
|
evt1?: number;
|
|
157
160
|
evt2?: number;
|
|
158
161
|
evtVnd1?: number;
|
|
@@ -47,6 +47,7 @@ export declare class SunspecModbusClient {
|
|
|
47
47
|
private openCount;
|
|
48
48
|
private closeCount;
|
|
49
49
|
private operationChain;
|
|
50
|
+
private txChains;
|
|
50
51
|
constructor(energyApp: EnergyApp);
|
|
51
52
|
/**
|
|
52
53
|
* Connect to a Modbus device unit. Multiple unit IDs on the same network device share this
|
|
@@ -107,6 +108,17 @@ export declare class SunspecModbusClient {
|
|
|
107
108
|
* behind any in-flight one. A rejected `fn` does not poison the chain for later callers.
|
|
108
109
|
*/
|
|
109
110
|
private withConnectionLock;
|
|
111
|
+
/**
|
|
112
|
+
* Run `fn` with exclusive access to a single unit's socket, so at most one Modbus
|
|
113
|
+
* transaction is in flight per unit at a time. Subsequent calls for the same unit queue
|
|
114
|
+
* behind any in-flight one; different units run in parallel (independent chains). A
|
|
115
|
+
* rejected `fn` does not poison the chain for later callers.
|
|
116
|
+
*
|
|
117
|
+
* IMPORTANT: only wrap leaf transactions (a single reader/instance read or write). Never
|
|
118
|
+
* call this around a method that itself calls a runExclusive-wrapped method for the same
|
|
119
|
+
* unit — that re-enters the unit's chain and deadlocks.
|
|
120
|
+
*/
|
|
121
|
+
private runExclusive;
|
|
110
122
|
private recordOpen;
|
|
111
123
|
private recordClose;
|
|
112
124
|
/**
|
|
@@ -98,6 +98,14 @@ export class SunspecModbusClient {
|
|
|
98
98
|
// Serializes connection-state transitions so concurrent callers cannot open duplicate
|
|
99
99
|
// sockets for the same unit ID while one is still alive.
|
|
100
100
|
operationChain = Promise.resolve();
|
|
101
|
+
// Serializes data transactions per unit so only one Modbus request is in flight on a
|
|
102
|
+
// unit's socket at a time. Reads go through the fault-tolerant reader and writes go
|
|
103
|
+
// straight to the instance — two paths to the same socket. Without this, a concurrent
|
|
104
|
+
// poll + control write cross their responses on the wire and the transport raises
|
|
105
|
+
// "OutOfSync: request fc and response fc does not match". Kept separate from
|
|
106
|
+
// operationChain (the connection lock) to avoid re-entrancy: a transaction never waits
|
|
107
|
+
// on a connect/disconnect, and vice versa.
|
|
108
|
+
txChains = new Map();
|
|
101
109
|
constructor(energyApp) {
|
|
102
110
|
this.energyApp = energyApp;
|
|
103
111
|
this.connectionHealth = new EnergyAppModbusConnectionHealth();
|
|
@@ -328,6 +336,22 @@ export class SunspecModbusClient {
|
|
|
328
336
|
this.operationChain = run.catch(() => undefined);
|
|
329
337
|
return run;
|
|
330
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* Run `fn` with exclusive access to a single unit's socket, so at most one Modbus
|
|
341
|
+
* transaction is in flight per unit at a time. Subsequent calls for the same unit queue
|
|
342
|
+
* behind any in-flight one; different units run in parallel (independent chains). A
|
|
343
|
+
* rejected `fn` does not poison the chain for later callers.
|
|
344
|
+
*
|
|
345
|
+
* IMPORTANT: only wrap leaf transactions (a single reader/instance read or write). Never
|
|
346
|
+
* call this around a method that itself calls a runExclusive-wrapped method for the same
|
|
347
|
+
* unit — that re-enters the unit's chain and deadlocks.
|
|
348
|
+
*/
|
|
349
|
+
runExclusive(unitId, fn) {
|
|
350
|
+
const prev = this.txChains.get(unitId) ?? Promise.resolve();
|
|
351
|
+
const run = prev.then(fn, fn);
|
|
352
|
+
this.txChains.set(unitId, run.catch(() => undefined));
|
|
353
|
+
return run;
|
|
354
|
+
}
|
|
331
355
|
recordOpen() {
|
|
332
356
|
this.openCount++;
|
|
333
357
|
const expected = this.modbusInstances.size;
|
|
@@ -428,7 +452,7 @@ export class SunspecModbusClient {
|
|
|
428
452
|
console.log(`Detect models for custom base address '${customBaseAddress}' ...`);
|
|
429
453
|
// Try 0-based at custom address
|
|
430
454
|
try {
|
|
431
|
-
const sunspecId = await instance.readRegisterStringValue(customBaseAddress, 2);
|
|
455
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(customBaseAddress, 2));
|
|
432
456
|
if (sunspecId.includes('SunS')) {
|
|
433
457
|
console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
|
|
434
458
|
return {
|
|
@@ -443,7 +467,7 @@ export class SunspecModbusClient {
|
|
|
443
467
|
}
|
|
444
468
|
// Try 1-based at custom address (customBaseAddress + 1)
|
|
445
469
|
try {
|
|
446
|
-
const sunspecId = await instance.readRegisterStringValue(customBaseAddress + 1, 2);
|
|
470
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(customBaseAddress + 1, 2));
|
|
447
471
|
if (sunspecId.includes('SunS')) {
|
|
448
472
|
console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
|
|
449
473
|
return {
|
|
@@ -460,7 +484,7 @@ export class SunspecModbusClient {
|
|
|
460
484
|
else {
|
|
461
485
|
// Try 1-based addressing first (most common)
|
|
462
486
|
try {
|
|
463
|
-
const sunspecId = await instance.readRegisterStringValue(40001, 2);
|
|
487
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(40001, 2));
|
|
464
488
|
if (sunspecId.includes('SunS')) {
|
|
465
489
|
console.log('Detected 1-based addressing mode (base address: 40001)');
|
|
466
490
|
return {
|
|
@@ -475,7 +499,7 @@ export class SunspecModbusClient {
|
|
|
475
499
|
}
|
|
476
500
|
// Try 0-based addressing
|
|
477
501
|
try {
|
|
478
|
-
const sunspecId = await instance.readRegisterStringValue(40000, 2);
|
|
502
|
+
const sunspecId = await this.runExclusive(unitId, () => instance.readRegisterStringValue(40000, 2));
|
|
479
503
|
if (sunspecId.includes('SunS')) {
|
|
480
504
|
console.log('Detected 0-based addressing mode (base address: 40000)');
|
|
481
505
|
return {
|
|
@@ -526,7 +550,7 @@ export class SunspecModbusClient {
|
|
|
526
550
|
currentAddress = addressInfo.nextAddress;
|
|
527
551
|
try {
|
|
528
552
|
while (currentAddress < maxAddress) {
|
|
529
|
-
const buffer = await instance.readHoldingRegisters(currentAddress, 2);
|
|
553
|
+
const buffer = await this.runExclusive(unitId, () => instance.readHoldingRegisters(currentAddress, 2));
|
|
530
554
|
const modelData = [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
|
|
531
555
|
if (!modelData || modelData.length < 2) {
|
|
532
556
|
console.debug(`No data at address ${currentAddress}, ending discovery`);
|
|
@@ -668,15 +692,20 @@ export class SunspecModbusClient {
|
|
|
668
692
|
// length 124, which combined with the 2-register header (126) exceeds the limit,
|
|
669
693
|
// so chunk reads larger than 125 registers and concatenate the results.
|
|
670
694
|
const MAX_REGISTERS_PER_READ = 125;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
695
|
+
// Serialize the whole (possibly multi-chunk) block read on the unit's socket so a
|
|
696
|
+
// concurrent poll or control write can't interleave its frames between our chunks.
|
|
697
|
+
const chunks = await this.runExclusive(unitId, async () => {
|
|
698
|
+
const acc = [];
|
|
699
|
+
for (let offset = 0; offset < totalRegisters; offset += MAX_REGISTERS_PER_READ) {
|
|
700
|
+
const quantity = Math.min(MAX_REGISTERS_PER_READ, totalRegisters - offset);
|
|
701
|
+
const result = await reader.readHoldingRegisters(model.address + offset, quantity);
|
|
702
|
+
if (!result.success || !result.value) {
|
|
703
|
+
throw new Error(`Failed to read model block ${model.id} at address ${model.address + offset} (unit ${unitId}): ${result.error?.message || 'Unknown error'}`);
|
|
704
|
+
}
|
|
705
|
+
acc.push(result.value);
|
|
677
706
|
}
|
|
678
|
-
|
|
679
|
-
}
|
|
707
|
+
return acc;
|
|
708
|
+
});
|
|
680
709
|
this.connectionHealth.recordSuccess();
|
|
681
710
|
return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
|
|
682
711
|
}
|
|
@@ -703,7 +732,7 @@ export class SunspecModbusClient {
|
|
|
703
732
|
async readRegisterValue(unitId, address, quantity = 1, dataType) {
|
|
704
733
|
const reader = this.getReader(unitId);
|
|
705
734
|
try {
|
|
706
|
-
const result = await reader.readHoldingRegisters(address, quantity);
|
|
735
|
+
const result = await this.runExclusive(unitId, () => reader.readHoldingRegisters(address, quantity));
|
|
707
736
|
// Check if the read was successful
|
|
708
737
|
if (!result.success || !result.value) {
|
|
709
738
|
throw new Error(`Failed to read register at address ${address}: ${result.error?.message || 'Unknown error'}`);
|
|
@@ -753,8 +782,9 @@ export class SunspecModbusClient {
|
|
|
753
782
|
break;
|
|
754
783
|
}
|
|
755
784
|
}
|
|
756
|
-
// Write to holding registers
|
|
757
|
-
|
|
785
|
+
// Write to holding registers. Serialized on the unit's socket so the write can't
|
|
786
|
+
// collide with a concurrent poll's read (which would cross their response frames).
|
|
787
|
+
await this.runExclusive(unitId, () => instance.writeMultipleRegisters(address, registerValues));
|
|
758
788
|
this.connectionHealth.recordSuccess();
|
|
759
789
|
// Per-register write success — fires for every parameter inside higher-level
|
|
760
790
|
// writers (writeBatteryControls / writeInverterControls). Demoted to debug.
|
|
@@ -2370,7 +2400,7 @@ export class SunspecModbusClient {
|
|
|
2370
2400
|
// Note: This is a simplified implementation. In production, you'd batch writes
|
|
2371
2401
|
if (settings.WMax !== undefined) {
|
|
2372
2402
|
// Need to read scale factor first if not provided
|
|
2373
|
-
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 22, 1);
|
|
2403
|
+
const sfBuffer = await this.runExclusive(unitId, () => instance.readHoldingRegisters(baseAddr + 22, 1));
|
|
2374
2404
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
2375
2405
|
const scaledValue = Math.round(settings.WMax / Math.pow(10, scaleFactor));
|
|
2376
2406
|
// Writing registers needs to be implemented in EnergyAppModbusInstance
|
|
@@ -2378,7 +2408,7 @@ export class SunspecModbusClient {
|
|
|
2378
2408
|
console.debug(`Would write value ${scaledValue} to register ${baseAddr}`);
|
|
2379
2409
|
}
|
|
2380
2410
|
if (settings.VRef !== undefined) {
|
|
2381
|
-
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 23, 1);
|
|
2411
|
+
const sfBuffer = await this.runExclusive(unitId, () => instance.readHoldingRegisters(baseAddr + 23, 1));
|
|
2382
2412
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
2383
2413
|
const scaledValue = Math.round(settings.VRef / Math.pow(10, scaleFactor));
|
|
2384
2414
|
console.debug(`Would write value ${scaledValue} to register ${baseAddr + 1}`);
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED