@enyo-energy/sunspec-sdk 0.0.82 → 0.0.84
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 +59 -13
- package/dist/cjs/sunspec-devices.d.cts +14 -0
- package/dist/cjs/sunspec-interfaces.cjs +17 -1
- package/dist/cjs/sunspec-interfaces.d.cts +15 -0
- package/dist/cjs/sunspec-modbus-client.cjs +84 -4
- package/dist/cjs/sunspec-modbus-client.d.cts +33 -2
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/sunspec-devices.d.ts +14 -0
- package/dist/sunspec-devices.js +60 -14
- package/dist/sunspec-interfaces.d.ts +15 -0
- package/dist/sunspec-interfaces.js +16 -0
- package/dist/sunspec-modbus-client.d.ts +33 -2
- package/dist/sunspec-modbus-client.js +84 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -346,6 +346,12 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
346
346
|
storage;
|
|
347
347
|
errorState = { activeCodes: [], lastStatus: 'healthy' };
|
|
348
348
|
snapshotService;
|
|
349
|
+
// Whether we've emitted a status update at least once since (re)connecting.
|
|
350
|
+
// The persisted errorState can diverge from what the core actually shows
|
|
351
|
+
// (e.g. a faulted that was never cleared while our local state reads
|
|
352
|
+
// healthy), so we force one re-assert of the current status on the first
|
|
353
|
+
// successful read of each session to converge the core.
|
|
354
|
+
statusReassertedThisSession = false;
|
|
349
355
|
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
|
|
350
356
|
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
|
|
351
357
|
this.capabilities = capabilities;
|
|
@@ -394,9 +400,13 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
394
400
|
else {
|
|
395
401
|
try {
|
|
396
402
|
const existingAppliance = await this.applianceManager.findApplianceById(this.applianceId);
|
|
403
|
+
// Drop any previously core-stored `status` so we don't echo a
|
|
404
|
+
// stale `faulted` back through ApplianceSave — status is driven
|
|
405
|
+
// solely by ApplianceStateUpdateV1 (detectAndEmitStatusTransition).
|
|
406
|
+
const { status: _staleStatus, ...prevMetadata } = existingAppliance?.metadata ?? {};
|
|
397
407
|
await this.applianceManager.updateAppliance(this.applianceId, {
|
|
398
408
|
metadata: {
|
|
399
|
-
...
|
|
409
|
+
...prevMetadata,
|
|
400
410
|
connectionType: enyo_appliance_js_1.EnyoApplianceConnectionType.Connector,
|
|
401
411
|
state: enyo_appliance_js_1.EnyoApplianceStateEnum.Connected,
|
|
402
412
|
modbus: { unitId: this.unitId, baseAddress: this.baseAddress },
|
|
@@ -420,6 +430,9 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
420
430
|
console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
|
|
421
431
|
}
|
|
422
432
|
await this.loadErrorState();
|
|
433
|
+
// Fresh session: force the next successful read to re-assert the true
|
|
434
|
+
// status so a stale faulted in the core is cleared on (re)start.
|
|
435
|
+
this.statusReassertedThisSession = false;
|
|
423
436
|
this.startDataBusListening();
|
|
424
437
|
// Cold-start recovery: if the persisted state says we were stuck in
|
|
425
438
|
// connection_lost but connect() just succeeded (we read commonBlock,
|
|
@@ -638,16 +651,30 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
638
651
|
if (!this.applianceId || !this.dataBus)
|
|
639
652
|
return;
|
|
640
653
|
const { codes, codeIds } = this.decodeActiveErrors(data);
|
|
641
|
-
|
|
642
|
-
|
|
654
|
+
// Status is driven by BOTH the decoded event-register error bits AND the
|
|
655
|
+
// SunSpec operating state. Only operatingState 7 (FAULT) is a genuine
|
|
656
|
+
// fault; MPPT (4), SLEEPING (2) and the other states are normal. This
|
|
657
|
+
// is re-evaluated every poll so a producing inverter (operatingState=4,
|
|
658
|
+
// no error bits) reports Healthy even if it was previously faulted.
|
|
659
|
+
const operatingFault = data.operatingState === sunspec_interfaces_js_1.SunspecInverterOperatingState.FAULT;
|
|
660
|
+
const newStatus = (codeIds.length > 0 || operatingFault)
|
|
661
|
+
? enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted
|
|
662
|
+
: enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy;
|
|
663
|
+
const newLastStatus = newStatus === enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted';
|
|
664
|
+
// Emit when the effective status changed (covers faulted/connection_lost
|
|
665
|
+
// -> healthy convergence), when the active error-code set changed, or
|
|
666
|
+
// once per session to re-assert the true status to the core (clears a
|
|
667
|
+
// stale faulted that diverged from our local state). Otherwise stay
|
|
668
|
+
// quiet so we don't republish the same status every 10s poll.
|
|
669
|
+
const statusChanged = newLastStatus !== this.errorState.lastStatus;
|
|
670
|
+
const mustReassert = !this.statusReassertedThisSession;
|
|
671
|
+
if (!statusChanged && !this.hasErrorSetChanged(codeIds) && !mustReassert) {
|
|
643
672
|
return;
|
|
644
673
|
}
|
|
645
|
-
const newStatus = codeIds.length === 0
|
|
646
|
-
? enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy
|
|
647
|
-
: enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted;
|
|
648
674
|
const message = this.buildStatusMessage(newStatus, codes, timestamp);
|
|
649
|
-
console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
|
|
675
|
+
console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}], operatingState=${data.operatingState})`);
|
|
650
676
|
this.dataBus.sendMessage([message]);
|
|
677
|
+
this.statusReassertedThisSession = true;
|
|
651
678
|
this.errorState = {
|
|
652
679
|
evt1: data.events,
|
|
653
680
|
evt2: data.events2,
|
|
@@ -656,7 +683,7 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
656
683
|
evtVnd3: data.vendorEvents3,
|
|
657
684
|
evtVnd4: data.vendorEvents4,
|
|
658
685
|
activeCodes: codeIds,
|
|
659
|
-
lastStatus:
|
|
686
|
+
lastStatus: newLastStatus,
|
|
660
687
|
};
|
|
661
688
|
await this.persistErrorState();
|
|
662
689
|
}
|
|
@@ -1649,6 +1676,20 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
1649
1676
|
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
|
|
1650
1677
|
this.capabilities = capabilities;
|
|
1651
1678
|
}
|
|
1679
|
+
/**
|
|
1680
|
+
* The specific meter model instance this meter reads, when its unit hosts more than one
|
|
1681
|
+
* embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
|
|
1682
|
+
* When set, identity and telemetry are read from THIS block and its own preceding Common
|
|
1683
|
+
* block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
|
|
1684
|
+
*/
|
|
1685
|
+
meterModel;
|
|
1686
|
+
/**
|
|
1687
|
+
* Bind this meter to a specific discovered meter model instance (from
|
|
1688
|
+
* {@link SunspecModbusClient.findModels}). Call before {@link connect}.
|
|
1689
|
+
*/
|
|
1690
|
+
bindMeterModel(model) {
|
|
1691
|
+
this.meterModel = model;
|
|
1692
|
+
}
|
|
1652
1693
|
/**
|
|
1653
1694
|
* Connect to the meter and create/update the appliance
|
|
1654
1695
|
*/
|
|
@@ -1656,7 +1697,8 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
1656
1697
|
// Connect with specific unit ID for meter
|
|
1657
1698
|
await this.ensureConnected();
|
|
1658
1699
|
// Check if meter models exist (int+SF: 201/203/204, float: 211/212/213/214)
|
|
1659
|
-
const hasMeter = this.
|
|
1700
|
+
const hasMeter = this.meterModel !== undefined ||
|
|
1701
|
+
this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3Phase) !== undefined ||
|
|
1660
1702
|
this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.MeterWye) !== undefined ||
|
|
1661
1703
|
this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase) !== undefined ||
|
|
1662
1704
|
this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3PhaseWyeFloat) !== undefined ||
|
|
@@ -1666,8 +1708,12 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
1666
1708
|
if (!hasMeter) {
|
|
1667
1709
|
throw new Error('No meter model found in device');
|
|
1668
1710
|
}
|
|
1669
|
-
// Get device info
|
|
1670
|
-
|
|
1711
|
+
// Get device info. For a bound meter instance, read ITS own Common block (the one
|
|
1712
|
+
// preceding its meter model) so the appliance gets the meter's own manufacturer/model/
|
|
1713
|
+
// serial — not the inverter's or a sibling meter's (the unit's first Common).
|
|
1714
|
+
const commonData = this.meterModel
|
|
1715
|
+
? await this.sunspecClient.readCommonBlockForModel(this.unitId, this.meterModel)
|
|
1716
|
+
: await this.sunspecClient.readCommonBlock(this.unitId);
|
|
1671
1717
|
// Create or update appliance (skip if an existing appliance was provided)
|
|
1672
1718
|
if (!this.applianceId) {
|
|
1673
1719
|
try {
|
|
@@ -1741,8 +1787,8 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
1741
1787
|
const messages = [];
|
|
1742
1788
|
const timestamp = new Date();
|
|
1743
1789
|
try {
|
|
1744
|
-
// Read meter data
|
|
1745
|
-
const meterData = await this.sunspecClient.readMeterData(this.unitId);
|
|
1790
|
+
// Read meter data (from the bound instance when this unit hosts multiple meters)
|
|
1791
|
+
const meterData = await this.sunspecClient.readMeterData(this.unitId, this.meterModel);
|
|
1746
1792
|
// SDK readers swallow modbus errors and return null; detect that here so the
|
|
1747
1793
|
// appliance is marked offline and the retry manager starts its backoff.
|
|
1748
1794
|
if (await this.markOfflineIfUnhealthy()) {
|
|
@@ -3,6 +3,7 @@ import { ApplianceManager, EnergyApp } from "@enyo-energy/energy-app-sdk";
|
|
|
3
3
|
import { type EnyoAppliance, type EnyoApplianceErrorCode, EnyoApplianceName } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
|
|
4
4
|
import { EnyoNetworkDevice } from "@enyo-energy/energy-app-sdk/dist/types/enyo-network-device.js";
|
|
5
5
|
import { SunspecModbusClient } from "./sunspec-modbus-client.cjs";
|
|
6
|
+
import { SunspecModel } from "./sunspec-interfaces.cjs";
|
|
6
7
|
import { ConnectionRetryManager } from "./connection-retry-manager.cjs";
|
|
7
8
|
import { EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusMessage, EnyoDataBusMessageEnum, EnyoDataBusMessageResolution } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
|
|
8
9
|
import { BatteryCalibrator, type BatteryCalibratorConfig, type CalibrationResult, type CalibrationResultStore, type CalibrationSnapshot, type RestoreReason, SnapshotService } from "@enyo-energy/appliance-calibration";
|
|
@@ -131,6 +132,7 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
131
132
|
private storage?;
|
|
132
133
|
private errorState;
|
|
133
134
|
private snapshotService?;
|
|
135
|
+
private statusReassertedThisSession;
|
|
134
136
|
constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecInverterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
|
|
135
137
|
connect(): Promise<void>;
|
|
136
138
|
disconnect(): Promise<void>;
|
|
@@ -468,6 +470,18 @@ export declare class SunspecBattery extends BaseSunspecDevice {
|
|
|
468
470
|
export declare class SunspecMeter extends BaseSunspecDevice {
|
|
469
471
|
private readonly capabilities;
|
|
470
472
|
constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecMeterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
|
|
473
|
+
/**
|
|
474
|
+
* The specific meter model instance this meter reads, when its unit hosts more than one
|
|
475
|
+
* embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
|
|
476
|
+
* When set, identity and telemetry are read from THIS block and its own preceding Common
|
|
477
|
+
* block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
|
|
478
|
+
*/
|
|
479
|
+
private meterModel?;
|
|
480
|
+
/**
|
|
481
|
+
* Bind this meter to a specific discovered meter model instance (from
|
|
482
|
+
* {@link SunspecModbusClient.findModels}). Call before {@link connect}.
|
|
483
|
+
*/
|
|
484
|
+
bindMeterModel(model: SunspecModel): void;
|
|
471
485
|
/**
|
|
472
486
|
* Connect to the meter and create/update the appliance
|
|
473
487
|
*/
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* SunSpec block interfaces with block numbers
|
|
4
4
|
*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.SUNSPEC_CONTROLLABLE_FEATURES = exports.SunspecMeterCapability = exports.SunspecBatteryFeatureModeKind = exports.SunspecBatteryCapability = exports.SunspecInverterCapability = exports.SunspecStorageMode = exports.SunspecChargeSource = exports.SunspecVArPctMode = exports.SunspecEnableControl = exports.SunspecConnectionControl = exports.SunspecStorageControlMode = exports.SunspecBatteryEvent1 = exports.SunspecBatteryBankState = exports.SunspecBatteryType = exports.SunspecBatteryControlMode = exports.SunspecBatteryChargeState = exports.SunspecMPPTOperatingState = exports.SUNSPEC_CONNECTION_LOST_CODE = exports.SunspecInverterEvent1 = exports.SunspecModelId = exports.DEFAULT_RETRY_CONFIG = void 0;
|
|
6
|
+
exports.SUNSPEC_CONTROLLABLE_FEATURES = exports.SunspecMeterCapability = exports.SunspecBatteryFeatureModeKind = exports.SunspecBatteryCapability = exports.SunspecInverterCapability = exports.SunspecStorageMode = exports.SunspecChargeSource = exports.SunspecVArPctMode = exports.SunspecEnableControl = exports.SunspecConnectionControl = exports.SunspecStorageControlMode = exports.SunspecBatteryEvent1 = exports.SunspecBatteryBankState = exports.SunspecBatteryType = exports.SunspecBatteryControlMode = exports.SunspecBatteryChargeState = exports.SunspecInverterOperatingState = exports.SunspecMPPTOperatingState = exports.SUNSPEC_CONNECTION_LOST_CODE = exports.SunspecInverterEvent1 = exports.SunspecModelId = exports.DEFAULT_RETRY_CONFIG = void 0;
|
|
7
7
|
const enyo_battery_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js");
|
|
8
8
|
exports.DEFAULT_RETRY_CONFIG = {
|
|
9
9
|
phases: [
|
|
@@ -87,6 +87,22 @@ var SunspecMPPTOperatingState;
|
|
|
87
87
|
SunspecMPPTOperatingState[SunspecMPPTOperatingState["TEST"] = 9] = "TEST";
|
|
88
88
|
SunspecMPPTOperatingState[SunspecMPPTOperatingState["RESERVED_10"] = 10] = "RESERVED_10";
|
|
89
89
|
})(SunspecMPPTOperatingState || (exports.SunspecMPPTOperatingState = SunspecMPPTOperatingState = {}));
|
|
90
|
+
/**
|
|
91
|
+
* Inverter Operating State values (SunSpec `St`, register offset 38) for
|
|
92
|
+
* models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
|
|
93
|
+
* genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
|
|
94
|
+
*/
|
|
95
|
+
var SunspecInverterOperatingState;
|
|
96
|
+
(function (SunspecInverterOperatingState) {
|
|
97
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["OFF"] = 1] = "OFF";
|
|
98
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["SLEEPING"] = 2] = "SLEEPING";
|
|
99
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["STARTING"] = 3] = "STARTING";
|
|
100
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["MPPT"] = 4] = "MPPT";
|
|
101
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["THROTTLED"] = 5] = "THROTTLED";
|
|
102
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["SHUTTING_DOWN"] = 6] = "SHUTTING_DOWN";
|
|
103
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["FAULT"] = 7] = "FAULT";
|
|
104
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["STANDBY"] = 8] = "STANDBY";
|
|
105
|
+
})(SunspecInverterOperatingState || (exports.SunspecInverterOperatingState = SunspecInverterOperatingState = {}));
|
|
90
106
|
/**
|
|
91
107
|
* Battery Charge State values for Model 124
|
|
92
108
|
*/
|
|
@@ -222,6 +222,21 @@ export declare enum SunspecMPPTOperatingState {
|
|
|
222
222
|
TEST = 9,
|
|
223
223
|
RESERVED_10 = 10
|
|
224
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Inverter Operating State values (SunSpec `St`, register offset 38) for
|
|
227
|
+
* models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
|
|
228
|
+
* genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
|
|
229
|
+
*/
|
|
230
|
+
export declare enum SunspecInverterOperatingState {
|
|
231
|
+
OFF = 1,
|
|
232
|
+
SLEEPING = 2,
|
|
233
|
+
STARTING = 3,
|
|
234
|
+
MPPT = 4,// Maximum Power Point Tracking active (producing)
|
|
235
|
+
THROTTLED = 5,// Power output is being limited
|
|
236
|
+
SHUTTING_DOWN = 6,
|
|
237
|
+
FAULT = 7,
|
|
238
|
+
STANDBY = 8
|
|
239
|
+
}
|
|
225
240
|
/**
|
|
226
241
|
* Battery Charge State values for Model 124
|
|
227
242
|
*/
|
|
@@ -83,7 +83,15 @@ class SunspecModbusClient {
|
|
|
83
83
|
// many unit IDs share that client (each unit ID is one EnergyAppModbusInstance underneath).
|
|
84
84
|
modbusInstances = new Map();
|
|
85
85
|
faultTolerantReaders = new Map();
|
|
86
|
+
// First-instance-wins directory (lowest address per model id). SunSpec allows a model id to
|
|
87
|
+
// repeat on one unit — most notably Common (id 1), which appears once per logical device
|
|
88
|
+
// (inverter, then each embedded meter). Keyed lookups want the PRIMARY (first) instance, so
|
|
89
|
+
// findModel/readCommonBlock resolve to the device's own block, not a later meter's.
|
|
86
90
|
discoveredModelsByUnit = new Map();
|
|
91
|
+
// Full address-ordered list of every discovered instance, including repeats. Used by
|
|
92
|
+
// findModels()/readCommonBlockForModel() to enumerate multiple meters and pair each meter
|
|
93
|
+
// block with its own preceding Common block.
|
|
94
|
+
discoveredModelListByUnit = new Map();
|
|
87
95
|
connectionHealth;
|
|
88
96
|
modbusDataTypeConverter;
|
|
89
97
|
connectionParams = null;
|
|
@@ -306,6 +314,7 @@ class SunspecModbusClient {
|
|
|
306
314
|
this.modbusInstances.delete(unitId);
|
|
307
315
|
this.faultTolerantReaders.delete(unitId);
|
|
308
316
|
this.discoveredModelsByUnit.delete(unitId);
|
|
317
|
+
this.discoveredModelListByUnit.delete(unitId);
|
|
309
318
|
if (instance) {
|
|
310
319
|
try {
|
|
311
320
|
await instance.disconnect();
|
|
@@ -376,6 +385,17 @@ class SunspecModbusClient {
|
|
|
376
385
|
}
|
|
377
386
|
return m;
|
|
378
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Get (or create) the address-ordered list of every discovered model instance for a unit.
|
|
390
|
+
*/
|
|
391
|
+
getModelList(unitId) {
|
|
392
|
+
let list = this.discoveredModelListByUnit.get(unitId);
|
|
393
|
+
if (!list) {
|
|
394
|
+
list = [];
|
|
395
|
+
this.discoveredModelListByUnit.set(unitId, list);
|
|
396
|
+
}
|
|
397
|
+
return list;
|
|
398
|
+
}
|
|
379
399
|
/**
|
|
380
400
|
* Enable or disable automatic reconnection
|
|
381
401
|
*/
|
|
@@ -473,6 +493,8 @@ class SunspecModbusClient {
|
|
|
473
493
|
const instance = this.getInstance(unitId);
|
|
474
494
|
const models = this.getModelsMap(unitId);
|
|
475
495
|
models.clear();
|
|
496
|
+
const modelList = this.getModelList(unitId);
|
|
497
|
+
modelList.length = 0;
|
|
476
498
|
const maxAddress = 50000; // Safety limit
|
|
477
499
|
let currentAddress = 0;
|
|
478
500
|
console.log(`Starting Sunspec model discovery for unit ${unitId}...`);
|
|
@@ -514,7 +536,13 @@ class SunspecModbusClient {
|
|
|
514
536
|
address: currentAddress,
|
|
515
537
|
length: modelLength
|
|
516
538
|
};
|
|
517
|
-
|
|
539
|
+
// Keep the FIRST (lowest-address) instance per id in the keyed directory so
|
|
540
|
+
// findModel() resolves to the device's own block. Record every instance —
|
|
541
|
+
// including repeats like a meter's Common (id 1) — in the ordered list.
|
|
542
|
+
if (!models.has(modelId)) {
|
|
543
|
+
models.set(modelId, model);
|
|
544
|
+
}
|
|
545
|
+
modelList.push(model);
|
|
518
546
|
// Per-model discovery step. The end-of-discovery summary below is the
|
|
519
547
|
// info-level outcome; the per-model walk is debug-only.
|
|
520
548
|
console.debug(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength} (unit ${unitId})`);
|
|
@@ -525,7 +553,9 @@ class SunspecModbusClient {
|
|
|
525
553
|
catch (error) {
|
|
526
554
|
console.error(`Error during model discovery at address ${currentAddress} (unit ${unitId}): ${error}`);
|
|
527
555
|
}
|
|
528
|
-
|
|
556
|
+
// Report every instance in address order so repeated ids (e.g. two Common blocks, or
|
|
557
|
+
// multiple meters) are visible — models.size would hide them behind the unique keys.
|
|
558
|
+
console.log(`Discovery complete for unit ${unitId}. Found ${modelList.length} model instance(s): [${modelList.map(m => m.id).join(', ')}]`);
|
|
529
559
|
return models;
|
|
530
560
|
}
|
|
531
561
|
/**
|
|
@@ -534,6 +564,17 @@ class SunspecModbusClient {
|
|
|
534
564
|
findModel(unitId, modelId) {
|
|
535
565
|
return this.discoveredModelsByUnit.get(unitId)?.get(modelId);
|
|
536
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Find every instance of a model id on a unit, in ascending address order.
|
|
569
|
+
*
|
|
570
|
+
* SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
|
|
571
|
+
* inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
|
|
572
|
+
* each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
|
|
573
|
+
* `findModel` only returns the first instance; use this to enumerate them all.
|
|
574
|
+
*/
|
|
575
|
+
findModels(unitId, modelId) {
|
|
576
|
+
return (this.discoveredModelListByUnit.get(unitId) ?? []).filter(m => m.id === modelId);
|
|
577
|
+
}
|
|
537
578
|
/**
|
|
538
579
|
* Check if a value is "unimplemented" according to Sunspec specification
|
|
539
580
|
* Returns true if the value represents an unimplemented/not applicable register
|
|
@@ -1834,7 +1875,16 @@ class SunspecModbusClient {
|
|
|
1834
1875
|
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
1835
1876
|
* for this unit (see getPreferredEncoding). Never reads both.
|
|
1836
1877
|
*/
|
|
1837
|
-
async readMeterData(unitId) {
|
|
1878
|
+
async readMeterData(unitId, meterModel) {
|
|
1879
|
+
// When a specific meter instance is given (a unit with multiple embedded meters), read
|
|
1880
|
+
// exactly that block — don't let findModel pick the first/only directory entry.
|
|
1881
|
+
if (meterModel) {
|
|
1882
|
+
console.debug(`Using bound meter Model ${meterModel.id} at address ${meterModel.address} (length ${meterModel.length}) on unit ${unitId}`);
|
|
1883
|
+
const floatIds = [211, 212, 213, 214];
|
|
1884
|
+
return floatIds.includes(meterModel.id)
|
|
1885
|
+
? this.readFloatMeterData(unitId, meterModel, meterModel.id)
|
|
1886
|
+
: this.readIntSfMeterData(unitId, meterModel);
|
|
1887
|
+
}
|
|
1838
1888
|
const preferred = this.getPreferredEncoding(unitId);
|
|
1839
1889
|
const tryFloat = async () => {
|
|
1840
1890
|
const floatIds = [213, 214, 212, 211];
|
|
@@ -1992,7 +2042,12 @@ class SunspecModbusClient {
|
|
|
1992
2042
|
}
|
|
1993
2043
|
}
|
|
1994
2044
|
/**
|
|
1995
|
-
* Read common block data (Model 1)
|
|
2045
|
+
* Read common block data (Model 1).
|
|
2046
|
+
*
|
|
2047
|
+
* Resolves to the unit's FIRST Common block — the leading device's own identity (the
|
|
2048
|
+
* inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
|
|
2049
|
+
* own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
|
|
2050
|
+
* its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
|
|
1996
2051
|
*/
|
|
1997
2052
|
async readCommonBlock(unitId) {
|
|
1998
2053
|
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Common);
|
|
@@ -2002,6 +2057,31 @@ class SunspecModbusClient {
|
|
|
2002
2057
|
console.debug(`Common block model not found on unit ${unitId}`);
|
|
2003
2058
|
return null;
|
|
2004
2059
|
}
|
|
2060
|
+
return this.readCommonBlockAt(unitId, model);
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Read the Common (id 1) block that belongs to a specific device model — the Common instance
|
|
2064
|
+
* immediately preceding the model's address. SunSpec lays out each logical device as
|
|
2065
|
+
* `Common → device model(s)`, so a meter's identity is the Common block right before its meter
|
|
2066
|
+
* model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
|
|
2067
|
+
* well-formed map). Used to give each embedded meter its own appliance identity.
|
|
2068
|
+
*/
|
|
2069
|
+
async readCommonBlockForModel(unitId, deviceModel) {
|
|
2070
|
+
const preceding = this.findModels(unitId, sunspec_interfaces_js_1.SunspecModelId.Common)
|
|
2071
|
+
.filter(c => c.address <= deviceModel.address)
|
|
2072
|
+
.sort((a, b) => a.address - b.address)
|
|
2073
|
+
.pop();
|
|
2074
|
+
const model = preceding ?? this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Common);
|
|
2075
|
+
if (!model) {
|
|
2076
|
+
console.debug(`Common block model not found on unit ${unitId} for model ${deviceModel.id}@${deviceModel.address}`);
|
|
2077
|
+
return null;
|
|
2078
|
+
}
|
|
2079
|
+
return this.readCommonBlockAt(unitId, model);
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Decode a given Common (Model 1) block instance into identity fields.
|
|
2083
|
+
*/
|
|
2084
|
+
async readCommonBlockAt(unitId, model) {
|
|
2005
2085
|
console.debug(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
|
|
2006
2086
|
try {
|
|
2007
2087
|
// Read entire model block in a single Modbus call
|
|
@@ -37,6 +37,7 @@ export declare class SunspecModbusClient {
|
|
|
37
37
|
private modbusInstances;
|
|
38
38
|
private faultTolerantReaders;
|
|
39
39
|
private discoveredModelsByUnit;
|
|
40
|
+
private discoveredModelListByUnit;
|
|
40
41
|
private connectionHealth;
|
|
41
42
|
private modbusDataTypeConverter;
|
|
42
43
|
private connectionParams;
|
|
@@ -128,6 +129,10 @@ export declare class SunspecModbusClient {
|
|
|
128
129
|
* Get (or create) the discovered-models map for a unit.
|
|
129
130
|
*/
|
|
130
131
|
private getModelsMap;
|
|
132
|
+
/**
|
|
133
|
+
* Get (or create) the address-ordered list of every discovered model instance for a unit.
|
|
134
|
+
*/
|
|
135
|
+
private getModelList;
|
|
131
136
|
/**
|
|
132
137
|
* Enable or disable automatic reconnection
|
|
133
138
|
*/
|
|
@@ -153,6 +158,15 @@ export declare class SunspecModbusClient {
|
|
|
153
158
|
* Find a specific model by ID for a given unit
|
|
154
159
|
*/
|
|
155
160
|
findModel(unitId: number, modelId: number): SunspecModel | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* Find every instance of a model id on a unit, in ascending address order.
|
|
163
|
+
*
|
|
164
|
+
* SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
|
|
165
|
+
* inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
|
|
166
|
+
* each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
|
|
167
|
+
* `findModel` only returns the first instance; use this to enumerate them all.
|
|
168
|
+
*/
|
|
169
|
+
findModels(unitId: number, modelId: number): SunspecModel[];
|
|
156
170
|
/**
|
|
157
171
|
* Check if a value is "unimplemented" according to Sunspec specification
|
|
158
172
|
* Returns true if the value represents an unimplemented/not applicable register
|
|
@@ -337,7 +351,7 @@ export declare class SunspecModbusClient {
|
|
|
337
351
|
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
338
352
|
* for this unit (see getPreferredEncoding). Never reads both.
|
|
339
353
|
*/
|
|
340
|
-
readMeterData(unitId: number): Promise<SunspecMeterData | null>;
|
|
354
|
+
readMeterData(unitId: number, meterModel?: SunspecModel): Promise<SunspecMeterData | null>;
|
|
341
355
|
/**
|
|
342
356
|
* Read meter data from an int+SF variant model (201/203/204). Extracted from the original
|
|
343
357
|
* readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
|
|
@@ -362,9 +376,26 @@ export declare class SunspecModbusClient {
|
|
|
362
376
|
*/
|
|
363
377
|
private readFloatMeterData;
|
|
364
378
|
/**
|
|
365
|
-
* Read common block data (Model 1)
|
|
379
|
+
* Read common block data (Model 1).
|
|
380
|
+
*
|
|
381
|
+
* Resolves to the unit's FIRST Common block — the leading device's own identity (the
|
|
382
|
+
* inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
|
|
383
|
+
* own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
|
|
384
|
+
* its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
|
|
366
385
|
*/
|
|
367
386
|
readCommonBlock(unitId: number): Promise<any>;
|
|
387
|
+
/**
|
|
388
|
+
* Read the Common (id 1) block that belongs to a specific device model — the Common instance
|
|
389
|
+
* immediately preceding the model's address. SunSpec lays out each logical device as
|
|
390
|
+
* `Common → device model(s)`, so a meter's identity is the Common block right before its meter
|
|
391
|
+
* model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
|
|
392
|
+
* well-formed map). Used to give each embedded meter its own appliance identity.
|
|
393
|
+
*/
|
|
394
|
+
readCommonBlockForModel(unitId: number, deviceModel: SunspecModel): Promise<any>;
|
|
395
|
+
/**
|
|
396
|
+
* Decode a given Common (Model 1) block instance into identity fields.
|
|
397
|
+
*/
|
|
398
|
+
private readCommonBlockAt;
|
|
368
399
|
/**
|
|
369
400
|
* Get serial number from device
|
|
370
401
|
*/
|
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.84';
|
|
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
|
@@ -3,6 +3,7 @@ import { ApplianceManager, EnergyApp } from "@enyo-energy/energy-app-sdk";
|
|
|
3
3
|
import { type EnyoAppliance, type EnyoApplianceErrorCode, EnyoApplianceName } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
|
|
4
4
|
import { EnyoNetworkDevice } from "@enyo-energy/energy-app-sdk/dist/types/enyo-network-device.js";
|
|
5
5
|
import { SunspecModbusClient } from "./sunspec-modbus-client.js";
|
|
6
|
+
import { SunspecModel } from "./sunspec-interfaces.js";
|
|
6
7
|
import { ConnectionRetryManager } from "./connection-retry-manager.js";
|
|
7
8
|
import { EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusMessage, EnyoDataBusMessageEnum, EnyoDataBusMessageResolution } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
|
|
8
9
|
import { BatteryCalibrator, type BatteryCalibratorConfig, type CalibrationResult, type CalibrationResultStore, type CalibrationSnapshot, type RestoreReason, SnapshotService } from "@enyo-energy/appliance-calibration";
|
|
@@ -131,6 +132,7 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
131
132
|
private storage?;
|
|
132
133
|
private errorState;
|
|
133
134
|
private snapshotService?;
|
|
135
|
+
private statusReassertedThisSession;
|
|
134
136
|
constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecInverterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
|
|
135
137
|
connect(): Promise<void>;
|
|
136
138
|
disconnect(): Promise<void>;
|
|
@@ -468,6 +470,18 @@ export declare class SunspecBattery extends BaseSunspecDevice {
|
|
|
468
470
|
export declare class SunspecMeter extends BaseSunspecDevice {
|
|
469
471
|
private readonly capabilities;
|
|
470
472
|
constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecMeterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
|
|
473
|
+
/**
|
|
474
|
+
* The specific meter model instance this meter reads, when its unit hosts more than one
|
|
475
|
+
* embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
|
|
476
|
+
* When set, identity and telemetry are read from THIS block and its own preceding Common
|
|
477
|
+
* block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
|
|
478
|
+
*/
|
|
479
|
+
private meterModel?;
|
|
480
|
+
/**
|
|
481
|
+
* Bind this meter to a specific discovered meter model instance (from
|
|
482
|
+
* {@link SunspecModbusClient.findModels}). Call before {@link connect}.
|
|
483
|
+
*/
|
|
484
|
+
bindMeterModel(model: SunspecModel): void;
|
|
471
485
|
/**
|
|
472
486
|
* Connect to the meter and create/update the appliance
|
|
473
487
|
*/
|
package/dist/sunspec-devices.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SunspecBatteryChargeState, SunspecBatteryFeatureModeKind, SunspecEnableControl, SunspecInverterCapability, SunspecInverterEvent1, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode, SUNSPEC_CONNECTION_LOST_CODE } from "./sunspec-interfaces.js";
|
|
1
|
+
import { SunspecBatteryChargeState, SunspecBatteryFeatureModeKind, SunspecEnableControl, SunspecInverterCapability, SunspecInverterEvent1, SunspecInverterOperatingState, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode, SUNSPEC_CONNECTION_LOST_CODE } from "./sunspec-interfaces.js";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { EnyoApplianceConnectionType, EnyoApplianceStateEnum, EnyoApplianceStatusEnum, EnyoApplianceTopologyFeatureEnum, EnyoApplianceTypeEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
|
|
4
4
|
import { ConnectionRetryManager } from "./connection-retry-manager.js";
|
|
@@ -339,6 +339,12 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
339
339
|
storage;
|
|
340
340
|
errorState = { activeCodes: [], lastStatus: 'healthy' };
|
|
341
341
|
snapshotService;
|
|
342
|
+
// Whether we've emitted a status update at least once since (re)connecting.
|
|
343
|
+
// The persisted errorState can diverge from what the core actually shows
|
|
344
|
+
// (e.g. a faulted that was never cleared while our local state reads
|
|
345
|
+
// healthy), so we force one re-assert of the current status on the first
|
|
346
|
+
// successful read of each session to converge the core.
|
|
347
|
+
statusReassertedThisSession = false;
|
|
342
348
|
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
|
|
343
349
|
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
|
|
344
350
|
this.capabilities = capabilities;
|
|
@@ -387,9 +393,13 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
387
393
|
else {
|
|
388
394
|
try {
|
|
389
395
|
const existingAppliance = await this.applianceManager.findApplianceById(this.applianceId);
|
|
396
|
+
// Drop any previously core-stored `status` so we don't echo a
|
|
397
|
+
// stale `faulted` back through ApplianceSave — status is driven
|
|
398
|
+
// solely by ApplianceStateUpdateV1 (detectAndEmitStatusTransition).
|
|
399
|
+
const { status: _staleStatus, ...prevMetadata } = existingAppliance?.metadata ?? {};
|
|
390
400
|
await this.applianceManager.updateAppliance(this.applianceId, {
|
|
391
401
|
metadata: {
|
|
392
|
-
...
|
|
402
|
+
...prevMetadata,
|
|
393
403
|
connectionType: EnyoApplianceConnectionType.Connector,
|
|
394
404
|
state: EnyoApplianceStateEnum.Connected,
|
|
395
405
|
modbus: { unitId: this.unitId, baseAddress: this.baseAddress },
|
|
@@ -413,6 +423,9 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
413
423
|
console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
|
|
414
424
|
}
|
|
415
425
|
await this.loadErrorState();
|
|
426
|
+
// Fresh session: force the next successful read to re-assert the true
|
|
427
|
+
// status so a stale faulted in the core is cleared on (re)start.
|
|
428
|
+
this.statusReassertedThisSession = false;
|
|
416
429
|
this.startDataBusListening();
|
|
417
430
|
// Cold-start recovery: if the persisted state says we were stuck in
|
|
418
431
|
// connection_lost but connect() just succeeded (we read commonBlock,
|
|
@@ -631,16 +644,30 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
631
644
|
if (!this.applianceId || !this.dataBus)
|
|
632
645
|
return;
|
|
633
646
|
const { codes, codeIds } = this.decodeActiveErrors(data);
|
|
634
|
-
|
|
635
|
-
|
|
647
|
+
// Status is driven by BOTH the decoded event-register error bits AND the
|
|
648
|
+
// SunSpec operating state. Only operatingState 7 (FAULT) is a genuine
|
|
649
|
+
// fault; MPPT (4), SLEEPING (2) and the other states are normal. This
|
|
650
|
+
// is re-evaluated every poll so a producing inverter (operatingState=4,
|
|
651
|
+
// no error bits) reports Healthy even if it was previously faulted.
|
|
652
|
+
const operatingFault = data.operatingState === SunspecInverterOperatingState.FAULT;
|
|
653
|
+
const newStatus = (codeIds.length > 0 || operatingFault)
|
|
654
|
+
? EnyoApplianceStatusEnum.Faulted
|
|
655
|
+
: EnyoApplianceStatusEnum.Healthy;
|
|
656
|
+
const newLastStatus = newStatus === EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted';
|
|
657
|
+
// Emit when the effective status changed (covers faulted/connection_lost
|
|
658
|
+
// -> healthy convergence), when the active error-code set changed, or
|
|
659
|
+
// once per session to re-assert the true status to the core (clears a
|
|
660
|
+
// stale faulted that diverged from our local state). Otherwise stay
|
|
661
|
+
// quiet so we don't republish the same status every 10s poll.
|
|
662
|
+
const statusChanged = newLastStatus !== this.errorState.lastStatus;
|
|
663
|
+
const mustReassert = !this.statusReassertedThisSession;
|
|
664
|
+
if (!statusChanged && !this.hasErrorSetChanged(codeIds) && !mustReassert) {
|
|
636
665
|
return;
|
|
637
666
|
}
|
|
638
|
-
const newStatus = codeIds.length === 0
|
|
639
|
-
? EnyoApplianceStatusEnum.Healthy
|
|
640
|
-
: EnyoApplianceStatusEnum.Faulted;
|
|
641
667
|
const message = this.buildStatusMessage(newStatus, codes, timestamp);
|
|
642
|
-
console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
|
|
668
|
+
console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}], operatingState=${data.operatingState})`);
|
|
643
669
|
this.dataBus.sendMessage([message]);
|
|
670
|
+
this.statusReassertedThisSession = true;
|
|
644
671
|
this.errorState = {
|
|
645
672
|
evt1: data.events,
|
|
646
673
|
evt2: data.events2,
|
|
@@ -649,7 +676,7 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
649
676
|
evtVnd3: data.vendorEvents3,
|
|
650
677
|
evtVnd4: data.vendorEvents4,
|
|
651
678
|
activeCodes: codeIds,
|
|
652
|
-
lastStatus:
|
|
679
|
+
lastStatus: newLastStatus,
|
|
653
680
|
};
|
|
654
681
|
await this.persistErrorState();
|
|
655
682
|
}
|
|
@@ -1640,6 +1667,20 @@ export class SunspecMeter extends BaseSunspecDevice {
|
|
|
1640
1667
|
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
|
|
1641
1668
|
this.capabilities = capabilities;
|
|
1642
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* The specific meter model instance this meter reads, when its unit hosts more than one
|
|
1672
|
+
* embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
|
|
1673
|
+
* When set, identity and telemetry are read from THIS block and its own preceding Common
|
|
1674
|
+
* block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
|
|
1675
|
+
*/
|
|
1676
|
+
meterModel;
|
|
1677
|
+
/**
|
|
1678
|
+
* Bind this meter to a specific discovered meter model instance (from
|
|
1679
|
+
* {@link SunspecModbusClient.findModels}). Call before {@link connect}.
|
|
1680
|
+
*/
|
|
1681
|
+
bindMeterModel(model) {
|
|
1682
|
+
this.meterModel = model;
|
|
1683
|
+
}
|
|
1643
1684
|
/**
|
|
1644
1685
|
* Connect to the meter and create/update the appliance
|
|
1645
1686
|
*/
|
|
@@ -1647,7 +1688,8 @@ export class SunspecMeter extends BaseSunspecDevice {
|
|
|
1647
1688
|
// Connect with specific unit ID for meter
|
|
1648
1689
|
await this.ensureConnected();
|
|
1649
1690
|
// Check if meter models exist (int+SF: 201/203/204, float: 211/212/213/214)
|
|
1650
|
-
const hasMeter = this.
|
|
1691
|
+
const hasMeter = this.meterModel !== undefined ||
|
|
1692
|
+
this.sunspecClient.findModel(this.unitId, SunspecModelId.Meter3Phase) !== undefined ||
|
|
1651
1693
|
this.sunspecClient.findModel(this.unitId, SunspecModelId.MeterWye) !== undefined ||
|
|
1652
1694
|
this.sunspecClient.findModel(this.unitId, SunspecModelId.MeterSinglePhase) !== undefined ||
|
|
1653
1695
|
this.sunspecClient.findModel(this.unitId, SunspecModelId.Meter3PhaseWyeFloat) !== undefined ||
|
|
@@ -1657,8 +1699,12 @@ export class SunspecMeter extends BaseSunspecDevice {
|
|
|
1657
1699
|
if (!hasMeter) {
|
|
1658
1700
|
throw new Error('No meter model found in device');
|
|
1659
1701
|
}
|
|
1660
|
-
// Get device info
|
|
1661
|
-
|
|
1702
|
+
// Get device info. For a bound meter instance, read ITS own Common block (the one
|
|
1703
|
+
// preceding its meter model) so the appliance gets the meter's own manufacturer/model/
|
|
1704
|
+
// serial — not the inverter's or a sibling meter's (the unit's first Common).
|
|
1705
|
+
const commonData = this.meterModel
|
|
1706
|
+
? await this.sunspecClient.readCommonBlockForModel(this.unitId, this.meterModel)
|
|
1707
|
+
: await this.sunspecClient.readCommonBlock(this.unitId);
|
|
1662
1708
|
// Create or update appliance (skip if an existing appliance was provided)
|
|
1663
1709
|
if (!this.applianceId) {
|
|
1664
1710
|
try {
|
|
@@ -1732,8 +1778,8 @@ export class SunspecMeter extends BaseSunspecDevice {
|
|
|
1732
1778
|
const messages = [];
|
|
1733
1779
|
const timestamp = new Date();
|
|
1734
1780
|
try {
|
|
1735
|
-
// Read meter data
|
|
1736
|
-
const meterData = await this.sunspecClient.readMeterData(this.unitId);
|
|
1781
|
+
// Read meter data (from the bound instance when this unit hosts multiple meters)
|
|
1782
|
+
const meterData = await this.sunspecClient.readMeterData(this.unitId, this.meterModel);
|
|
1737
1783
|
// SDK readers swallow modbus errors and return null; detect that here so the
|
|
1738
1784
|
// appliance is marked offline and the retry manager starts its backoff.
|
|
1739
1785
|
if (await this.markOfflineIfUnhealthy()) {
|
|
@@ -222,6 +222,21 @@ export declare enum SunspecMPPTOperatingState {
|
|
|
222
222
|
TEST = 9,
|
|
223
223
|
RESERVED_10 = 10
|
|
224
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Inverter Operating State values (SunSpec `St`, register offset 38) for
|
|
227
|
+
* models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
|
|
228
|
+
* genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
|
|
229
|
+
*/
|
|
230
|
+
export declare enum SunspecInverterOperatingState {
|
|
231
|
+
OFF = 1,
|
|
232
|
+
SLEEPING = 2,
|
|
233
|
+
STARTING = 3,
|
|
234
|
+
MPPT = 4,// Maximum Power Point Tracking active (producing)
|
|
235
|
+
THROTTLED = 5,// Power output is being limited
|
|
236
|
+
SHUTTING_DOWN = 6,
|
|
237
|
+
FAULT = 7,
|
|
238
|
+
STANDBY = 8
|
|
239
|
+
}
|
|
225
240
|
/**
|
|
226
241
|
* Battery Charge State values for Model 124
|
|
227
242
|
*/
|
|
@@ -84,6 +84,22 @@ export var SunspecMPPTOperatingState;
|
|
|
84
84
|
SunspecMPPTOperatingState[SunspecMPPTOperatingState["TEST"] = 9] = "TEST";
|
|
85
85
|
SunspecMPPTOperatingState[SunspecMPPTOperatingState["RESERVED_10"] = 10] = "RESERVED_10";
|
|
86
86
|
})(SunspecMPPTOperatingState || (SunspecMPPTOperatingState = {}));
|
|
87
|
+
/**
|
|
88
|
+
* Inverter Operating State values (SunSpec `St`, register offset 38) for
|
|
89
|
+
* models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
|
|
90
|
+
* genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
|
|
91
|
+
*/
|
|
92
|
+
export var SunspecInverterOperatingState;
|
|
93
|
+
(function (SunspecInverterOperatingState) {
|
|
94
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["OFF"] = 1] = "OFF";
|
|
95
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["SLEEPING"] = 2] = "SLEEPING";
|
|
96
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["STARTING"] = 3] = "STARTING";
|
|
97
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["MPPT"] = 4] = "MPPT";
|
|
98
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["THROTTLED"] = 5] = "THROTTLED";
|
|
99
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["SHUTTING_DOWN"] = 6] = "SHUTTING_DOWN";
|
|
100
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["FAULT"] = 7] = "FAULT";
|
|
101
|
+
SunspecInverterOperatingState[SunspecInverterOperatingState["STANDBY"] = 8] = "STANDBY";
|
|
102
|
+
})(SunspecInverterOperatingState || (SunspecInverterOperatingState = {}));
|
|
87
103
|
/**
|
|
88
104
|
* Battery Charge State values for Model 124
|
|
89
105
|
*/
|
|
@@ -37,6 +37,7 @@ export declare class SunspecModbusClient {
|
|
|
37
37
|
private modbusInstances;
|
|
38
38
|
private faultTolerantReaders;
|
|
39
39
|
private discoveredModelsByUnit;
|
|
40
|
+
private discoveredModelListByUnit;
|
|
40
41
|
private connectionHealth;
|
|
41
42
|
private modbusDataTypeConverter;
|
|
42
43
|
private connectionParams;
|
|
@@ -128,6 +129,10 @@ export declare class SunspecModbusClient {
|
|
|
128
129
|
* Get (or create) the discovered-models map for a unit.
|
|
129
130
|
*/
|
|
130
131
|
private getModelsMap;
|
|
132
|
+
/**
|
|
133
|
+
* Get (or create) the address-ordered list of every discovered model instance for a unit.
|
|
134
|
+
*/
|
|
135
|
+
private getModelList;
|
|
131
136
|
/**
|
|
132
137
|
* Enable or disable automatic reconnection
|
|
133
138
|
*/
|
|
@@ -153,6 +158,15 @@ export declare class SunspecModbusClient {
|
|
|
153
158
|
* Find a specific model by ID for a given unit
|
|
154
159
|
*/
|
|
155
160
|
findModel(unitId: number, modelId: number): SunspecModel | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* Find every instance of a model id on a unit, in ascending address order.
|
|
163
|
+
*
|
|
164
|
+
* SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
|
|
165
|
+
* inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
|
|
166
|
+
* each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
|
|
167
|
+
* `findModel` only returns the first instance; use this to enumerate them all.
|
|
168
|
+
*/
|
|
169
|
+
findModels(unitId: number, modelId: number): SunspecModel[];
|
|
156
170
|
/**
|
|
157
171
|
* Check if a value is "unimplemented" according to Sunspec specification
|
|
158
172
|
* Returns true if the value represents an unimplemented/not applicable register
|
|
@@ -337,7 +351,7 @@ export declare class SunspecModbusClient {
|
|
|
337
351
|
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
338
352
|
* for this unit (see getPreferredEncoding). Never reads both.
|
|
339
353
|
*/
|
|
340
|
-
readMeterData(unitId: number): Promise<SunspecMeterData | null>;
|
|
354
|
+
readMeterData(unitId: number, meterModel?: SunspecModel): Promise<SunspecMeterData | null>;
|
|
341
355
|
/**
|
|
342
356
|
* Read meter data from an int+SF variant model (201/203/204). Extracted from the original
|
|
343
357
|
* readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
|
|
@@ -362,9 +376,26 @@ export declare class SunspecModbusClient {
|
|
|
362
376
|
*/
|
|
363
377
|
private readFloatMeterData;
|
|
364
378
|
/**
|
|
365
|
-
* Read common block data (Model 1)
|
|
379
|
+
* Read common block data (Model 1).
|
|
380
|
+
*
|
|
381
|
+
* Resolves to the unit's FIRST Common block — the leading device's own identity (the
|
|
382
|
+
* inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
|
|
383
|
+
* own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
|
|
384
|
+
* its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
|
|
366
385
|
*/
|
|
367
386
|
readCommonBlock(unitId: number): Promise<any>;
|
|
387
|
+
/**
|
|
388
|
+
* Read the Common (id 1) block that belongs to a specific device model — the Common instance
|
|
389
|
+
* immediately preceding the model's address. SunSpec lays out each logical device as
|
|
390
|
+
* `Common → device model(s)`, so a meter's identity is the Common block right before its meter
|
|
391
|
+
* model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
|
|
392
|
+
* well-formed map). Used to give each embedded meter its own appliance identity.
|
|
393
|
+
*/
|
|
394
|
+
readCommonBlockForModel(unitId: number, deviceModel: SunspecModel): Promise<any>;
|
|
395
|
+
/**
|
|
396
|
+
* Decode a given Common (Model 1) block instance into identity fields.
|
|
397
|
+
*/
|
|
398
|
+
private readCommonBlockAt;
|
|
368
399
|
/**
|
|
369
400
|
* Get serial number from device
|
|
370
401
|
*/
|
|
@@ -78,7 +78,15 @@ export class SunspecModbusClient {
|
|
|
78
78
|
// many unit IDs share that client (each unit ID is one EnergyAppModbusInstance underneath).
|
|
79
79
|
modbusInstances = new Map();
|
|
80
80
|
faultTolerantReaders = new Map();
|
|
81
|
+
// First-instance-wins directory (lowest address per model id). SunSpec allows a model id to
|
|
82
|
+
// repeat on one unit — most notably Common (id 1), which appears once per logical device
|
|
83
|
+
// (inverter, then each embedded meter). Keyed lookups want the PRIMARY (first) instance, so
|
|
84
|
+
// findModel/readCommonBlock resolve to the device's own block, not a later meter's.
|
|
81
85
|
discoveredModelsByUnit = new Map();
|
|
86
|
+
// Full address-ordered list of every discovered instance, including repeats. Used by
|
|
87
|
+
// findModels()/readCommonBlockForModel() to enumerate multiple meters and pair each meter
|
|
88
|
+
// block with its own preceding Common block.
|
|
89
|
+
discoveredModelListByUnit = new Map();
|
|
82
90
|
connectionHealth;
|
|
83
91
|
modbusDataTypeConverter;
|
|
84
92
|
connectionParams = null;
|
|
@@ -301,6 +309,7 @@ export class SunspecModbusClient {
|
|
|
301
309
|
this.modbusInstances.delete(unitId);
|
|
302
310
|
this.faultTolerantReaders.delete(unitId);
|
|
303
311
|
this.discoveredModelsByUnit.delete(unitId);
|
|
312
|
+
this.discoveredModelListByUnit.delete(unitId);
|
|
304
313
|
if (instance) {
|
|
305
314
|
try {
|
|
306
315
|
await instance.disconnect();
|
|
@@ -371,6 +380,17 @@ export class SunspecModbusClient {
|
|
|
371
380
|
}
|
|
372
381
|
return m;
|
|
373
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Get (or create) the address-ordered list of every discovered model instance for a unit.
|
|
385
|
+
*/
|
|
386
|
+
getModelList(unitId) {
|
|
387
|
+
let list = this.discoveredModelListByUnit.get(unitId);
|
|
388
|
+
if (!list) {
|
|
389
|
+
list = [];
|
|
390
|
+
this.discoveredModelListByUnit.set(unitId, list);
|
|
391
|
+
}
|
|
392
|
+
return list;
|
|
393
|
+
}
|
|
374
394
|
/**
|
|
375
395
|
* Enable or disable automatic reconnection
|
|
376
396
|
*/
|
|
@@ -468,6 +488,8 @@ export class SunspecModbusClient {
|
|
|
468
488
|
const instance = this.getInstance(unitId);
|
|
469
489
|
const models = this.getModelsMap(unitId);
|
|
470
490
|
models.clear();
|
|
491
|
+
const modelList = this.getModelList(unitId);
|
|
492
|
+
modelList.length = 0;
|
|
471
493
|
const maxAddress = 50000; // Safety limit
|
|
472
494
|
let currentAddress = 0;
|
|
473
495
|
console.log(`Starting Sunspec model discovery for unit ${unitId}...`);
|
|
@@ -509,7 +531,13 @@ export class SunspecModbusClient {
|
|
|
509
531
|
address: currentAddress,
|
|
510
532
|
length: modelLength
|
|
511
533
|
};
|
|
512
|
-
|
|
534
|
+
// Keep the FIRST (lowest-address) instance per id in the keyed directory so
|
|
535
|
+
// findModel() resolves to the device's own block. Record every instance —
|
|
536
|
+
// including repeats like a meter's Common (id 1) — in the ordered list.
|
|
537
|
+
if (!models.has(modelId)) {
|
|
538
|
+
models.set(modelId, model);
|
|
539
|
+
}
|
|
540
|
+
modelList.push(model);
|
|
513
541
|
// Per-model discovery step. The end-of-discovery summary below is the
|
|
514
542
|
// info-level outcome; the per-model walk is debug-only.
|
|
515
543
|
console.debug(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength} (unit ${unitId})`);
|
|
@@ -520,7 +548,9 @@ export class SunspecModbusClient {
|
|
|
520
548
|
catch (error) {
|
|
521
549
|
console.error(`Error during model discovery at address ${currentAddress} (unit ${unitId}): ${error}`);
|
|
522
550
|
}
|
|
523
|
-
|
|
551
|
+
// Report every instance in address order so repeated ids (e.g. two Common blocks, or
|
|
552
|
+
// multiple meters) are visible — models.size would hide them behind the unique keys.
|
|
553
|
+
console.log(`Discovery complete for unit ${unitId}. Found ${modelList.length} model instance(s): [${modelList.map(m => m.id).join(', ')}]`);
|
|
524
554
|
return models;
|
|
525
555
|
}
|
|
526
556
|
/**
|
|
@@ -529,6 +559,17 @@ export class SunspecModbusClient {
|
|
|
529
559
|
findModel(unitId, modelId) {
|
|
530
560
|
return this.discoveredModelsByUnit.get(unitId)?.get(modelId);
|
|
531
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Find every instance of a model id on a unit, in ascending address order.
|
|
564
|
+
*
|
|
565
|
+
* SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
|
|
566
|
+
* inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
|
|
567
|
+
* each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
|
|
568
|
+
* `findModel` only returns the first instance; use this to enumerate them all.
|
|
569
|
+
*/
|
|
570
|
+
findModels(unitId, modelId) {
|
|
571
|
+
return (this.discoveredModelListByUnit.get(unitId) ?? []).filter(m => m.id === modelId);
|
|
572
|
+
}
|
|
532
573
|
/**
|
|
533
574
|
* Check if a value is "unimplemented" according to Sunspec specification
|
|
534
575
|
* Returns true if the value represents an unimplemented/not applicable register
|
|
@@ -1829,7 +1870,16 @@ export class SunspecModbusClient {
|
|
|
1829
1870
|
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
1830
1871
|
* for this unit (see getPreferredEncoding). Never reads both.
|
|
1831
1872
|
*/
|
|
1832
|
-
async readMeterData(unitId) {
|
|
1873
|
+
async readMeterData(unitId, meterModel) {
|
|
1874
|
+
// When a specific meter instance is given (a unit with multiple embedded meters), read
|
|
1875
|
+
// exactly that block — don't let findModel pick the first/only directory entry.
|
|
1876
|
+
if (meterModel) {
|
|
1877
|
+
console.debug(`Using bound meter Model ${meterModel.id} at address ${meterModel.address} (length ${meterModel.length}) on unit ${unitId}`);
|
|
1878
|
+
const floatIds = [211, 212, 213, 214];
|
|
1879
|
+
return floatIds.includes(meterModel.id)
|
|
1880
|
+
? this.readFloatMeterData(unitId, meterModel, meterModel.id)
|
|
1881
|
+
: this.readIntSfMeterData(unitId, meterModel);
|
|
1882
|
+
}
|
|
1833
1883
|
const preferred = this.getPreferredEncoding(unitId);
|
|
1834
1884
|
const tryFloat = async () => {
|
|
1835
1885
|
const floatIds = [213, 214, 212, 211];
|
|
@@ -1987,7 +2037,12 @@ export class SunspecModbusClient {
|
|
|
1987
2037
|
}
|
|
1988
2038
|
}
|
|
1989
2039
|
/**
|
|
1990
|
-
* Read common block data (Model 1)
|
|
2040
|
+
* Read common block data (Model 1).
|
|
2041
|
+
*
|
|
2042
|
+
* Resolves to the unit's FIRST Common block — the leading device's own identity (the
|
|
2043
|
+
* inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
|
|
2044
|
+
* own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
|
|
2045
|
+
* its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
|
|
1991
2046
|
*/
|
|
1992
2047
|
async readCommonBlock(unitId) {
|
|
1993
2048
|
const model = this.findModel(unitId, SunspecModelId.Common);
|
|
@@ -1997,6 +2052,31 @@ export class SunspecModbusClient {
|
|
|
1997
2052
|
console.debug(`Common block model not found on unit ${unitId}`);
|
|
1998
2053
|
return null;
|
|
1999
2054
|
}
|
|
2055
|
+
return this.readCommonBlockAt(unitId, model);
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Read the Common (id 1) block that belongs to a specific device model — the Common instance
|
|
2059
|
+
* immediately preceding the model's address. SunSpec lays out each logical device as
|
|
2060
|
+
* `Common → device model(s)`, so a meter's identity is the Common block right before its meter
|
|
2061
|
+
* model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
|
|
2062
|
+
* well-formed map). Used to give each embedded meter its own appliance identity.
|
|
2063
|
+
*/
|
|
2064
|
+
async readCommonBlockForModel(unitId, deviceModel) {
|
|
2065
|
+
const preceding = this.findModels(unitId, SunspecModelId.Common)
|
|
2066
|
+
.filter(c => c.address <= deviceModel.address)
|
|
2067
|
+
.sort((a, b) => a.address - b.address)
|
|
2068
|
+
.pop();
|
|
2069
|
+
const model = preceding ?? this.findModel(unitId, SunspecModelId.Common);
|
|
2070
|
+
if (!model) {
|
|
2071
|
+
console.debug(`Common block model not found on unit ${unitId} for model ${deviceModel.id}@${deviceModel.address}`);
|
|
2072
|
+
return null;
|
|
2073
|
+
}
|
|
2074
|
+
return this.readCommonBlockAt(unitId, model);
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Decode a given Common (Model 1) block instance into identity fields.
|
|
2078
|
+
*/
|
|
2079
|
+
async readCommonBlockAt(unitId, model) {
|
|
2000
2080
|
console.debug(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
|
|
2001
2081
|
try {
|
|
2002
2082
|
// Read entire model block in a single Modbus call
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED