Skip to content

Merge upstream springfall2008/batpred (v8.53.0-equivalent) into main - #52

Merged
dandwhelan merged 257 commits into
mainfrom
merge/upstream-2026-08-24
Aug 24, 2026
Merged

dandwhelan merged 257 commits into
mainfrom
merge/upstream-2026-08-24

Conversation

@dandwhelan

Copy link
Copy Markdown
Owner

Summary

Merges ~250 commits from springfall2008/batpred upstream into this fork's main, alongside this fork's ~130 own commits. Full detail in the merge commit message; highlights below.

New from upstream: myenergi (Zappi/Eddi) integration, AlphaESS cloud component, annual planner fast-mode (interpolates months instead of simulating all 12), debug-history snapshot buffer, MCP server hardening, kernel ABI bump (4→5: nullable soc_out, pk_run_batch, PK_MAX_CARS 4→8), plus a large batch of new test coverage and doc updates.

FIT (Feed-in Tariff) dropped from the kernel at Dan's request — upstream's ABI bump landed the same cycle this fork had FIT fields living in PkContext, and keeping FIT would mean hand-recompiling all 6 platform binaries against a merged struct layout, which isn't practical here. Kernel now runs upstream's plain ABI 5/9 unmodified (source + prebuilt binaries). FIT's config/Python-engine code (fetch.py/prediction.py/output.py/plan.py) is untouched but inert on this deployment since the kernel is the active engine — flagging in case you want it fully ripped out later rather than left dormant.

Heat-pump annual modelling vs. fast-mode: both touched the same per-month planning loop. Resolved by forcing fast-mode off whenever a heat pump is configured (annual.py) — heat-pump users keep full per-month simulation, everyone else gets upstream's new interpolation.

Merge-induced regressions caught by the test suite and fixed (285 tests, all green after fixes):

  • prediction_kernel.py's ctypes struct still declared the 3 dropped FIT fields → kernel context creation failed on every scenario (cascaded into kernel_parity, model_kernel, prediction_batch, kernel_static_cache, optimise_windows_kernel, debug_cases, perf, random).
  • The Bottle Cap Dave join branch in octopus.py picked up upstream's immediate call_notify on join-request, double-notifying alongside the fork's actual joined_events-polling design (octopus_saving_notified). Restored fork's notify-on-confirmation-only behaviour; kept upstream's service-name fallback (join_octoplus_power_down_session_event).
  • web_mcp.py's _execute_set_config picked up upstream's self.set_state_external shorthand, which breaks when self is a MCPServerWrapper (not a ComponentBase subclass) per test_web_mcp.py. Reverted to self.base.ha_interface.set_state_external.
  • Reconciled a genuine fork/upstream test disagreement: fork's entity_has_no_value softens "unavailable"/"unknown" to Info-not-error (get_arg_unavailable, e.g. for an asleep car's SoC sensor); upstream's new get_arg_missing_index test used "unavailable" as its example of a value that must warn. Kept the fork's softening, re-pointed upstream's test at "abc" instead.

THIS_VERSION bumped to v712.25.

Test plan

  • Full unit test suite (285 tests, --quick, excluding web_if) — all passing
  • prediction_kernel.cpp compiles cleanly with -Wall -Werror (scratch build, not committed)
  • Deploy to the Pi and verify a live prediction cycle (not done yet — holding for review before releasing)

🤖 Generated with Claude Code

chalfontchubby and others added 30 commits August 18, 2026 21:51
…nload

Chasing a user through the per-snapshot picker to find the right moment is
exactly the back-and-forth this buffer was meant to avoid. One link now
downloads every retained snapshot as a gzip tarball; the plan page's own
History/Yesterday view (a separate consumer of the same snapshot index) is
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…position

steps_back is the snapshot's current position in the retention ring, not a
property of the snapshot itself - it shifts as newer captures push it back.
Baking it into the filename meant the same real capture got a different name
in every archive downloaded after it, making archives from different times
impossible to merge/deduplicate by filename. snapshot_id (its capture
timestamp) is already unique and stable; that's all the filename needs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tention window

Two captures landing in the same calendar minute (a routine capture and a
close-by force-capture) used to both stay in the ring, reaching the UI as
two identically-named chart series and breaking "Deselect all", which
keys off series name. capture_snapshot() now discards the older of any
same-minute pair, keeping the newer.

Also add an optional max_age prune so a burst of close-together captures
can't leave something far older than the buffer's intended window sitting
there just because the count cap alone hasn't caught up to it - wired
through from predbat.py as interval_hours * count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ded raw disk writes

Per @springfall2008's review on springfall2008#4438: "debug_enable still causes
unbounded disk growth - the original problem isn't actually fixed yet."
The rolling debug-history buffer (springfall2008#4417) shipped alongside the original
raw create_debug_yaml() disk write, not in place of it, so leaving the
switch on still wrote an uncapped predbat_debug_HH_MM_SS.yaml every
~5-minute cycle.

Rather than remove the raw write outright: debug_enable also gates
verbose logging and the C++ kernel bypass (the more accurate but far
slower prediction path, see springfall2008#4453's investigation), both genuinely useful
for watching a live issue develop cycle to cycle - finer-grained than the
history buffer's coarsest 1-hour interval floor (debug_history_interval's
min is 1, unit is hours). Removing the write entirely would be a real
capability loss for exactly the kind of live debugging this session has
been doing.

Instead, track when debug_enable was turned on (debug_enable_started) and
auto-disable it (expose_config, so it's visible in HA too) after
DEBUG_ENABLE_MAX_HOURS (2h). Bounds both the disk growth and the standing
performance cost of leaving the slow path on by accident, while preserving
full-resolution capture for someone actively watching a problem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…imum raised to 1

Per @springfall2008's review on springfall2008#4438: "give the rotating buffer its own
on/off switch, rather than overloading debug_history_count == 0" -
inconsistent with how the codebase normally does on/off (debug_enable and
debug_history_force_capture are both real switch CONFIG_ITEMS already).

Defaults on, preserving existing behaviour for anyone already relying on
debug_history_count=15 as "on". debug_history_force_capture still fires
regardless of this switch, matching the existing "explicit capture now
request is a different intent to the rolling background history" design.

Also raises debug_history_count's config-schema minimum from 0 to 1 - with
a dedicated switch, count=0 no longer needs to double as an off-switch, so
removing it as a valid value avoids "switch is on but count is 0" becoming
a second, silently-conflicting way to end up disabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…on genuine success

Per @springfall2008's review on springfall2008#4438 (items 1-3):

1. debug_history_last_capture was reset unconditionally after the
   try/except, so a failed capture (an exception, caught and logged as a
   Warning) was silently treated as if it had succeeded - deferring the
   next routine retry a full debug_history_interval instead of leaving it
   to retry at the normal cadence.
2. The storage-unavailable path returned before that reset, so it was
   never throttled at all - every ~5-minute cycle re-entered and re-logged
   the same warning indefinitely. Fixed with a separate
   debug_history_storage_warned timestamp, deliberately independent of the
   capture throttle, so a later genuine capture attempt is never skipped
   just because the warning was recently logged.
3. debug_history_force_capture reset on trigger, before storage was even
   checked or a capture attempted - contradicting docs/customisation.md's
   "Predbat resets the switch back off itself once the snapshot has been
   taken." A failed forced capture now leaves the switch on, so it retries
   every cycle until it genuinely succeeds (or the switch is turned off),
   rather than the request being silently swallowed by an early reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hot id

Per @springfall2008's review on springfall2008#4438:

Item 4 (race condition): html_debug_history_download's id=latest case
called load_snapshot(storage, "latest") - which resolves "latest"
internally but discards the resolved id - then called list_snapshots() a
second, independent time just to rebuild the filename. A capture landing
between the two calls could serve one snapshot's bytes under a different
snapshot's filename. Added resolve_and_load_snapshot(), returning
(resolved_id, text) from a single resolution, and used it in the route so
the id used for the filename is always the same one the data actually
came from. load_snapshot() now delegates to it, unchanged for existing
callers.

Item 6 (reflected XSS): "Snapshot {} not found".format(snapshot_id)
reflected the raw ?id= query param unescaped into a text/html response.
Escaped via html.escape(), matching the existing pattern used elsewhere
in web.py (e.g. the plan-log highlighting).

Item 21 (test coverage): the three debug-history web routes had zero
tests. Added test_web_debug_history_routes.py covering all three
(list/download/download-all) against a real StorageLocalFiles backend,
including the specific race (item 4) and XSS (item 6) fixes above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…havior

Per @springfall2008's review on springfall2008#4438 (items 8-9): docs described the
dropdown snapshot picker and "nearest"/"how many snapshots ago" matching
from an earlier design in this PR, replaced during development by a
single "Download all (.tgz)" link and exact floored-slot matching
(confirmed against the shipped JS - findNearestDebugSnapshot's tolerance
is 1000ms, guarding only sub-second formatting noise, not a real nearest-
within-a-window search; labels use absolute local time, not "N ago").

Updated docs/web-interface.md, docs/predbat-plan-card.md, and
docs/customisation.md to describe what actually ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per @springfall2008's review on springfall2008#4438 (item 22): findNearestDebugSnapshot,
loadDebugHistoryData, and the plan table's Debug column were untested.
Follows test_plan_why_reason.py's established precedent for this kind of
embedded-JS test - assert on the JS source text's structure directly
(there's no JS engine in this suite), rather than executing it.

Covers: the exact/tight-tolerance (not nearest-of-several) snapshot match,
the fetch being wired to the Yesterday view switch rather than the
frequent plan poll, and the Debug column linking by the matched
snapshot's own id with a real time label - locking in the same shipped
behaviour docs were just corrected to describe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…comment

- docs/customisation.md: mention the 2-hour auto-disable next to the
  existing switch.predbat_debug_enable writeup (review comment on springfall2008#4438).
- test_debug_history.py: same-minute-dedup test comment referenced a
  "Deselect all" chart-series regression that belongs to a different,
  unmerged branch - reworded to describe what this PR actually guards
  against (id/filename collision on the floored-to-minute snapshot id).
…reeze Export, enable it for FoxESS/FoxCloud (springfall2008#4207)

Freeze Export previously forced charge_rate_now to 0 unconditionally and fell
through into the general export-limit clip, so any PV genuinely exceeding
load + the hardware export limit was modelled as clipped/lost - regardless of
whether the real inverter actually routes it into the battery instead
(confirmed on FoxESS's "Feed-in First" mode and independently on Sigenergy
under active Force Export). inverter_can_charge_during_export already modelled
this exact recapture for active Force Export windows; it was just never wired
into the freeze path.

Freeze Export now gets its own branch in prediction.py (and its C++ kernel
mirror): the battery is never actively discharged, but once PV alone exceeds
what load + the export limit can absorb, the genuine spillover charges the
battery (capped at the overflow amount and the battery's own charge rate, not
the full rate) rather than being clipped, when inverter_can_charge_during_export
allows it. SoC still holds flat whenever the export limit alone can cover the
surplus - only genuine overflow moves it.

FoxESS and FoxCloud's support_discharge_freeze reverts to True (both are the
same hardware via different connection methods) now that this is correctly
modelled instead of disabled outright - supersedes the earlier fix that just
turned Freeze Export off for them.

Kernel parity revision bumped (6 -> 7) and all six platform binaries
cross-rebuilt via zig. coverage/cases/random_results.json and
predbat_debug_pre_saving1.yaml.expected.json regenerated - Freeze Export now
recaptures more energy than before in scenarios where PV exceeds the export
limit, so some plans shift to use it more.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ero entries

- Sanitize WindowedFeatures and chunk conversions against NaN, Inf, and None
- Use nanmean/nanstd and sanitize scales before np.maximum in feature/target normalization
- Add gradient clipping and finite parameter checks in Adam optimizer
- Preserve valid 0.0 values in database save/load roundtrips
- Fix duplicate training execution and curriculum pass initialization tracking
- Add robust unit tests for NaN resilience, zero preservation, and 90-day curriculum
… joining (springfall2008#4593)

The auto-join loop read octopoints_per_kwh only to compute a display rate,
never to decide whether the event was worth joining, so a zero-point event
was join-attempted exactly like a real one. This matters now because the
Octopus integration puts national Power Up (free electricity) events into
the Power Down available_events set at 0 p/kWh (springfall2008#4548 point 5), so each one
gets joined, rejected, and still fires a false "joined" notification.

Adds a guard matching the existing Axle-conflict skip in the same loop:
octopoints_kwh <= 0 skips with a log line and continue, before the join
attempt. null keeps its existing meaning (rate not reported, default
applies) - only an explicit non-positive rate is skipped. Mirrors the
planning side's own saving_rate > 0 requirement in the joined_events loop
that builds octopus_saving_slots - that check already existed, it just
wasn't shared with the join side.

New test_saving_session_zero_rate_skip covers zero, negative, positive and
null rates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ed one

Bottle Cap Dave's Octopus Energy integration renamed
join_octoplus_saving_session_event to
join_octoplus_power_down_session_event (ADR-0004). Both remain
registered until the old one is retired in January 2027, so try
the new name first and fall back to the old one on failure.

Part of springfall2008#4548 (point 3). TODO(springfall2008#4599) tracks removing the fallback
once the old service name is gone upstream.
…r Down/Up rename

Octopus Energy integration v19.0.0 renamed the saving-session and
free-electricity event entities to Power Down and Power Up
(event.octopus_energy_ACCOUNT_ID_octoplus_power_down_events /
..._power_up_events). Both old and new entities remain registered
until January 2027 (ADR-0004), and resolve_arg_re()'s regex
matching against HA state keys is a one-off startup resolution, so
broadening the 're:' alternation to match either name is enough -
no runtime try-new-then-old logic is needed here, unlike the join
service rename in point 3.

Updates the octopus_saving_session/octopus_free_session regex in
apps.yaml (default, coverage test fixture, and all 23 inverter
templates that configure them), plus docs/energy-rates.md to
mention the rename, the entity_registry_enabled_default=False
gotcha, and the January 2027 cutoff.

Part of springfall2008#4548 (point 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s user, not just low power mode

The dawn light/dark split (calc_dawn, springfall2008#4557) only ever mattered when
combine_charge_slots could merge a window across sunrise - with it off,
find_charge_window already forces a break every charge_slot_split minutes
(= plan_interval_minutes, the same granularity calc_dawn buckets at), so the
split was already a no-op there. It was gated on set_charge_low_power
instead, which meant combine_charge_slots users without low power charging
never got it, even though the split also lets the plan optimizer charge just
the dark portion of a combined window and skip the daylight portion where
solar may cover the load - a benefit independent of low power charging.

New calc_pv_light_dark() gates the split on combine_charge_slots directly.
Also logs the calculated dawn time each time it runs, so it's visible
whether the detected dawn looks sane.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Plan four seasonal anchor months and reconstruct the other eight from the
solar curve the run already downloads, cutting a WhatIf run to about 2.5x
faster. Curve chosen from a measured study over three recovered 12-month
reference runs: affine in solar (per-day = a + b*PV) beats cyclic linear,
Fourier and proportional-to-solar on per-month error, and is insensitive
to which months are anchored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five 12-month references scored (3x Intelligent Go, Cosy, Agile, plus an
Agile run at 6 samples/month as low-noise ground truth).

Confirms solar_affine as the default and rejects the mean-rate regressor:
against noisy references it looked best, halving Agile's annual error, but
leave-one-anchor-out cross-validation shows it overfits badly (362% vs 38%
held-out error on Agile) because 4 anchors against 3 parameters leaves one
degree of freedom.

Decomposing Agile's error shows interpolation contributes under 1% to
annual savings; the large Agile figures are sampling noise already present
in today's 12-month run (-8.5%/+17.8% against the 6-sample reference),
which fast mode inherits rather than creates. Recorded as a pre-existing
limitation bounding what the caveat text may claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven tasks: config/CLI flag, the pure interpolation module, a shared
included-status constant, engine wiring, web UI, a committed curve-scoring
guard, and docs.

Also commits the two reduced reference fixtures the curve guard scores
against, built from the Agile (6 samples/month) and Cosy 12-month runs.
Reduced to months, scenarios and monthly PV only - no plans, location,
tariff URLs or keys. Verified solar_affine still beats linear on both
(agile 18.39% vs 18.84%, cosy 9.56% vs 14.47%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nsumers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t mode

Anchors are Mar/Jun/Sep/Dec, which measurably beat Jan/Apr/Jul/Oct on the
savings figures (Cosy: -0.5%/+0.2% against -10.6% on the Predbat saving).

Adds a volatility guard. Interpolation only holds while a month's economics
follow the solar curve; on a tariff whose whole price level moves day to day
the reconstructed savings are 20-30% out. The run measures the coefficient of
variation of daily average import price over the anchor months and, above
0.10, silently becomes a full run rather than reporting a fast wrong number.
Measured: Agile 0.21, Cosy 0.005.

Verified end to end: Agile --fast produces a bit-identical full run
(+0.0%/+0.0%), Cosy --fast reconstructs payback to 5.84 vs 5.81 years.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nths

Checkbox in Advanced, parsed like the existing debug flag. Interpolated
months are tagged in the month table and noted above the chart, following
the rates-synthesised precedent, so an estimated month is never mistaken
for a planned one. 'What this run used' distinguishes a run that used fast
mode from one that asked for it and was given a full run instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… runs

Asserts the volatility guard sorts the reference tariffs correctly (Agile
0.222 declined, Cosy 0.002 accepted) and that on the tariff it passes, four
anchors reconstruct the savings to 0.48%/0.19%, with solar_affine beating
linear on the Predbat saving (0.19% vs 4.38%) - which is why it is the
default. Fixtures carry daily mean import rates so the guard threshold is
regression-tested, not just the curve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
User docs gain a Fast mode section covering what it does, its measured
accuracy, when to turn it off, and that volatile tariffs get a full run
automatically; plus the --fast flag and the config key.

Corrects two things the spec got wrong before implementation. Anchors are
Mar/Jun/Sep/Dec, not Jan/Apr/Jul/Oct: per-month cost error does not separate
the candidates but the savings figures do, sharply, because a saving is a
difference between two reconstructed scenarios. And the 'under 1% on annual
savings' claim was a mean across anchor sets that hid a worst case of 33%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
springfall2008 and others added 28 commits August 23, 2026 14:32
…arging

The myenergi component publishes a Zappi's plug status straight from the
pilot state, and pilot states C1 and D1 normalise to "EV ready to charge" -
a car that is plugged in and waiting for its slot. None of the apps.yaml
templates carried that value in car_charging_planned_response, so Predbat
read a plugged-in, ready car as not planned to charge and dropped it from
the plan. Added it to every template that lists the myenergi-aware
responses, commented examples included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h retry

Ten issues found by the whole-branch review, all in myenergi.py:

The cloud device-list cache never aged. meta_age_seconds was set to its
maximum in __init__ and to zero on refresh, and nothing in between ever
increased it, so GET /devices ran exactly once per process and a device
added, removed or renamed in the myenergi app stayed invisible until
Predbat restarted. It is now a wall-clock stamp compared against
CLOUD_DEVICE_LIST_MAX_AGE.

The direct transport checked for the X_MYENERGI-asn header before the HTTP
status, so a 503 from the account server surfaced as "check the hub serial
and API key" and sent a self-hosted user off to regenerate a perfectly good
key during a myenergi outage. The status checks now run first, matching
_resolve_asn, whose docstring already explained why.

Auto-configuration left car_charging_planned broken. Every apps.yaml
template ships a regex for that key targeting the third-party ha-myenergi
integration's entity names, which do not match the ones this component
publishes, so Predbat logged "failed to match - disabling this item" and
fell back to the car_charging_threshold heuristic. automatic_config now
wires the Zappi plug status sensors into it alongside car_charging_energy.

The /cgi-* command endpoints answer HTTP 200 whether or not they acted,
carrying the outcome in a {"status": N} body that send_boost and
cancel_boost discarded. A refused boost - an Eddi already at maximum tank
temperature has no pre-check at all - logged as a success and the switch
quietly flipped back on the next poll. A non-zero numeric status now
raises, and switch_event_handler returns the result rather than dropping
it, so a refusal reaches the run loop's "control failed" warning.

A 401 mid-poll raised MyEnergiAuthError and simply ended the cycle. The
proactive check_and_refresh_oauth_token only covers a token that has
reached its stated expiry, so a token revoked before then wedged the
component until restart. It now calls handle_oauth_401 and retries the poll
once, as fox.py, deye.py and solis.py do.

Also: a cloud device whose status call fails no longer costs the whole
poll; poll_seconds is capped at 30 minutes and the success timestamp is
stamped only by a cycle that actually polled, so the health check reflects
real API contact without a slow poll reading as a failure; the entity
lookup anchors on a whole prefix; number_event_handler is guarded on the
boost suffixes symmetrically with switch_event_handler; boost amounts round
rather than truncate and a target time is zero padded to HHMM; an
unsupported device kind is refused rather than sent an Eddi command; the
CLI connects before polling; and an aiohttp too old for DigestAuthMiddleware
is reported with an actionable message instead of a bare AttributeError.

Tests cover every one of these, including the three production behaviours
that previously had no coverage at all - the poll_seconds gate, the cloud
cache-staleness branch and the Eddi kind guard - plus a reason-label table
for the direct transport matching the cloud transport's. The two "unknown
entity" tests now load a known device first, so their lookup loop actually
runs. All fourteen fixes were mutation-checked: reverting any one of them
fails the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vers

The documented "known limitation" was empirically false. It claimed
iboost_today is the midnight-to-now difference and so under-reports after a
mid-day Eddi reset, and that car_charging_energy is unaffected. In fact
fetch.py reads a series that has already been through
minute_data_load(..., clean_increment=True) and clean_incrementing_reverse,
which rebases the counter on a reset: a realistic Eddi day of 3.0 kWh
yesterday and two sessions of 2.0 and 1.5 today, with a mid-day reset, runs
through Predbat's own minute_data to exactly the right 3.5 kWh.

The real limitation is narrower and was undocumented. A drop only counts as
a reset once a sample reads zero or the fall exceeds 1 kWh, so a session
ending below roughly 1 kWh without a zero sample is not rebased and its
energy is lost - two 0.6 kWh sessions total 0.600 against a truth of 1.20.
That loss is in the shared cumulative series, so it hits car_charging_energy
exactly as hard as iboost_today, the opposite of what was documented.
Corrected in docs/components.md, in the automatic_config docstring, and in
the design record.

Also corrected: auto-config now covers car_charging_planned, so the docs no
longer claim more or less than it does; the "whichever matches your
auth_method" gate does not exist, components.py's required_or is an
unconditional either/or; the not-implemented controls are a reserved
interface nothing can reach rather than something a user can attempt; the
Eco/Eco+ pre-check is one specific check, not a general guarantee that no
rejected call is ever made; the Eddi sensor's more consequential effect is
the load subtraction gated on iboost_energy_subtract, which runs whether or
not iboost is enabled; and auto-config latching until restart is now stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses PR springfall2008#4658 review feedback. rates_export_override and
rates_import_override no longer collide/replace on repeat when sent
without an index; document that each distinct command is kept as a
separate time window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_running(): an install upgraded from before record_status() wrote a
timezone-aware last_updated could still have the old naive format
persisted in HA until the next record_status() call overwrites it -
comparing that against a timezone-aware "now" raised TypeError. Now
branches on whether the parsed value is naive.

predbat_update_move(): was returning True unconditionally regardless of
whether the mv chain actually succeeded. Now returns the real result.
Kept clearing git_version.txt before the mv (not after) - that ordering
is deliberate, per the PR's own review discussion, to close a hot-reload
race where a new process could read a stale marker mid-move; moving the
clear to "only after success" would reopen that race. The trade-off -
losing the diagnostic marker on a failed mv - is low-stakes, since
verify_staged_files() has already ruled out a bad download by that point.

Adds test coverage for both: a naive/aware last_updated round-trip
through is_running(), and a failed mv chain propagating through
predbat_update_move()'s return value.
…ersions-copilot-followup

Address Copilot review comments from springfall2008#4653
Include token_hash in the myenergi activation gate. A refresh-only OAuth setup
carries no access token, and initialize() already accepts that, so gating on key
alone meant such a config never constructed the component at all.

Reject unsupported device kinds in the cloud transport's boost and cancel paths
rather than defaulting them to the Eddi body, matching the direct transport.

Add 'ev ready to charge' to alphaess_cloud.yaml, which landed on main after this
branch and carries the same car_charging_planned_response vocabulary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_make_web() stubs my_predbat.components with a SimpleNamespace, but my_predbat is
shared across the whole run, so the stub outlived the test and broke every later
test calling a real method on it - is_running() raised AttributeError on
components.is_all_alive() in test_web_functions. Save and restore it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-device tolerance added for a single unreachable device also swallowed the
case where every device failed: fetch_devices returned an empty list, run() read
that as an empty site, kept the previous readings and stamped success. A site
whose every device was erroring therefore reported healthy indefinitely.

Raise instead when the transport knows about devices and reads none of them, so
run() returns False, the error count rises and components.py's 60 minute staleness
check eventually marks the component unhealthy. An account with genuinely no Zappi
or Eddi still reports no devices rather than an error, and a partial failure still
keeps the healthy devices visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs, docstring and spec all said a session ending below roughly 1 kWh is
under-counted only when no reading lands on zero, which implied a zero reading
would rescue it. It does not: minute_data() smooths any fall under 1 kWh as a dip
in the data (utils.py:565) before clean_incrementing_reverse() (utils.py:740) ever
looks for a reset, so the zero is gone by then. State the behaviour as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rgi-component

feat(myenergi): Zappi and Eddi monitoring, auto-config and boost controls
…ervice

HA's call_service websocket command expects target (entity_id/device_id/
area_id addressing) as a sibling of service_data, not nested inside it. A
service configured with the common target: entity_id: ... syntax (e.g.
input_boolean.turn_on/turn_off for a generic Service API bridge) landed
with target nested inside service_data, which HA rejects with
invalid_format: extra keys not allowed @ data['target'] - silently
blocking every charge/discharge service call while Predbat's plan and
status kept reporting normally.

Fixes springfall2008#4662
- Pop target from a copy of service_data, not the dict itself - it's
  the same object async_call_service_websocket_command() logs on
  failure, so popping in place was silently dropping target from that
  diagnostic.
- Reword the docs note: only target is elevated to a top-level sibling
  field, other keys (entity_id, device_id, option, etc.) still travel
  inside service_data as before.
- Extend the regression test to assert the original queued
  service_data is left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…boolean-service-target-4662

fix(ha): send target as a sibling of service_data in websocket call_service
Adds export_limit_kw as a top-level WhatIf config field (default 10kW),
combined via min() with whatever apply_hardware() already derives for
export capability. Previously this only existed as battery.export_limit_kw,
so a PV-only (no-battery) run had no way to model a grid export cap at
all - only battery-equipped runs could. A legacy battery.export_limit_kw
is still read as a fallback so existing YAML configs and stored runs keep
working.
Adds myenergi_zappi_control. Inside a planned charging window Predbat puts the
Zappi in Fast, and outside one in Stopped. Fast because the window was chosen for
its rate rather than for sunshine - Eco or Eco+ would only charge from surplus and
the car would not get what the plan assumed.

Each Zappi follows its own car, matched in serial order, the same order
automatic_config wires car_charging_energy and car_charging_planned in, so the two
cannot disagree about which Zappi is which car. The mode already polled is compared
against what was asked for and re-applied, since purely edge-triggered control
diverges the moment anything else touches the charger.

A component-level switch.predbat_myenergi_zappi_control hands the charger back
without editing apps.yaml, on by default and persisted through Storage so a restart
does not quietly resume control. That release, and Predbat's own read only mode,
restore the mode the Zappi had before Predbat took over, falling back to Eco+ when
nothing was saved - simply going quiet would strand a car Predbat had left Stopped.

set_mode is implemented on both transports to support this, so it is no longer one
of the reserved stubs and is now abstract on the transport interface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Exercises the same three supply-mode commands Predbat-led charge control issues,
so the API can be tested against a real Zappi without enabling the feature.

--release is included because without it --stop-charge would leave the charger
sitting in Stopped with no way back from the command line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The move to a top-level field accidentally added exclusive_minimum=True,
rejecting 0 - a regression from the original battery.export_limit_kw,
which allowed 0 to model a zero-export grid connection. Flagged by
Copilot review on PR springfall2008#4672.
Copilot review: the switch handler is gated behind myenergi_enable_controls, so
the switch cannot be toggled in monitor-only mode.

The symptom is real but wider than reported - switch_event() returns before the
handler is ever reached, and publish_data gated only on the config key, so the
switch appeared reading "on" for a feature enable_control() had already refused
to start. Making it respond to a toggle would leave it live but still lying, so
gate publishing on control_active instead: no switch appears unless control could
actually act, and it reappears with its remembered state once controls are allowed.

Also restore the saved state before the first publish rather than after. A restart
with control switched off previously showed the switch on for one cycle and then
flipped, which reads like Predbat taking control back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f-grid-export-limit

WhatIf: grid connection export limit, independent of battery
…rgi-zappi-control

feat(myenergi): drive the Zappi from Predbat's car charging plan
…al-api-rate-override-dedup

fix(manual-api): allow multiple overrides for lists (rates_import_override, rates_export_override)
Brings in ~250 upstream commits: myenergi integration, AlphaESS cloud
component, annual planner fast-mode (interpolated months), debug-history
snapshot buffer, MCP server hardening, and a large batch of test coverage
and doc updates - alongside this fork's ~130 commits (frozen car-SoC
guard, GivTCP discharge-target fixes, saving-session join/notify
handling, db_primary entity filtering, custom dashboard/mobile nav,
heat-pump annual modelling, kernel threads:0 fork-pool fix, etc).

FIT (Feed-in Tariff) dropped from the C++ kernel at the user's request:
upstream's ABI bump (4->5, PkScenario::soc_out nullable, pk_run_batch)
landed the same cycle the fork carried FIT fields in PkContext, so
keeping FIT would have meant hand-recompiling all 6 platform binaries
against a merged struct layout - not practical here, and the user no
longer needs the feature. Kernel now runs upstream's plain ABI 5/9
unmodified (source and prebuilt binaries); FIT's config/Python-engine
code in fetch.py/prediction.py/output.py/plan.py is untouched but inert
on this deployment since prediction_kernel_enable is on and the kernel
is the active engine.

Heat-pump annual modelling and upstream's new fast-mode interpolation
both touched the same per-month planning loop; resolved by gating fast
mode off whenever a heat pump is configured (annual.py) rather than
teaching the interpolator about a heat pump's demand curve.

Fixed three merge-induced regressions caught by the test suite
(285 tests, all green):
- prediction_kernel.py's ctypes PkContext still declared the 3 dropped
  FIT fields, desyncing it from upstream's compiled struct and failing
  kernel context creation on every scenario (kernel_parity, model_kernel,
  prediction_batch, kernel_static_cache, optimise_windows_kernel,
  debug_cases, perf, random all cascaded from this one).
- The Bottle Cap Dave join branch in octopus.py picked up upstream's
  immediate call_notify on join-request; the fork's actual design
  (confirmed by test_saving_session_notify_config) only notifies once
  an event is confirmed in joined_events, gated on
  octopus_saving_notified - restored that, kept the service-name
  fallback (join_octoplus_power_down_session_event) upstream added.
- web_mcp.py's _execute_set_config auto-merged upstream's
  self.set_state_external shorthand, but it also runs with self bound
  to MCPServerWrapper (not a ComponentBase subclass) per
  test_web_mcp.py - reverted to self.base.ha_interface.set_state_external,
  which works for both.

Also reconciled a genuine fork/upstream disagreement: fork's
entity_has_no_value softens "unavailable"/"unknown" to an Info log with
no recorded error (get_arg_unavailable, motivated by entities like an
asleep car's SoC sensor); upstream's new get_arg_missing_index test used
"unavailable" as its example of a value that must still warn. Kept the
fork's softening and re-pointed upstream's test at a non-sentinel bad
value ("abc") instead, since both behaviours are legitimate and only
collided on the specific string chosen.

THIS_VERSION bumped to v712.25.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
prediction_kernel.cpp: clipped_today (and the pv_ac_before/pv_ac_no_loss
locals that fed it) only ever existed to feed the FIT income calculation
removed in the merge commit - dropping that left them set-but-never-read,
which -Werror correctly caught under clang (the CI cross-build), though
not under the native g++ used for the local compile-check. Removed all
four.

test_web_if.py: confirmed against springfall2008/batpred upstream
directly (not introduced by this merge) - web.py registers
/debug_history_list, /debug_history_download, /debug_history_download_all
and /images/{filename}, but upstream's own hand-written endpoint list
in this test never picked them up. Added them to the existing
sweep_exempt mechanism: the debug_history routes need a captured
snapshot to mean anything and are already covered by
test_web_debug_history_routes.py, and /images/{filename} is a path
parameter, not a literal fetchable URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dandwhelan
dandwhelan merged commit 6ea9375 into main Aug 24, 2026
@dandwhelan
dandwhelan deleted the merge/upstream-2026-08-24 branch August 24, 2026 06:19
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.

8 participants