@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.
@@ -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
- // The persisted errorState can diverge from what the core actually shows
360
- // (e.g. a faulted that was never cleared while our local state reads
361
- // healthy), so we force one re-assert of the current status on the first
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
- * Persisted error state for a SunSpec inverter, written to storage so the
152
- * status reporter can detect transitions across process restarts and avoid
153
- * re-emitting `faulted` for the same already-known errors.
154
- */
155
- export interface SunspecInverterPersistedErrorState {
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
- const chunks = [];
677
- for (let offset = 0; offset < totalRegisters; offset += MAX_REGISTERS_PER_READ) {
678
- const quantity = Math.min(MAX_REGISTERS_PER_READ, totalRegisters - offset);
679
- const result = await reader.readHoldingRegisters(model.address + offset, quantity);
680
- if (!result.success || !result.value) {
681
- throw new Error(`Failed to read model block ${model.id} at address ${model.address + offset} (unit ${unitId}): ${result.error?.message || 'Unknown error'}`);
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
- chunks.push(result.value);
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
- await instance.writeMultipleRegisters(address, registerValues);
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
  /**
@@ -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.88';
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
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "0.0.88";
8
+ export declare const SDK_VERSION = "0.0.90";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
@@ -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>;
@@ -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
- // The persisted errorState can diverge from what the core actually shows
353
- // (e.g. a faulted that was never cleared while our local state reads
354
- // healthy), so we force one re-assert of the current status on the first
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
- * Persisted error state for a SunSpec inverter, written to storage so the
152
- * status reporter can detect transitions across process restarts and avoid
153
- * re-emitting `faulted` for the same already-known errors.
154
- */
155
- export interface SunspecInverterPersistedErrorState {
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
- const chunks = [];
672
- for (let offset = 0; offset < totalRegisters; offset += MAX_REGISTERS_PER_READ) {
673
- const quantity = Math.min(MAX_REGISTERS_PER_READ, totalRegisters - offset);
674
- const result = await reader.readHoldingRegisters(model.address + offset, quantity);
675
- if (!result.success || !result.value) {
676
- throw new Error(`Failed to read model block ${model.id} at address ${model.address + offset} (unit ${unitId}): ${result.error?.message || 'Unknown error'}`);
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
- chunks.push(result.value);
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
- await instance.writeMultipleRegisters(address, registerValues);
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
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "0.0.88";
8
+ export declare const SDK_VERSION = "0.0.90";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/dist/version.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export const SDK_VERSION = '0.0.88';
8
+ export const SDK_VERSION = '0.0.90';
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enyo-energy/sunspec-sdk",
3
- "version": "0.0.88",
3
+ "version": "0.0.90",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",