npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@enyo-energy/sunspec-sdk

v1.2.0

Published

enyo Energy Sunspec SDK

Readme

enyo SunSpec SDK

SunSpec Modbus client for reading data from solar inverters, batteries, meters, and other energy devices over Modbus TCP/RTU.

Table of Contents


Appliance Manager Integration

SunspecInverter, SunspecBattery, and SunspecMeter take an ApplianceManager from @enyo-energy/energy-app-sdk and call createOrUpdateAppliance with the device's networkDevice plus the common block's serialNumber (which may be empty for some firmware revisions).

Since @enyo-energy/energy-app-sdk 0.0.135 the default identifier strategy is SerialNumberStrategy. If a device's common block has no serial number, createOrUpdateAppliance throws MissingIdentifierError and the SDK logs the error and skips appliance creation.

Consumers whose devices may not expose a serial number should configure a fallback when initializing the ApplianceManager, e.g.:

import { ApplianceManager, FallbackIdentifierStrategy, SerialNumberStrategy, HostnameStrategy } from '@enyo-energy/energy-app-sdk';

const applianceManager = await ApplianceManager.initialize(energyApp, {
    identifierStrategy: new FallbackIdentifierStrategy([
        new SerialNumberStrategy(),
        new HostnameStrategy(),
    ]),
});

This SDK does not configure the strategy itself — it is the consumer app's responsibility.


Battery Feature Modes

SunspecBattery populates appliance.battery.features for every connected battery — the list the host's topology / UI reads to decide which control surfaces to render. The consumer chooses how that list is computed by passing a required featureMode argument to the SunspecBattery constructor. There is no default; the choice is explicit so a SunSpec device whose register surface lies (or whose responsiveness is unknown) cannot silently turn into an actionable battery from the host's point of view.

Three kinds are available, exposed via the SunspecBatteryFeatureModeKind enum:

| Kind | Calibration allowed? | What appears in appliance.battery.features | |---|---|---| | Disabled | No (configureCalibration throws) | Exactly the allowedFeatures array the consumer passes — register state is ignored. | | RegisterBased | No (configureCalibration throws) | Features the SunSpec registers expose. Filtered down to allowedFeatures if that's set. | | CalibrationBased | Yes (required for controllable features to ever appear) | Same as RegisterBased for read-only features. The four controllable features stay hidden until the calibration result store reports the appliance as calibrated. |

The four features the SDK treats as controllable (the ones calibration gates):

  • EnyoBatteryFeature.GridCharging
  • EnyoBatteryFeature.GridDischarging
  • EnyoBatteryFeature.ChargeLimitation
  • EnyoBatteryFeature.DischargeLimitation

Every other EnyoBatteryFeature is read-only from the SDK's perspective and is published as soon as the corresponding register is present (in RegisterBased and CalibrationBased) or as soon as the consumer lists it (in Disabled).

Disabled

Picks no registers, runs no calibration. Whatever the consumer lists in allowedFeatures becomes the published list; omit it (or pass []) to publish nothing.

Use when the host has out-of-band knowledge of the battery's capabilities — e.g. a curated commissioning database — and doesn't want the SDK inferring anything from registers it may not trust. Also useful for read-only deployments: omit allowedFeatures and the host will treat the battery as observation-only.

import { SunspecBattery, SunspecBatteryFeatureModeKind } from '@enyo-energy/sunspec-sdk';
import { EnyoBatteryFeature } from '@enyo-energy/energy-app-sdk';

const observationOnly = new SunspecBattery(
    app, name, networkDevice, client, applianceManager,
    { kind: SunspecBatteryFeatureModeKind.Disabled },
    // → appliance.battery.features = []
);

const curated = new SunspecBattery(
    app, name, networkDevice, client, applianceManager,
    {
        kind: SunspecBatteryFeatureModeKind.Disabled,
        allowedFeatures: [EnyoBatteryFeature.GridCharging],
    },
    // → appliance.battery.features = ['grid-charging'], regardless of what the registers say
);

Calling configureCalibration(...) on a Disabled battery throws.

RegisterBased

The SDK looks at the SunSpec Model 124 registers and publishes the features whose underlying registers are present:

  • chaGriSet register present → EnyoBatteryFeature.GridCharging
  • wChaMax register present → EnyoBatteryFeature.ChargeLimitation

Pass allowedFeatures to intersect the detected set with a whitelist — useful for hiding features the device exposes but the host doesn't want exercised. The allow-list never adds features that the registers don't expose; it is strictly a filter.

const trustRegisters = new SunspecBattery(
    app, name, networkDevice, client, applianceManager,
    { kind: SunspecBatteryFeatureModeKind.RegisterBased },
    // → appliance.battery.features reflects whichever Model 124 controls are exposed
);

const trustRegistersButHideGridCharging = new SunspecBattery(
    app, name, networkDevice, client, applianceManager,
    {
        kind: SunspecBatteryFeatureModeKind.RegisterBased,
        allowedFeatures: [EnyoBatteryFeature.ChargeLimitation],
    },
    // → publishes ChargeLimitation only (even if chaGriSet is present)
);

Calling configureCalibration(...) on a RegisterBased battery throws.

CalibrationBased

Same register-driven detection as RegisterBased for read-only features, but the four controllable features stay stripped from the published list until CalibrationResultStore.isCalibrated(applianceId) returns true for this battery. Once the verdict flips, the next readData() cycle republishes the full set.

configureCalibration(...) must be called after connect() — without it, controllable features stay hidden forever (safe-fallback behaviour).

const verified = new SunspecBattery(
    app, name, networkDevice, client, applianceManager,
    { kind: SunspecBatteryFeatureModeKind.CalibrationBased },
);
await verified.connect();
verified.configureCalibration({ resultStore });   // see "Battery Calibration" below
trigger.register(verified.getBatteryCalibrator()!);
// Until trigger.tick() runs a successful test charge, controllable features are absent.
// After it succeeds, they reappear within one readData() cycle.

See Battery Calibration for the full wiring of resultStore, trigger, and configureCalibration config.

Choosing a mode

| Situation | Pick | |---|---| | Battery is observation-only, or the host already knows its capabilities | Disabled | | Trust the SunSpec register surface; no responsiveness verification needed | RegisterBased | | Want a test charge to confirm the battery actually reacts before exposing controls | CalibrationBased |

The mode is fixed for the lifetime of the SunspecBattery instance. To switch modes, construct a new instance.


Battery Calibration

The SDK integrates @enyo-energy/appliance-calibration to verify that a battery actually responds to control commands before the host action-taker is allowed to send it any. The verification is a short test charge wrapped in a snapshot/restore so a hung or crashed run never leaves writable registers in a half-modified state, and the verdict (calibrated / not-supported / failed) is persisted per appliance so it survives restarts.

The SDK owns the SunSpec-specific pieces — the snapshot service, the vendor driver, the per-battery BatteryCalibrator. The consumer owns the trigger and the result store, so scheduling, multi-tenant storage namespacing, and command gating stay configurable.

Architecture

| Piece | Lives in | Who builds it | |---|---|---| | SunspecCalibrationStorage (adapter over EnergyAppStorage) | this SDK | consumer (factory: createSunspecCalibrationStorage(app)) | | CalibrationResultStore (persisted verdict map) | appliance-calibration | consumer — one shared instance across batteries | | SnapshotService<SunspecBatteryControls> | appliance-calibration | SDK (per-battery, inside SunspecBattery) | | SunspecBatteryCalibrationDriver | this SDK | SDK (per-battery, inside SunspecBattery) | | BatteryCalibrator<SunspecBatteryControls> | appliance-calibration | SDK — built by configureCalibration, exposed via getBatteryCalibrator | | CalibrationTrigger | appliance-calibration | consumer — drives runCalibration() on its own schedule |

Feature mode

Calibration is only relevant when SunspecBattery is constructed with SunspecBatteryFeatureModeKind.CalibrationBased — that's the mode under which the SDK gates controllable features on the calibration verdict. See Battery Feature Modes above for the full mode discussion; the rest of this section assumes the calibration-based mode has been selected.

Consumer wiring

import {
    SunspecBattery,
    SunspecBatteryFeatureModeKind,
    // re-exported from @enyo-energy/appliance-calibration so you don't need a second import:
    CalibrationResultStore,
    CalibrationTrigger,
    createSunspecCalibrationStorage,
} from '@enyo-energy/sunspec-sdk';

// 1. One storage adapter + one result store for the whole app — sharing the result store
//    across batteries keeps the persisted verdict map from being clobbered by per-instance writes.
const storage     = createSunspecCalibrationStorage(app);
const resultStore = new CalibrationResultStore(storage);
await resultStore.initialize();

// 2. One trigger that fans out across every registered calibrator.
const trigger = new CalibrationTrigger({ resultStore });

// 3. Per battery: build it with the desired feature mode, then connect,
//    then (only in calibration-based mode) configureCalibration + register.
for (const config of batteryConfigs) {
    const battery = new SunspecBattery(
        app, name, networkDevice, client, applianceManager,
        { kind: SunspecBatteryFeatureModeKind.CalibrationBased },
        // unitId, port, baseAddress, ...
    );
    await battery.connect();
    battery.configureCalibration({
        resultStore,
        // Optional — override any BatteryCalibratorConfig defaults (see "Tuning" below).
        config: { testPowerW: 300 },
    });
    const calibrator = battery.getBatteryCalibrator();
    if (calibrator) {
        trigger.register(calibrator);
    }
}

// 4. Drive the trigger on your own cadence. tick() runs at most one calibration per call
//    and is a no-op for any battery that already has a stored result.
client.useInterval().createInterval('30m', () => trigger.tick());

Gate commands on the calibration result

In whatever code emits battery commands, check the store synchronously before sending:

if (!resultStore.isCalibrated(applianceId)) {
    console.log(`Skipping ${applianceId}: not calibrated yet`);
    return;
}
// ...send the command

Per-feature probes

In calibration-based mode the SDK runs SunspecBatteryFeatureCalibrator (a subclass of the library's BatteryCalibrator) which exercises each of the four controllable features individually and records the outcomes in CalibrationResult.notes as JSON. Only features whose probes pass land in appliance.battery.features after calibration.

The session is one snapshot → four probes → one restore. Probes run in this order so the battery has energy to discharge with by the end:

| # | Probe | What it writes | Pass condition | |---|---|---|---| | 1 | GridCharging | chaGriSet=GRID, wChaMax=testPowerW, inWRte=100, storCtlMod=CHARGE | Battery power AND grid power both rise above responseThresholdW | | 2 | ChargeLimitation | same as above with inWRte=50 | Charge power settles within ±responseThresholdW of testPowerW × 0.5 | | 3 | DischargeLimitation | chaGriSet=PV, outWRte=50, storCtlMod=DISCHARGE | Discharge power settles within ±responseThresholdW of testPowerW × 0.5 | | 4 | GridDischarging | chaGriSet=PV, outWRte=100, storCtlMod=DISCHARGE | Battery discharges AND meter shows reverse flow above responseThresholdW |

Each probe runs a writeBatteryControls(...) then polls the driver's cached battery/grid power until either the predicate passes or responseTimeoutMs elapses. Between probes the SDK resets to a neutral state (storCtlMod=AUTO, chaGriSet=PV, inWRte/outWRte=100, wChaMax=baseline) so each probe starts cleanly. The final restoreFromSnapshot (run by the SnapshotService on stopCalibration) writes the original pre-calibration register set back regardless of outcome.

SoC preconditions

Probes refuse to run outside a useful SoC band and return not-supported instead — distinct from failed so the host can tell "device won't" apart from "we couldn't even try":

  • Charge probes (GridCharging, ChargeLimitation) need headroom: SoC ≥ 90% → not-supported.
  • Discharge probes (DischargeLimitation, GridDischarging) need energy: SoC ≤ 20% → not-supported.

Aggregate verdict

The library's CalibrationResult.state (one value per appliance) is computed from the four per-feature verdicts:

| Per-feature verdicts | CalibrationResult.state | |---|---| | At least one passed | calibrated | | All not-supported | not-supported | | Otherwise (mix of failed / not-supported) | failed |

isCalibrated(applianceId) therefore reads as "the SDK probed this device and at least something is controllable" — keep using it as the broad command-emission gate. The per-feature breakdown stored in notes is what resolveAdvertisedFeatures consults to decide which controllable features to publish; decodeFeatureResults(result.notes) returns the passing set and is exported for consumers that want to inspect it directly.

Other operational notes

  • Republishing cadence: the controllable features reappear on the next readData() cycle after the calibrator persists its result — not synchronously when calibration completes. With a typical 10s/30s/1m poll cadence the appliance metadata follows within one cycle.
  • Defence in depth: the feature filter is complementary to the isCalibrated command-gate, not a substitute. The consumer's action-taker should still check resultStore.isCalibrated(applianceId) synchronously before every command write — the filter keeps the controllable surface off the appliance until the device is verified, but a race window exists between calibration completing and the next readData cycle.
  • GridDischarge ambiguity: SunSpec Model 124 has no register to force battery output toward the grid versus toward local load. The probe drives the battery into discharge and then watches the meter for export. If the host's local load eats the discharge there will be no export and the probe records failed with a note explaining the likely cause. Hosts that know their grid topology can map this to "controllable, but couldn't be verified".

What configureCalibration does under the hood

For each battery it builds:

  • A SunspecBatteryCalibrationDriver that:
    • Snapshots writable controls via readBatteryControls().
    • Restores them via writeBatteryControls() (whitelist: storCtlMod, chaGriSet, wChaMax, inWRte, outWRte, minRsvPct).
    • Runs the test charge with the same 3-step sequence as handleStartGridCharge (enableGridCharging(true), wChaMax = powerW, setStorageMode(CHARGE)).
    • Reads battery power from a LatestValueCache fed by SunspecBattery.readData() each cycle.
    • Reads grid power from a LatestValueCache fed by a MeterValuesUpdateV1 data-bus subscription that's live for the driver's lifetime (so the grid signal works regardless of where the meter actually comes from).
  • A BatteryCalibrator<SunspecBatteryControls> wired to the driver, the per-battery SnapshotService, and the shared resultStore.

Battery disconnect() tears the driver subscription down.

What still happens through the data bus

The SDK also keeps listening for StartCalibrationV1 / StopCalibrationV1 data-bus messages so an external orchestrator (e.g. the action-taker) can begin and end a manual calibration session. Those handlers now drive the same SnapshotService<T> instance from @enyo-energy/appliance-calibration. While a manual session is active, any concurrent SetInverterFeedInLimitV1 / SetStorageChargeLimitV1 / etc. call records the field it touched into modifiedFields, and only those fields are written back on stop (or on the 5-minute auto-stop). This works exactly as before — the change is purely the implementation underneath.

Re-running a calibration

The trigger runs each battery once — appliances already marked calibrated, not-supported, or failed are skipped on every subsequent tick(). To force a re-run, drop the persisted result for that key:

await app.useStorage().remove('battery-calibration');   // wipes ALL results (the default key)

If you need targeted invalidation, snapshot the store via resultStore.snapshot(), mutate the map, and re-save the survivors. Periodic re-calibration is a host concern — build it on top of calibrator.runCalibration() directly rather than going through CalibrationTrigger.

Tuning

Override defaults via configureCalibration({ resultStore, config }):

| Key | Default | When to change | |---|---|---| | testPowerW | 500 W | Smaller batteries; PV-only sites where 500 W is visible. | | responseThresholdW | 200 W | Noisier meters; tighter tolerance for known-good devices. | | ackTimeoutMs | 10 s | Slow Modbus links. | | responseTimeoutMs | 20 s | Devices that take longer to ramp. | | pollIntervalMs | 2 s | Faster polling for short-window tests. |

See @enyo-energy/appliance-calibration's README for the full crash-safety contract and every result-store / snapshot-service knob.


Storage Schedule Control

SunspecBattery reacts to a single data-bus command for control: EnyoDataBusSetStorageScheduleV1. The message carries a mode (auto or schedule) and, when mode is schedule, a sorted relativeSchedule of {seconds, direction, powerW} entries. Subscription is automatic — each battery wires its own SunspecBatteryScheduleHandler (extending the SDK's StorageScheduleHandler from @enyo-energy/energy-app-sdk) during connect(). There is no consumer setup beyond connecting the battery.

What the SDK does on receipt

  1. Acks the message with Accepted immediately ("received & queued" — schedule entries play out over time).
  2. Snapshots the writable Model 124 registers (storCtlMod, chaGriSet, wChaMax, inWRte, outWRte) and persists them to EnergyAppStorage for restart-safe rollback.
  3. Activates the first entry immediately, then advances to subsequent entries as their seconds offsets elapse on a 1-second tick (driven by EnergyApp.useInterval()).
  4. Each Charge entry writes chaGriSet=GRID, inWRte = powerW / installedWChaMax × 100, storCtlMod=CHARGE.
  5. Each Discharge entry writes chaGriSet=PV, outWRte = powerW / installedWChaMax × 100, storCtlMod=DISCHARGE.
  6. Restores the snapshotted pre-schedule registers (storCtlMod, chaGriSet, wChaMax, inWRte, outWRte) only on mode: auto, disconnect(), or process-restart recovery — not when one mode: schedule is replaced by another. Schedule-to-schedule replacement keeps the device on the active register set and lets the new schedule's first entry take over directly, so consecutive schedules don't leak a storCtlMod=AUTO (0x3) write between them. The pre-schedule snapshot stays sticky across every replacement until one of the three terminal events fires.

Register write order

writeBatteryControls issues each register write sequentially in this order so the device never sees a stale-parameter window when the control mode changes: chaGriSet → wChaMax → inWRte → outWRte → minRsvPct → storCtlMod. Source pin and the limit/rate parameters land first; the control mode is written last so the device only "starts acting" once every governing value is fresh.

Power cap

Scheduled powerW is clamped to the device's installed wChaMax because the handler holds that value stable as the denominator for the percentage math throughout the schedule. To request more than the installed maximum, raise wChaMax out-of-band before sending the schedule.

Calibration coexistence

Each per-entry write is recorded into the calibration SnapshotService (when calibration is configured), so a calibration that starts mid-schedule still restores the right register set on stop / auto-stop.

Legacy messages

The earlier StartStorageGridChargeV1 / StopStorageGridChargeV1 / SetStorageChargeLimitV1 / SetStorageDischargeLimitV1 family is no longer handled by this SDK. Their type defs in @enyo-energy/energy-app-sdk are flagged @deprecated with pointers to SetStorageScheduleV1. Migrate any callers that still emit them.


Connection Failures and Logging

Two kinds of failure

The client distinguishes a transaction that was attempted and failed from one refused because the link is already known to be dead. On a half-open socket (the peer stops answering while the OS still reports ESTABLISHED) the first read times out and poisons the unit's socket; every read that lands afterwards is refused outright, without touching the wire, until the reconnect path recycles it.

| Situation | Rejection | Log level | | --- | --- | --- | | Read/write attempted, timed out | ModbusOperationTimeoutError | one WARN on the poison transition | | Read/write refused, unit already down | SunspecUnavailableError (reason: 'socket-poisoned') | none — debug only | | Socket recovered on reconnect | — | one INFO with poisonedForMs |

import { isSunspecUnavailableError } from '@enyo-energy/sunspec-sdk';

try {
    await client.readRegisterValue(unitId, 40100, 1, 'uint16');
} catch (error) {
    if (isSunspecUnavailableError(error)) {
        // Already reported by the SDK — `error.reason` says why, `error.sinceMs` says
        // how long it has been down. Log the first one of an outage at most.
        return null;
    }
    console.error(`read failed`, error);
}

Prefer isSunspecUnavailableError() over instanceof at package boundaries, where a duplicated copy of the SDK in the module graph would break class identity.

Both error classes implement toJSON(), so JSON.stringify(error) yields a usable { name, message, ... } instead of the {} a bare Error serialises to.

One event, one line

The SDK logs state transitions, not states. A unit that is unreachable for an hour produces one WARN entering the poisoned state and one INFO on recovery — not one ERROR per read per poll cycle. The same rule applies to reconnect attempts: the first failure of a run is logged at ERROR, every repeat while the peer stays down at debug, and a success re-arms the logging for the next outage.

Backoff

The tiered ConnectionRetryManager schedule is cleared by a healthy read cycle, not by a reconnect that merely reopened the socket. A peer that accepts TCP connections but never answers Modbus would otherwise restart the schedule at its shortest phase on every cycle and retry forever at poll rate. Because the schedule now survives such a cycle, the elapsed clock keeps growing and the intervals widen as configured (default: 10s → 30s → 1m → 5m → 15m, infinite retry). Tune with the retryConfig constructor argument on any device.


Transport Options

Everything in IModbusTransportConfig is forwarded verbatim to the host's useModbus().connect() — the SDK owns the behaviour, this client only passes the config it was constructed with. Omitted fields stay undefined so the host's own defaults apply.

const client = getOrCreateSunspecClient(energyApp, host, 502, {
    noParallelRequests: true,      // serialize frames (slow devices, e.g. Solax)
    waitBetweenMessagesMs: 40,     // spacing between frames; only with noParallelRequests
    pooledConnection: true,        // share one socket per device (energy-app-sdk >= 1.6.0)
});

pooledConnection takes the socket from the host's shared pool — keyed by host, port, unit ID and transport settings — instead of opening one owned exclusively by this client, and its disconnect() releases the connection back to the pool rather than necessarily closing it. It exists for devices that accept only a handful of simultaneous Modbus clients.

Note this SDK already refcounts one client per host:port (getOrCreateSunspecClient / releaseSunspecClient), so several devices behind one gateway share a connection either way. Pooling only adds something when code outside this client talks to the same device — a native driver in the same app, say. Leave it unset otherwise.

The config is applied when the client for a host:port is first created; a later caller passing a different config gets the cached instance and its config is ignored.


Vendor Function Codes and Connection-Scoped Permission

Some devices refuse control until a proprietary handshake has succeeded, over a function code outside the standard eight. The canonical case is a Huawei SUN2000 with a LUNA2000 and no EMMA: its control registers are ordinary holding registers, but every write comes back with exception 0x80 — "an operation is not allowed because of a permission authentication failure or permission expiration" — until an installer login has gone out over Huawei's function code 0x41.

Two methods cover that, both vendor-agnostic. Sub-commands, credential digests and keepalives belong to the consumer; the SDK contributes the socket discipline.

sendRawPdu(unitId, functionCode, payload)

Puts one PDU on the wire verbatim and returns the raw answer. It runs inside the unit's transaction chain, exactly like readRegisterBlock — so a handshake can never cross frames with a poll already in flight, which is what the single-frame SDongles behind these devices require.

const challenge = await client.sendRawPdu(unitId, 0x41, Buffer.from([0x24]));
const accepted = await client.sendRawPdu(unitId, 0x41, Buffer.concat([Buffer.from([0x25]), digest]));

A device exception arrives as data, on response.exceptionCode, not as a rejection — a permission probe expects to be refused, and that has to be distinguishable from a dead link. Only transport failures throw. Request payloads are never logged (they carry credential digests); the host logs the function code and the payload length.

Requires an energy manager running energy-app-sdk 1.9.0 or newer. On an older host the call throws with a message saying so, which is the signal to fall back to monitor-only behaviour rather than to retry.

onUnitReconnected(unitId, listener)

The permission a login buys belongs to the socket, not the device: when the connection is replaced it is gone, silently, and the next write simply comes back 0x80. This is the hook that lets a consumer re-authenticate before the next command instead of after a failed one.

const stop = client.onUnitReconnected(unitId, () => {
    authenticated = false;
    void loginAgain();
});

Both ways a connection can be replaced are covered — the transport recycling the socket underneath (dropped link, half-open socket, stale socket) and this client's own close-and-reopen (a poisoned unit, reconnectUnit(), reconnect()) — and one loss produces exactly one notification even when both fire. A deliberate disconnectUnit() is not a reconnect: connecting again afterwards is a new session, and the listener stays quiet.

Listeners are registered against the unit, not the socket, so they survive reconnects and keep firing. getConnectionStats().opens is not a substitute: it counts opens across the whole client, not per unit.


Appliance Metadata the Registers Don't Carry

Three appliance fields added in energy-app-sdk 1.6.0 have no SunSpec register behind them, so the SDK cannot derive them — it publishes what the consumer configures, and writes nothing at all when nothing is configured (spreading an explicit undefined would erase a value the host set through another path).

new SunspecInverter(energyApp, name, networkDevice, client, applianceManager,
    1, 502, 40000, [], retryConfig, appliance, useTls, SunspecPvPowerSource.Auto,
    { controlAllowed: true, installedPeakPowerWp: 12_400 });

| Field | Meaning | | --- | --- | | inverter.controlAllowed | Whether the energy manager may steer this inverter (e.g. curtail). Unset = "not configured", which is not false. | | inverter.installedPeakPowerWp | Module peak power from the Anlagenpass. Distinct from maxPvProductionW, which the SDK reads from Model 121 WMax (the AC side). | | battery.roundTripEfficiencyPercent | Measured AC-to-AC round-trip efficiency (0–100). |

Round-trip efficiency is a measured figure that drifts with cell age and temperature, so a value fixed at construction goes stale. Push a recomputed one at any time:

await battery.setRoundTripEfficiencyPercent(83.2);   // publishes immediately

Values outside 0–100 throw. Calling it before connect() is fine — the value is remembered and published with the rest of the metadata once the appliance exists. Leave it unset until enough cycles have been observed: a missing value means "not yet known", which is more useful to an optimizer than a datasheet number that overstates what the pack returns.


How Addressing Works

Base Address Detection

SunSpec devices expose a "SunS" identifier at a known base address. The SDK auto-detects:

| Mode | Base Address | "SunS" Location | First Model Address | |------|-------------|-------------------|---------------------| | 1-based (most common) | 40001 | 40001–40002 | 40003 | | 0-based | 40000 | 40000–40001 | 40002 | | Custom | user-provided | user-provided | base + 2 |

Model Discovery

Starting from the first model address, the SDK scans through contiguous model blocks:

┌──────────────┬──────────────┬─────────────────────────┐
│ Model ID     │ Model Length │ Model Data Registers    │
│ (1 register) │ (1 register) │ (Length registers)      │
├──────────────┼──────────────┼─────────────────────────┤
│ offset 0     │ offset 1     │ offset 2 ... Length+1   │
└──────────────┴──────────────┴─────────────────────────┘
         ▲                              ▲
    model.address               model.address + 2
  • model.address — Modbus address of the model ID register (start of the 2-register header)
  • model.length — Number of data registers (does NOT include the 2-register header)
  • Next model — Located at model.address + 2 + model.length
  • End marker — Model ID 0xFFFF (65535) signals end of model chain

Register Offsets

All register offsets in this document are relative to model.address:

  • Offset 0 = Model ID register
  • Offset 1 = Model Length register
  • Offset 2 = First data register (for most models)

Bulk Register Reading

Each model is read in a single Modbus call via readModelBlock(), which reads model.length + 2 contiguous registers starting at model.address. Individual fields are then extracted from the resulting buffer — no additional network round trips.


Data Types

| Type | Registers | Bytes | Description | |------|-----------|-------|-------------| | uint16 | 1 | 2 | Unsigned 16-bit integer | | int16 | 1 | 2 | Signed 16-bit integer (two's complement) | | uint32 | 2 | 4 | Unsigned 32-bit integer (big-endian) | | acc32 | 2 | 4 | 32-bit accumulator (unsigned, monotonically increasing) | | string | N | 2N | UTF-8 string, null-padded | | enum16 | 1 | 2 | Enumerated value (uint16) | | enum32 | 2 | 4 | Enumerated value (uint32) | | bitfield16 | 1 | 2 | Bit flags (uint16) | | bitfield32 | 2 | 4 | Bit flags (uint32) |

NOT_IMPLEMENTED Sentinel Values

Registers may return sentinel values indicating the field is not implemented:

| Type | Sentinel Value | Hex | |------|---------------|-----| | int16 | -32768 | 0x8000 | | uint16 | 65535 | 0xFFFF | | int32 | -2147483648 | 0x80000000 | | uint32 | 4294967295 | 0xFFFFFFFF | | enum16 / bitfield16 | 65535 | 0xFFFF | | enum32 / bitfield32 | 4294967295 | 0xFFFFFFFF | | acc16 | 0 | 0x0000 (NOT_ACCUMULATED) | | acc32 | 0 | 0x00000000 (NOT_ACCUMULATED) | | string | all NULLs | 0x0000 per register |


Scale Factors

Many numeric fields have an associated scale factor (SF) register. The final value is:

scaled_value = raw_value × 10^SF

Scale factor registers are int16 and typically contain values like -2, -1, 0, 1, 2.

For example: raw value 2345 with SF -1 → 2345 × 10⁻¹ = 234.5


Model 1 — Common Block

Method: readCommonBlock()

| Offset | Field | Type | Qty | Description | |--------|-------|------|-----|-------------| | 0 | ID | uint16 | 1 | Model ID (= 1) | | 1 | L | uint16 | 1 | Model length (typically 65–66) | | 2–17 | Mn | string | 16 | Manufacturer name | | 18–33 | Md | string | 16 | Model name | | 34–41 | Opt | string | 8 | Options | | 42–49 | Vr | string | 8 | Firmware version | | 50–65 | SN | string | 16 | Serial number | | 66 | DA | uint16 | 1 | Modbus device address |


Model 101 — Single-Phase Inverter

Method: readSinglePhaseInverterData()

Scale Factors

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 6 | A_SF | int16 | Current SF | | 10 | W_SF | int16 | Power SF | | 12 | Hz_SF | int16 | Frequency SF | | 13 | V_SF | int16 | Voltage SF | | 18 | DCA_SF | int16 | DC current SF | | 19 | DCV_SF | int16 | DC voltage SF | | 21 | DCW_SF | int16 | DC power SF |

Data Registers

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 2 | A | uint16 | A_SF | AC current (A) | | 7 | PhVphA | uint16 | V_SF | Phase voltage AN (V) | | 9 | W | int16 | W_SF | AC power (W) | | 11 | Hz | uint16 | Hz_SF | Frequency (Hz) | | 14 | DCA | uint16 | DCA_SF | DC current (A) | | 15 | DCV | uint16 | DCV_SF | DC voltage (V) | | 20 | DCW | int16 | DCW_SF | DC power (W) | | 24 | St | uint16 | — | Operating state (enum) |


Model 103 — Three-Phase Inverter

Method: readInverterData()

Scale Factors

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 6 | A_SF | int16 | Current SF | | 13 | V_SF | int16 | Voltage SF | | 15 | W_SF | int16 | Power SF | | 17 | Hz_SF | int16 | Frequency SF | | 19 | VA_SF | int16 | Apparent power SF | | 21 | VAr_SF | int16 | Reactive power SF | | 23 | PF_SF | int16 | Power factor SF | | 26 | WH_SF | int16 | Energy SF | | 28 | DCA_SF | int16 | DC current SF | | 29 | DCV_SF | int16 | DC voltage SF | | 31 | DCW_SF | int16 | DC power SF | | 36 | Tmp_SF | int16 | Temperature SF |

Data Registers

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 2 | A | uint16 | A_SF | Total AC current (A) | | 3 | AphA | uint16 | A_SF | Phase A current (A) | | 4 | AphB | uint16 | A_SF | Phase B current (A) | | 5 | AphC | uint16 | A_SF | Phase C current (A) | | 7 | PPVphAB | uint16 | V_SF | Line voltage AB (V) | | 8 | PPVphBC | uint16 | V_SF | Line voltage BC (V) | | 9 | PPVphCA | uint16 | V_SF | Line voltage CA (V) | | 10 | PhVphA | uint16 | V_SF | Phase voltage AN (V) | | 11 | PhVphB | uint16 | V_SF | Phase voltage BN (V) | | 12 | PhVphC | uint16 | V_SF | Phase voltage CN (V) | | 14 | W | int16 | W_SF | AC power (W) | | 16 | Hz | uint16 | Hz_SF | Frequency (Hz) | | 18 | VA | uint16 | VA_SF | Apparent power (VA) | | 20 | VAr | int16 | VAr_SF | Reactive power (VAr) | | 22 | PF | int16 | PF_SF | Power factor | | 24–25 | WH | acc32 | WH_SF | Cumulative energy (Wh) | | 27 | DCA | uint16 | DCA_SF | DC current (A) | | 28 | DCV | uint16 | DCV_SF | DC voltage (V) | | 30 | DCW | int16 | DCW_SF | DC power (W) | | 32 | TmpCab | int16 | Tmp_SF | Cabinet temperature (°C) | | 34 | TmpSnk | int16 | Tmp_SF | Heat sink temperature (°C) | | 35 | TmpTrns | int16 | Tmp_SF | Transformer temperature (°C) | | 36 | TmpOt | int16 | Tmp_SF | Other temperature (°C) | | 38 | St | uint16 | — | Operating state (enum) | | 39 | StVnd | uint16 | — | Vendor-specific state | | 40–41 | Evt1 | bitfield32 | — | Event flags 1 | | 42–43 | Evt2 | bitfield32 | — | Event flags 2 | | 44–45 | EvtVnd1 | bitfield32 | — | Vendor events 1 | | 46–47 | EvtVnd2 | bitfield32 | — | Vendor events 2 | | 48–49 | EvtVnd3 | bitfield32 | — | Vendor events 3 | | 50–51 | EvtVnd4 | bitfield32 | — | Vendor events 4 |


Model 121 — Inverter Settings

Method: readInverterSettings()

Scale Factors

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 22 | WMax_SF | int16 | Power SF | | 23 | VRef_SF | int16 | Voltage reference SF | | 24 | VRefOfs_SF | int16 | Voltage offset SF | | 25 | VMinMax_SF | int16 | Min/max voltage SF | | 26 | VAMax_SF | int16 | Apparent power SF | | 27 | VArMax_SF | int16 | Reactive power SF | | 28 | WGra_SF | int16 | Ramp rate SF | | 29 | PFMin_SF | int16 | Power factor SF | | 30 | MaxRmpRte_SF | int16 | Max ramp rate SF | | 31 | ECPNomHz_SF | int16 | Frequency SF |

Data Registers

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 2 | WMax | uint16 | WMax_SF | Maximum power output (W) | | 3 | VRef | uint16 | VRef_SF | Voltage at PCC (V) | | 4 | VRefOfs | int16 | VRefOfs_SF | Voltage offset from PCC (V) | | 5 | VMax | uint16 | VMinMax_SF | Maximum voltage (V) | | 6 | VMin | uint16 | VMinMax_SF | Minimum voltage (V) | | 7 | VAMax | uint16 | VAMax_SF | Maximum apparent power (VA) | | 8 | VArMaxQ1 | int16 | VArMax_SF | Max reactive power Q1 (VAr) | | 9 | VArMaxQ2 | int16 | VArMax_SF | Max reactive power Q2 (VAr) | | 10 | VArMaxQ3 | int16 | VArMax_SF | Max reactive power Q3 (VAr) | | 11 | VArMaxQ4 | int16 | VArMax_SF | Max reactive power Q4 (VAr) | | 12 | WGra | uint16 | WGra_SF | Default ramp rate (%WMax/sec) | | 13 | PFMinQ1 | int16 | PFMin_SF | Min power factor Q1 | | 14 | PFMinQ2 | int16 | PFMin_SF | Min power factor Q2 | | 15 | PFMinQ3 | int16 | PFMin_SF | Min power factor Q3 | | 16 | PFMinQ4 | int16 | PFMin_SF | Min power factor Q4 | | 17 | VArAct | enum16 | — | VAR action on mode change | | 18 | ClcTotVA | enum16 | — | Total VA calculation method | | 19 | MaxRmpRte | uint16 | MaxRmpRte_SF | Maximum ramp rate (%WGra) | | 20 | ECPNomHz | uint16 | ECPNomHz_SF | Nominal frequency (Hz) | | 21 | ConnPh | enum16 | — | Connected phase (single phase) |


Model 123 — Inverter Controls

Method: readInverterControls() / writeInverterControls()

Scale Factors

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 21 | WMaxLimPct_SF | int16 | Power limit SF | | 22 | OutPFSet_SF | int16 | Power factor SF | | 23 | VArPct_SF | int16 | VAR percentage SF |

Connection Control

| Offset | Field | Type | R/W | Description | |--------|-------|------|-----|-------------| | 0 | Conn_WinTms | uint16 | R | Time window for connect/disconnect (sec) | | 1 | Conn_RvrtTms | uint16 | R | Timeout for connect/disconnect (sec) | | 2 | Conn | enum16 | W | Connect/disconnect (0=DISCONNECT, 1=CONNECT) |

Power Limit Control

| Offset | Field | Type | R/W | SF | Description | |--------|-------|------|-----|----|-------------| | 3 | WMaxLimPct | uint16 | W | WMaxLimPct_SF | Power limit (%WMax) | | 4 | WMaxLimPct_WinTms | uint16 | R | — | Time window (sec) | | 5 | WMaxLimPct_RvrtTms | uint16 | R | — | Timeout (sec) | | 6 | WMaxLimPct_RmpTms | uint16 | R | — | Ramp time (sec) | | 7 | WMaxLim_Ena | enum16 | W | — | Enable (0=DISABLED, 1=ENABLED) |

Power Factor Control

| Offset | Field | Type | R/W | SF | Description | |--------|-------|------|-----|----|-------------| | 8 | OutPFSet | int16 | W | OutPFSet_SF | Power factor setpoint | | 9 | OutPFSet_WinTms | uint16 | R | — | Time window (sec) | | 10 | OutPFSet_RvrtTms | uint16 | R | — | Timeout (sec) | | 11 | OutPFSet_RmpTms | uint16 | R | — | Ramp time (sec) | | 12 | OutPFSet_Ena | enum16 | W | — | Enable (0=DISABLED, 1=ENABLED) |

Reactive Power Control

| Offset | Field | Type | R/W | SF | Description | |--------|-------|------|-----|----|-------------| | 13 | VArWMaxPct | int16 | R | VArPct_SF | Reactive power at max W (%) | | 14 | VArMaxPct | int16 | R | VArPct_SF | Max reactive power (%) | | 15 | VArAvalPct | int16 | R | VArPct_SF | Available reactive power (%) | | 16 | VArPct_WinTms | uint16 | R | — | Time window (sec) | | 17 | VArPct_RvrtTms | uint16 | R | — | Timeout (sec) | | 18 | VArPct_RmpTms | uint16 | R | — | Ramp time (sec) | | 19 | VArPct_Mod | enum16 | R | — | Mode (0=NONE, 1=WMAX, 2=VARMAX) | | 20 | VArPct_Ena | enum16 | R | — | Enable (0=DISABLED, 1=ENABLED) |


Model 124 — Battery Basic Storage Controls

Method: readBatteryData() / writeBatteryControls()

Scale Factors

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 18 | WChaMax_SF | int16 | Max charge power SF | | 19 | WChaDisChaGra_SF | int16 | Charge/discharge gradient SF | | 20 | VAChaMax_SF | int16 | Max charging VA SF | | 21 | MinRsvPct_SF | int16 | Min reserve percentage SF | | 22 | ChaState_SF | int16 | Charge state SF | | 23 | StorAval_SF | int16 | Available storage SF | | 24 | InBatV_SF | int16 | Battery voltage SF | | 25 | InOutWRte_SF | int16 | Charge/discharge rate SF |

Data Registers

| Offset | Field | Type | R/W | SF | Description | |--------|-------|------|-----|----|-------------| | 2 | WChaMax | uint16 | W | WChaMax_SF | Max charge power (W) | | 3 | WChaGra | uint16 | R | WChaDisChaGra_SF | Charge rate gradient (%/sec) | | 4 | WDisChaGra | uint16 | R | WChaDisChaGra_SF | Discharge rate gradient (%/sec) | | 5 | StorCtlMod | bitfield16 | W | — | Storage control mode (see enum) | | 6 | VAChaMax | uint16 | R | VAChaMax_SF | Max charging VA | | 7 | MinRsvPct | uint16 | W | MinRsvPct_SF | Min reserve (%) | | 8 | ChaState | uint16 | R | ChaState_SF | State of charge (%AhrRtg) | | 9 | StorAval | uint16 | R | StorAval_SF | Available storage (AH) | | 10 | InBatV | uint16 | R | InBatV_SF | Battery voltage (V) | | 11 | ChaSt | enum16 | R | — | Charge status (see enum) | | 12 | OutWRte | int16 | W | InOutWRte_SF | Discharge rate (%WDisChaMax) | | 13 | InWRte | int16 | W | InOutWRte_SF | Charge rate (%WChaMax) | | 14 | InOutWRteWinTms | uint16 | R | — | Rate change time window (sec) | | 15 | InOutWRteRvrtTms | uint16 | R | — | Rate change timeout (sec) | | 16 | InOutWRteRmpTms | uint16 | R | — | Rate change ramp time (sec) | | 17 | ChaGriSet | enum16 | W | — | Charge source (0=PV, 1=GRID) |


Model 160 — MPPT (Multiple Maximum Power Point Tracker)

Method: readAllMPPTData() / readMPPTData(moduleId)

Fixed Block (Scale Factors)

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 2 | DCA_SF | int16 | DC current SF | | 3 | DCV_SF | int16 | DC voltage SF | | 4 | DCW_SF | int16 | DC power SF | | 5 | DCWH_SF | int16 | DC energy SF | | 8 | N | uint16 | Number of MPPT modules |

Repeating Module Block (20 registers each)

Module 1 starts at offset 10, Module 2 at offset 30, Module N at offset 10 + (N-1) × 20.

| Relative Offset | Field | Type | SF | Description | |-----------------|-------|------|----|-------------| | 0 | ID | uint16 | — | Module ID | | 1–8 | IDStr | string (8 regs) | — | Module string identifier | | 9 | DCA | uint16 | DCA_SF | DC current (A) | | 10 | DCV | uint16 | DCV_SF | DC voltage (V) | | 11 | DCW | uint16 | DCW_SF | DC power (W) | | 12–13 | DCWH | acc32 | DCWH_SF | Cumulative DC energy (Wh) | | 14–15 | Tms | uint32 | — | Timestamp (seconds since epoch) | | 16 | Tmp | int16 | fixed (−1) | Module temperature (°C) | | 17 | DCSt | enum16 | — | Operating state |


Models 201/203/204 — Meters

Method: readMeterData()

The SDK reads Model 203 (3-Phase Delta), 204 (3-Phase Wye), or 201 (Single-Phase) — trying in that order. All three share the same register offsets for the fields we read:

Scale Factors

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 17 | Hz_SF | int16 | Frequency SF | | 22 | W_SF | int16 | Power SF | | 54 | TotWh_SF | int16 | Energy SF |

Data Registers

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 16 | Hz | uint16 | Hz_SF | Frequency (Hz) | | 18 | W | int16 | W_SF | Total real power (W) | | 38–39 | TotWhExp | acc32 | TotWh_SF | Total exported energy (Wh) | | 46–47 | TotWhImp | acc32 | TotWh_SF | Total imported energy (Wh) |


Model 802 — Battery Base

Method: readBatteryBaseData()

Scale Factors

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 52 | AHRtg_SF | int16 | Amp-hour rating SF | | 53 | WHRtg_SF | int16 | Watt-hour rating SF | | 54 | WChaDisChaMax_SF | int16 | Charge/discharge max rate SF | | 55 | DisChaRte_SF | int16 | Self-discharge rate SF | | 56 | SoC_SF | int16 | State of charge SF | | 57 | DoD_SF | int16 | Depth of discharge SF | | 58 | SoH_SF | int16 | State of health SF | | 59 | V_SF | int16 | Voltage SF | | 60 | CellV_SF | int16 | Cell voltage SF | | 61 | A_SF | int16 | Current SF | | 62 | AMax_SF | int16 | Max current SF | | 63 | W_SF | int16 | Power SF |

Nameplate (Offsets 2–5)

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 2 | AHRtg | uint16 | AHRtg_SF | Nameplate charge capacity (AH) | | 3 | WHRtg | uint16 | WHRtg_SF | Nameplate energy capacity (WH) | | 4 | WChaRteMax | uint16 | WChaDisChaMax_SF | Max charge rate (W) | | 5 | WDisChaRteMax | uint16 | WChaDisChaMax_SF | Max discharge rate (W) |

State of Charge / Health (Offsets 6–13)

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 6 | DisChaRte | uint16 | DisChaRte_SF | Self-discharge rate (%) | | 7 | SoCMax | uint16 | SoC_SF | Max state of charge (%) | | 8 | SoCMin | uint16 | SoC_SF | Min state of charge (%) | | 9 | SoCRsvMax | uint16 | SoC_SF | Max reserve SOC (%) | | 10 | SoCRsvMin | uint16 | SoC_SF | Min reserve SOC (%) | | 11 | SoC | uint16 | SoC_SF | Current state of charge (%) | | 12 | DoD | uint16 | DoD_SF | Depth of discharge (%) | | 13 | SoH | uint16 | SoH_SF | State of health (%) |

Status (Offsets 14–22)

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 14–15 | NCyc | uint32 | Cycle count | | 16 | ChaSt | enum16 | Charge status (see enum) | | 17 | LocRemCtl | enum16 | Local/remote control mode | | 21 | Typ | enum16 | Battery type (see enum) | | 22 | State | enum16 | Battery bank state (see enum) |

Events (Offsets 26–33)

| Offset | Field | Type | Description | |--------|-------|------|-------------| | 26–27 | Evt1 | bitfield32 | Event bitfield 1 | | 28–29 | Evt2 | bitfield32 | Event bitfield 2 | | 30–31 | EvtVnd1 | bitfield32 | Vendor event bitfield 1 | | 32–33 | EvtVnd2 | bitfield32 | Vendor event bitfield 2 |

Voltage (Offsets 34–43)

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 34 | V | uint16 | V_SF | Battery voltage (V) | | 35 | VMax | uint16 | V_SF | Max battery voltage (V) | | 36 | VMin | uint16 | V_SF | Min battery voltage (V) | | 37 | CellVMax | uint16 | CellV_SF | Max cell voltage (V) | | 38 | CellVMaxStr | uint16 | — | String containing max cell | | 39 | CellVMaxMod | uint16 | — | Module containing max cell | | 40 | CellVMin | uint16 | CellV_SF | Min cell voltage (V) | | 41 | CellVMinStr | uint16 | — | String containing min cell | | 42 | CellVMinMod | uint16 | — | Module containing min cell | | 43 | CellVAvg | uint16 | CellV_SF | Average cell voltage (V) |

Current (Offsets 44–46)

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 44 | A | int16 | A_SF | Battery current (A) | | 45 | AChaMax | uint16 | AMax_SF | Max charge current (A) | | 46 | ADisChaMax | uint16 | AMax_SF | Max discharge current (A) |

Power / Control (Offsets 47–51)

| Offset | Field | Type | SF | Description | |--------|-------|------|----|-------------| | 47 | W | int16 | W_SF | Battery power (W, +charge/−discharge) | | 48 | ReqInvState | enum16 | — | Requested inverter state | | 49 | ReqW | int16 | W_SF | Requested power (W) | | 50 | SetOp | enum16 | — | Set operation | | 51 | SetInvState | enum16 | — | Set inverter state |


Writable Registers

Model 123 — Inverter Controls (writeInverterControls)

| Register Offset | Field | Scale Factor Offset | Description | |----------------|-------|---------------------|-------------| | 2 | Conn | — | Connect/disconnect | | 3 | WMaxLimPct | 21 (WMaxLimPct_SF) | Power limit percentage | | 7 | WMaxLim_Ena | — | Throttle enable/disable | | 8 | OutPFSet | 22 (OutPFSet_SF) | Power factor setpoint | | 12 | OutPFSet_Ena | — | PF control enable/disable |

Model 124 — Battery Controls (writeBatteryControls)

| Register Offset | Field | Scale Factor Offset | Description | |----------------|-------|---------------------|-------------| | 2 | WChaMax | 18 (WChaMax_SF) | Max charge power (W) | | 5 | StorCtlMod | — | Storage control mode bits | | 7 | MinRsvPct | 21 (MinRsvPct_SF) | Min reserve percentage | | 12 | OutWRte | 25 (InOutWRte_SF) | Discharge rate (%) | | 13 | InWRte | 25 (InOutWRte_SF) | Charge rate (%) | | 17 | ChaGriSet | — | Charge source (PV/Grid) |

Helper: setFeedInLimit(limitW)

Reads WMax from Model 121, computes (limitW / WMax) × 100, then writes WMaxLimPct + enables WMaxLim_Ena via Model 123. Pass null to disable the limit.

Helper: setStorageMode(mode) / enableGridCharging(enable)

Convenience wrappers around writeBatteryControls() for common battery operations.


Enum Reference

SunspecBatteryChargeState (Model 124 offset 11 / Model 802 offset 16)

| Value | Name | Description | |-------|------|-------------| | 1 | OFF | Battery off | | 2 | EMPTY | Battery empty | | 3 | DISCHARGING | Discharging | | 4 | CHARGING | Charging | | 5 | FULL | Fully charged | | 6 | HOLDING | Holding charge | | 7 | TESTING | Under test |

SunspecStorageControlMode (Model 124 offset 5 — bitfield)

| Bit | Value | Name | Description | |-----|-------|------|-------------| | 0 | 0x0001 | CHARGE | Enable charging | | 1 | 0x0002 | DISCHARGE | Enable discharging |

Derived modes via setStorageMode():

| Mode | Bits | Description | |------|------|-------------| | CHARGE | 0x01 | Charge only | | DISCHARGE | 0x02 | Discharge only | | HOLDING | 0x00 | Neither charge nor discharge | | AUTO | 0x03 | Both charge and discharge |

SunspecChargeSource (Model 124 offset 17)

| Value | Name | Description | |-------|------|-------------| | 0 | PV | Charge from PV only | | 1 | GRID | Charge from grid |

SunspecEnableControl (Model 123 — various enable fields)

| Value | Name | |-------|------| | 0 | DISABLED | | 1 | ENABLED |

SunspecBatteryType (Model 802 offset 21)

| Value | Name | |-------|------| | 0 | NOT_APPLICABLE_UNKNOWN | | 1 | LEAD_ACID | | 2 | NICKEL_METAL_HYDRIDE | | 3 | NICKEL_CADMIUM | | 4 | LITHIUM_ION | | 5 | CARBON_ZINC | | 6 | ZINC_CHLORIDE | | 7 | ALKALINE | | 8 | RECHARGEABLE_ALKALINE | | 9 | SODIUM_SULFUR | | 10 | FLOW | | 11 | SUPER_CAPACITOR | | 99 | OTHER |

SunspecBatteryBankState (Model 802 offset 22)

| Value | Name | |-------|------| | 1 | DISCONNECTED | | 2 | INITIALIZING | | 3 | CONNECTED | | 4 | STANDBY | | 5 | SOC_PROTECTION | | 6 | SUSPENDING | | 99 | FAULT |