Skip to content

feat(gateway): independent measured Hub display and visibility - #5529

Open
mgazza wants to merge 15 commits into
mainfrom
feat/hub-display-integration
Open

mgazza wants to merge 15 commits into
mainfrom
feat/hub-display-integration

Conversation

@mgazza

@mgazza mgazza commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

A paired Hub can show fresh GivEnergy Cloud measurements while Cloud retains control. Display-only transport cannot publish battery, EV or executable-plan commands. Explicit display off blocks cached operating data at the final sink while preserving bounded metadata delivery.

Integrated onto main d4c3a9af, retaining current charge/discharge window helpers, three-phase attributes and charger control. Optional display startup and outages do not block the healthy Cloud inverter source. The earlier #5504 fix is included in this consolidated implementation and should not be merged separately.

Validation on the current-base integration: 442 focused Python tests passed (one canonical-runner fixture test deselected); the full quick suite passed, with four slow tests skipped. Pre-commit passed. Actual preference-save/SQL/config through Components, TLS MQTT and native receiver lifecycle passed, plus token-refresh/rejection/reconnect isolation. GitNexus staged audit: nine files, ten affected flows, HIGH aggregate scope, reviewed.

This set supports one paired Hub per site: GivEnergy Cloud can control while a parked Hub displays fresh measurements, and a controlling Hub can hide its operating display. A second display Hub alongside a Hub controller needs a separate target/transport extension. Cloud measured data remains unavailable for unsupported compositions, including EMS/Gateway and separate-PV sites.

Companions: engine #5529, SaaS #2159, firmware #482. Current tested heads: engine a46365ec, SaaS 98b5a7eb9, firmware 50404218. Compatible engine and firmware must precede an exact-site SaaS canary. Eligibility flags remain default off.

An independent reviewer found no actionable issues in the integration against current main/develop. Physical S3 pilot acceptance, live broker ACLs and real-device flash persistence are still pending. The joined proof uses isolated SQL, synthetic source data and a native receiver/storage adapter; it does not claim a physical inverter write cycle or automatic control handoff.

mgazza and others added 15 commits October 13, 2026 00:27
…s); mark GH#5438/#5492 fixed (#5439/#5493), GH#5472 and the GH#4900 compare-plan leak fixed (#5488/#5465), sigenergy docs split fixed (#5464), ML retrain interval configurable (#5433) (#5528)

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…car hold (#5419) (#5470)

"Hold for car" on an inverter without timed pause set the battery discharge
rate to 0 for the whole car slot, even while the house battery was charging
in a charge window. A charging battery cannot feed the car, so the write did
nothing useful - and some GivEnergy hybrids (GIV-HY3.6 / Gen 1 via GivTCP)
will not grid-charge at all with the discharge rate at 0 and the SoC on the
reserve. The battery then sat at reserve for the whole cheap window while
the status read "Charging, Hold for car" (#5419, #5467).

While the inverter's own status is "Charging", the hold now skips the rate
write, as it already skipped the reserve bump (#3899), and logs that it has
deferred. Once charging stops (Hold charging, Demand), both apply as before.
The test now reads this inverter's status rather than the fleet-wide one,
which can carry over from an earlier inverter. Inverters with timed pause
still pause discharge during the charge: it is one latched write that
blocks only discharge, and no report ties it to the charging fault.

Tests: car_charge_no_reserve_ratchet now expects the maximum rate while
charging (it failed on main with "Discharge rate should be 1000 got 0"), and
car_charge2_no_pause pins the rate hold still applying under Hold charging.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… that have the switch (#5371) (#5543)

When the window still on the inverter started more than set_window_minutes
away, the window and the enable were both left alone. A window that has just
finished reads as tomorrow's, so the charge enable stayed on all day whenever
another charge was planned within 24 hours.

On a GivEnergy 3-phase unit that one switch arms every charge slot, and the
unused slots, held at 00:00-00:00, are live for the first minute of the day -
an 11 kW grid import at midnight.

The leave-it branch came from #2993, for an inverter with no enable switch,
where turning the charge off means rewriting the window. It is now kept for
those only. With a switch the switch goes off, the times are left alone, and
it comes back on within set_window_minutes of the next start.

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…e rate only limits grid charging (#5082) (#5542)

* fix(inverter): no Freeze Export on GivEnergy 3-phase, where the charge rate only limits grid charging (#5082)

A GivEnergy 3-phase unit has no Battery Pause and its charge rate register
only limits charging from the grid, so a Freeze Export ended in a write that
left solar filling the battery while the plan modelled it as exported.

The component now marks the charge rate entity grid_charge_only - GE Cloud on
the percentage charge rate when the device lists neither pause_battery nor
battery_charge_power, the hub when the model it reports contains "3ph" - and
the inverter clears its Freeze Export support on that mark. Freeze Export is
also turned off when any inverter lacks it rather than only the first, and
the export_more_solar warning and the GE Cloud discovery record follow.

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

* fix(inverter): read and write the rate per inverter, so a mixed GE Cloud fleet sees the 3-phase unit (#5359)

Review of the Freeze Export change found that in a GE Cloud fleet mixing a unit
with a power register and a 3-phase one, the 3-phase inverter was bound to no
rate entity at all: auto-config withheld the percentage rate for the whole
fleet when any device had a power register, so its grid_charge_only mark was
never read and Freeze Export stayed on.

GE Cloud auto-config now binds the percentage rate per device, for a device
with no power register only. The inverter asks its own entry which control it
is bound to rather than whether the setting exists for the fleet, which is the
read and write fault in #5359: a power-register inverter no longer reads a
silent 100% from an empty percentage entry, and each inverter is written
through the control it has.

The docs now say the AC-coupled 3-phase model is treated the same way as a
precaution, not as a confirmed hardware limit.

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

---------

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(gateway): add isolated display-only mode

* fix(gateway): isolate optional display connection health

* Add app attribute to gateway display state update

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Trefor Southwell <48591903+springfall2008@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: CI <ci@example.com>
…record the PR #5470 car-hold write-deferral and the PR #5543 between-windows enable-off (#5546)

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…te (#5532)

* fix(gateway): never enable discharge/export after a failed window write

For a direct Hub battery, adjust_force_export wrote the discharge start
and end as two separate set_discharge_slot commands, then enabled the
scheduled discharge and switched to export mode regardless of whether
the endpoint writes were applied. schedule_write_ok only withheld the
later commit record. When the end write was refused after the start was
accepted, the Hub was left enabled with a stale wrapping window
(e.g. 02:55-00:01) and actively exporting.

Send both endpoints in one full-serial set_discharge_slot for direct Hub
batteries and verify both endpoint read-backs before continuing. Any
refusal, staged or partial ACK, timeout, publication error or swallowed
async-bridge exception returns before the enable write, export mode and
commit, leaving the commit pending for the next cycle to retry. Both
endpoints are tracked in the control ledger and share one backoff key.
Read-only, entity ownership and serial checks refuse before publishing.
Coordinators keep their staged separate-endpoint sequence; other
providers, disable and midnight windows are unchanged.

Mirrors the charge-window fix in #5531.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf

* fix(gateway): detect coordinator staging before discharge ownership checks

A coordinator whose discharge end entity was not (yet) owned by the
gateway was refused with an ownership error instead of keeping its
staged separate-endpoint sequence. Identify coordinators first, as that
decision only needs the entity names and discovery record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf

* fix(gateway): stop an armed export when its window move is refused; no re-poll of a refusal

- When the paired window write fails while scheduled export is already
  enabled, turn the schedule off and return to Eco instead of leaving
  the previous window exporting on a plan that has moved away from it
  (skipped in read-only mode).
- A refusal of the exact paired command ends the attempt immediately:
  the poll stops when it arrives, and a retry inside the ack window is
  not published (_send_control would suppress it anyway), so a refused
  write no longer holds the engine for the full poll timeout per retry.
- Drop the unreachable end-serial comparison (the end entity name is
  derived from the start, so it carries the same serial) and name the
  test case after what it exercises.
- Document that an exception inside a running event loop reaches the
  caller as None via run_async's thread, not as an exception.
- Test the write/moved counters for the paired path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf

* test(gateway): a successful move of an exporting schedule stays enabled

The fail-safe disable applies only to a failed window write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf

* fix(gateway): only an explicit Hub refusal turns off an exporting schedule

The fail-safe disable fired on any unverified window write, including a
slow but successful one (ACK outside the poll, older firmware without
an applied block, telemetry not yet landed). That stopped a correctly
re-windowed export for a cycle and toggled enable/mode. Take the
disable path only when the Hub refused the exact paired command; an
unconfirmed write leaves the running export alone and keeps the commit
pending for the next cycle. Document that execute.py still reports
"Exporting" for a cycle whose window was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: CI <ci@example.com>
)

* fix(gateway): send direct battery charge windows as paired slots

* fix(gateway): separate coordinator staging from async publication failure

* fix(gateway): detect coordinator staging before charge ownership checks

A coordinator whose charge end entity was not (yet) owned by the gateway
was refused with an ownership error, so its charge window was never
written, instead of keeping its staged separate-endpoint sequence.
Identify coordinators first, as that decision only needs the entity names
and discovery record. Same ordering as the discharge side in #5532.

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

---------

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…topus lists it as completed (#5520) (#5533)

* fix(octopus): keep the dispatch rate on a half hour a planned dispatch ran in until it is listed as completed (#5520)

A planned Octopus Intelligent dispatch only covers what is still to come: the
Octopus API component advances the start of a running one to now, and an ended
one is dropped, while Octopus lists it as completed 15 to 60 minutes later. In
between the half hour went back to the day rate, and as today's cost is priced
from the current rates the day's cost stepped up after each half hour of car
charging and fell back when the completed dispatch arrived.

Each cycle, after the slot confirmation check, remember the half hour a started
planned dispatch is running in unless the car was cancelled in it, and put it
back into the slots as a finished dispatch until a completed dispatch overlaps
it or OCTOPUS_DISPATCH_HOLD_MINUTES (90) have passed. A dispatch still to start
is never held.

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

* fix(octopus): only hold a half hour a planned dispatch is running in on the exact clock (#5520)

A planned entry still listed after its end passed the running check while its
half hour was current, so one first seen after it ended was held without ever
being seen running - and dispatch_billed_off_peak() takes an ended entry as
finished, so an AWAY one became a phantom cheap half hour. Require the exact
clock to be before the dispatch end as well.

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

---------

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…correct drifted Solcast ceiling cites (#5551)

Folds GH#5545 (PV source precedence, Solcast-direct no-fallback and the
staleness-key requirement) into the Solcast integration row, and adds a
symptom row for GH#5549 (reserve at/above soc_max empties the charge-
candidate scan, the kernel freeze interaction and the discard trap).
Corrects the two drifted line cites on the GH#4730 max_kwh entry
(9999 init now solcast.py:1718, ceiling warning now solcast.py:1538).

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…5553)

* feat(load): forecast new installs from today's recorded load (#5548)

The weighted-bucket load forecast (days_previous_auto) only sampled whole
calendar days before today, so on a new install any time of day not yet
recorded on a previous day got no history at all, and with under a day of
history the forecast was skipped entirely. A system installed at midday
forecast its first two nights at roughly base load even when this morning's
overnight load was already recorded.

For a slot with no sample on any previous day, compute_load_forecast_history
now uses, in order:
- today's sample at the same time of day, when that time has passed;
- the average of all load recorded so far, once at least an hour exists;
- base load (previously 0).

Today never contributes while a previous day has data for the slot, so an
established install's forecast is unchanged and the fallbacks fall away once
a full day is recorded. Today's elapsed slots read today's own load, which
keeps the in-day adjustment neutral instead of letting it scale up (to 2x,
carried into tomorrow) a forecast already built from today. The caller no
longer requires a day of history; {} is returned only when nothing has been
recorded, leaving the days_previous fallback in place.

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

* fix(load): search yesterday under 24h of history; keep pre-install hours at base load

Review follow-ups on #5553:

- load_minutes_age counts whole 24-hour periods, so a 20:00 install is still
  age 0 at 14:00 the next day and the previous-day loop searched nothing:
  tonight's slots took the recorded average instead of yesterday's recorded
  evening. Yesterday is now always searched (unrecorded buckets are skipped).
- Elapsed slots of today before the install were filled with the recorded
  average. load_today_comparison counts them as base load actual, so an 11:00
  install read at 14:00 as a large over-forecast and the in-day adjustment
  dropped to its 0.5x floor, cutting tonight's plan. They now stay at base load.

Regression tests cover both, the second through load_today_comparison itself.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@mgazza
mgazza marked this pull request as ready for review October 16, 2026 07:04

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants