Skip to content

fix(gateway): keep configured inverters when the hub cannot read them (#5471) - #5475

Merged
springfall2008 merged 10 commits into
mainfrom
fix/gateway-serial-list-authoritative-5471
Oct 11, 2026
Merged

springfall2008 merged 10 commits into
mainfrom
fix/gateway-serial-list-authoritative-5471

Conversation

@springfall2008

@springfall2008 springfall2008 commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Fixes #5471. Fixes #5478.

The problem

On a Hub site, a battery inverter the Hub could not read when Predbat started was left out of auto-config and stayed out until the next restart. Predbat planned and controlled on fewer inverters than the site has and reported no error (one site ran on half its capacity for about 37 hours).

automatic_config() only took inverters that were primary with battery data present, then recorded every serial in the status message as configured. _needs_reconfigure() only re-runs for a serial it has not seen, so the dropped inverter never triggered a re-run.

What this changes

With gateway_inverter_serial set: the list decides the site

  • Discovery records. For each listed serial the gateway records what the Hub reports it to be: type, whether it has a battery, battery size and model. Records are saved through the Storage component (gateway / discovery_<device id>, JSON), loaded in run() before the MQTT listener starts, and saved when one changes. A save the storage refuses stays pending and is retried after five minutes.
  • Not updated while offline. A record is only taken from an inverter the Hub is reading, so an offline inverter never overwrites what is known about it. "Has a battery" only ever turns on, because an early status can carry an inverter's other readings before its battery ones.
  • A discovered inverter keeps its slot. A listed inverter with a record is configured even when the Hub is not reading it or leaves it out of the status message, including straight after a restart. num_inverters no longer shrinks.
  • Not yet discovered. A listed inverter the Hub has never reported readings for is left out, with a warning, and configured on the first status that carries its readings.
  • A status naming no inverter. With at least one record held, a status with an empty inverter list is every discovered inverter unread, not nothing to do: they are bound at start-up, and once running their powers go to zero and the error is raised. It does not count towards the Hub being alive. With no record yet (or no serial list) it is ignored, as before.
  • A changed list wins. Stored records for serials no longer in the list are dropped at load and not used.
  • PV-only inverters. A listed inverter with readings but no battery gets no slot and is never a write target. Its pv_power and pv_today entities are appended after the bound inverters' so its generation is counted (the same pattern solis.py uses).
  • Errors. A configured inverter the Hub is not reading logs an Error when that starts and marks each run as having had errors until it returns.

The coordinator rule is unchanged: a Plant EMS, or a Gateway with two or more battery AIOs, is still the single control target, so a list naming a Gateway and its AIOs behaves as it does today.

With no serial list

Selection uses the same primary-and-battery filter as before: auto-config uses the inverters it finds, and inverters can come and go. The discovery records are still stored for every inverter the Hub reads, but only for their last known battery size, rates and export limit. An inverter that is in the records but not in the status, or not being read, is not held on to. One change to selection: an inverter that was unusable at start-up is now registered on the first status in which it becomes usable, without a restart. It does not re-run auto-config on every status while the inverter stays unusable, and removals are still ignored.

All gateway sites: stale, not zero

An inverter with no readings at all keeps the state of charge, battery size and settings it last reported, as an inverter behind an offline cloud API does. Previously the empty message was published, which read as a 0% battery. Its power readings (battery, PV, grid, load, inverter) do go to zero: a power is a rate, and a stale one would read as an inverter still discharging hours after it dropped out. An inverter with no entities yet is published once with defaults (and its stored battery size, if known) so the core has something to read.

Entities are also published again straight after a successful auto-config, before the API is declared started. An inverter that is only published once bound (one the Hub does not flag primary, or a PV-only one) would otherwise have no entities until the next status message.

Keeping the site's readings sound while an inverter is unread

  • Shared CT. With gateway_shared_ct the grid and load power and today's import, export and load are read from the first inverter's entities only. While that inverter is unread they are filled from the next inverter on the clamp, which reports the same readings.
  • gateway_integrate_power. Integration still stops while an inverter with a slot is missing or disconnected, as before, but midnight now resets the day's figures in that state too. A PV-only inverter's generation is added to the load. Each inverter's generation is tracked by serial, so auto-config re-running and changing the slots (a PV-only inverter gaining a battery, an inverter becoming usable later) neither loses it nor mixes it with another inverter's. After auto-config runs, the status it ran on is taken as the baseline for the new slots, so an inverter is counted from the status it joined on.
  • Coordinator sites. An unread battery inverter behind a Gateway or Plant EMS is reported as an error, not only an inverter with a slot of its own.
  • Stored records. A stored record that cannot be read is dropped with a warning instead of stopping auto-config.
  • Battery size. A slot with no battery size known is left unbound for soc_max (automatic sizing), as on main, and is re-bound on the first status that reports a size.

Ratings the Hub stops reporting (#5478)

A status with an inverter's ratings unpopulated, seen for a few minutes after a Hub restart, was published as a 6000 W battery and inverter rate with no export limit. A 3 kW inverter was planned on, and written to, at 6 kW.

  • Last good value. A zero battery rate, inverter rate or export limit now keeps the last real value the Hub reported for that inverter. The two rates and the export limit are also kept in the stored discovery record, with or without a serial list, so they survive a restart. A battery size the Hub leaves out is published from the record in the same way.
  • Reserve. A reserve the Hub leaves out (reads as 0%) also keeps the last real value, so Predbat no longer rewrites it for nothing. A 0% that Predbat itself writes is taken as real, and the next 0 from the Hub is then published as 0. That only applies once the write has gone out: if the publish fails the last value is kept, and if the Hub refuses the write it is put back.
  • Never reported. An inverter that has never reported a maximum rate is still published with the 6000 W default, because the plan needs a figure, but the gateway no longer writes it directly: a charge or discharge rate write above zero for that inverter is not sent, and nor is its set_rate_cap. Predbat reads the unchanged rate back and tries again, so the write goes through once the rating arrives. A rate of zero is always sent, so a charge or discharge can still be stopped. A Gateway or Plant EMS is not held. The execution plan still carries the fleet rate from the core, which includes the default: that relies on the Hub, which cannot write to an inverter it has not discovered and clamps the plan power to the real rating of one it has.

Behaviour changes to check in review

  • A Hub that never reports rate_max_w for a battery inverter can no longer have its charge or discharge rate raised above zero. Before, such a site ran on the 6000 W default. If any firmware in the field never reports the rating, this needs a decision before merge.

  • With a serial list, the Hub's primary flag no longer decides whether a listed battery inverter is used.

  • "Discovered" means the Hub has reported readings for the inverter at least once, not merely listed its serial. Readings are needed to tell a battery inverter from a PV-only one.

  • A PV-only inverter contributes PV only. Its load, grid, import and export readings are not used.

  • The error is a log line plus the run's error flag. The text of predbat.status is not changed.

Not covered

  • The other control values (target SoC, charge and discharge rate, enables) are still published as the Hub sends them.
  • A reserve of 0% set on the inverter by something other than Predbat reads as the last non-zero reserve, because the Hub's 0 cannot be told apart from "not reported".
  • The last good reserve is held in memory only, so it is lost if Predbat restarts.
  • Without a serial list, records for inverters that have gone for good stay in the stored file. They are not used for anything unless that serial is reported again.
  • An inverter whose first status carries some readings but no battery block is configured as PV only until its battery readings arrive, when auto-config re-runs and gives it a slot.
  • A listed inverter the Hub cannot read on the very first run with this change has no record yet, so it is left out, with a warning, until the Hub reads it. That is deliberate: without readings there is no way to tell a battery inverter from a PV-only one, and a PV-only inverter must not be given a slot.
  • What the Hub actually sends for an inverter it refuses with not_polled is not known. If it sends some readings with an empty battery block, the battery values are still published as zero.
  • Load and save are tested by calling _load_discovery() / _save_discovery() with a fake storage, not through run() or the real Storage backend.

Testing

  • 47 new tests in TestGatewayUnitControlBinding and TestIntegratePower (test_gateway.py). The first 18 cover: never-read inverter left out then bound; stored inverter bound when unread or absent at start-up; changed list beating stored records; records saved, updated and left alone while offline; last known values stored and used without a list, while inverters still come and go; offline error raised and cleared; stale entity values; PV-only configuration and its later promotion to a battery inverter; no re-run of auto-config per status; a failed save retried after a wait; a newly bound inverter published on the same status. The rest cover the section above: shared-CT readings carried by the next inverter, powers zeroed, coordinator-site errors, unreadable stored records, PV-only entities after a restart, battery size re-binding, the midnight reset and PV-only generation under gateway_integrate_power. The last ten cover Gateway MQTT: a missing max battery rate is replaced with a hard-coded 6000 W, which is then written to the inverter #5478: the reserve kept and a written 0% honoured, a stored export limit (including a limit of zero) after a restart, ratings kept when the Hub stops reporting them, a zero export limit kept, defaults when never reported, stored ratings after a restart, rate writes and rate caps held on an assumed rating, and coordinators not held. Five more cover the second review round: PV tracked across a promotion to a slot and for an inverter that joins later, and a status naming no inverter at start-up, once running, and with no list or record. Four more cover the third round: the integration rebased after auto-config (directly and through _process_telemetry()), and the reserve kept when a write of 0% fails or is refused.
  • The existing PV-only deferral test (test_incomplete_battery_telemetry_waits_then_excludes_pv_only_unit) is unchanged and passes.
  • ./run_all --test gateway passes, ./run_all --quick passes, and pre-commit passes on both changed files.

🤖 Generated with Claude Code

…#5471)

An inverter the hub could not read when Predbat started was left out of
auto-config and stayed out until the next restart, because every serial in
the status message was recorded as configured and so never counted as new.

With gateway_inverter_serial set, the list now decides the site:
- what the hub reports each listed inverter to be is kept in a discovery
  record and saved through the Storage component, updated only while the
  hub is reading that inverter;
- a listed inverter with a record keeps its slot when the hub cannot read
  it, including across a restart; one never read yet is left out with a
  warning until it is;
- records for serials no longer in the list are not used;
- a listed inverter with no battery is configured as PV only: no slot, no
  writes, its generation added to pv_power and pv_today;
- a configured inverter the hub is not reading logs an error and marks the
  run as having had errors until it returns.

With no serial list nothing is stored and selection is as before, except
that an inverter unusable at start-up is now registered when it becomes
usable.

An inverter with no readings keeps the entity values it last reported
instead of being published as zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 16:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Initial unread inverters can still be omitted, save failures lose durability, and first-frame entities may be missing.

3 open findings
What changed in this PR

Adds durable Gateway inverter discovery so configured inverters remain bound during telemetry outages.

Changes:

  • Persists inverter discovery metadata and retains stale entity values.
  • Supports PV-only listed inverters and dynamic reconfiguration.
  • Adds extensive Gateway regression tests.
File Description
apps/​predbat/​gateway.py Implements discovery persistence, binding, offline handling, and PV-only support.
apps/​predbat/​tests/​test_gateway.py Adds coverage for discovery, outages, persistence, and reconfiguration.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/gateway.py
Comment thread apps/predbat/gateway.py
Comment thread apps/predbat/gateway.py Outdated
CI and others added 2 commits October 10, 2026 17:43
…nverters at once

Review feedback on #5475:
- Storage.save() reports a failed write by returning False, which was
  ignored after the dirty flag had been cleared, so one failed write lost
  the record. The save now stays pending and is retried after five minutes.
- An inverter that is only published once bound (one the hub does not flag
  primary, or a PV-only one) had no entities on the status that bound it,
  because entities were injected before auto-config ran. They are now
  injected again after a successful auto-config, before the API is
  declared started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… unread

Follow-ups from review of #5475, all consequences of keeping an inverter
configured while the hub cannot read it:

- shared CT: grid and load power and today's import, export and load live
  on the first inverter's entities; while that one is unread they are
  filled from the next inverter on the clamp;
- an unread inverter's power readings go to zero; its state of charge,
  size and settings stay as last reported;
- an unread battery inverter behind a Gateway or Plant EMS is reported as
  an error, not only one with a slot of its own;
- a stored discovery record that cannot be read is dropped with a warning
  instead of stopping auto-config on every status;
- gateway_integrate_power: midnight still resets the day while an inverter
  is away, and a PV-only inverter's generation counts towards the load;
- a stored PV-only inverter missing from the status gets its PV entities;
- a slot with no battery size known is left unbound for soc_max again, and
  is re-bound when the hub first reports a size;
- after auto-config only the units the first pass skipped are published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Empty telemetry, partial capacity telemetry, and PV-only promotion still cause incorrect configuration or accounting.

3 open findings
3 resolved since last review

🧠 Review effort: Balanced

Comment thread apps/predbat/gateway.py
Comment thread apps/predbat/gateway.py Outdated
Comment thread apps/predbat/gateway.py Outdated
… them (#5478)

A status with an inverter's ratings unpopulated, seen for a few minutes
after a hub restart, was published as a 6000 W battery and inverter rate
with no export limit. A 3 kW inverter was planned on, and written to, at
6 kW.

A zero battery rate, inverter rate or export limit now keeps the last real
value the hub reported for that inverter. With gateway_inverter_serial set
the two rates are also kept in the stored discovery record, so they survive
a restart. An inverter that has never reported a rating still gets the
6000 W default and no export limit, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI and others added 3 commits October 10, 2026 20:12
…imum rate (#5478)

Until an inverter has reported its maximum rate, the rate Predbat works to
is the 6000 W default. The plan still needs a figure, but it is only an
assumption, so the gateway no longer acts on it: a charge or discharge rate
write above zero for that inverter is not sent, and nor is its set_rate_cap.
Predbat reads the unchanged rate back and tries again, so the write goes
through once the rating is known.

A rate of zero is always sent, so a charge or discharge can still be
stopped. A Gateway or Plant EMS is not held: it has no battery rating of
its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5478)

- A reserve the hub leaves out keeps the last real value it reported, like
  the ratings. Read as 0% it was rewritten by Predbat for nothing. A 0%
  that Predbat itself writes is real, so the next 0 from the hub is then
  published as 0.
- The export limit is added to the stored discovery record, so with
  gateway_inverter_serial set the last good limit survives a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no gateway_inverter_serial every inverter the hub reads now has its
record stored as well, and its last known battery size, rates and export
limit are used when the hub leaves them out, including after a restart.

What the list changes is unchanged: without one, auto-config uses the
inverters it finds, and an inverter that is in the records but not in the
status, or not being read, is not held on to.

A battery size the hub leaves out is now published from the record in the
same way as the ratings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI and others added 2 commits October 11, 2026 18:58
…verter

The integrated PV tracks were a positional list built when integration began. When
auto-config re-ran and changed the slots (a PV-only inverter gaining a battery, or an
inverter becoming usable later) the tracks were paired with the wrong counters or
dropped. They are now keyed by serial, for slot and PV-only inverters alike.

With gateway_inverter_serial set and a discovery record held, a status with an empty
inverter list is no longer ignored: the stored inverters are bound at start-up, and
once running their powers are zeroed and the unread error is raised. It does not
count towards the hub being alive. Without a list or a record it is ignored as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread apps/predbat/gateway.py
Comment thread apps/predbat/gateway.py
Comment thread apps/predbat/gateway.py Outdated
…reserve until a write of 0 is applied

Integration runs before auto-config, so an inverter that gained its slot on a status
had its PV baseline taken from the next one, losing that interval. The status is now
taken as the baseline for the new slots straight after auto-config, with nothing added.

The last good reserve was forgotten before the write of 0% went out. It is now forgotten
only once the write has been sent, and put back if the hub refuses it, so a refused or
failed write no longer reads back as a reserve of 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit d6985b6 into main Oct 11, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/gateway-serial-list-authoritative-5471 branch October 11, 2026 18:40
springfall2008 added a commit that referenced this pull request Oct 12, 2026
…ates); mark GH#5471 fixed in PR #5475 (#5495)

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants