Skip to content

Merge upstream Predbat v8.45.2 - #1

Merged
Scholdan merged 41 commits into
mainfrom
sync/upstream-v8.45.2
Jul 16, 2026
Merged

Merge upstream Predbat v8.45.2#1
Scholdan merged 41 commits into
mainfrom
sync/upstream-v8.45.2

Conversation

@Scholdan

Copy link
Copy Markdown
Owner

Summary

Validation

  • pre-commit run --all-files
  • Predbat quick test suite
  • Targeted AdditionalLoad, export commitment, execution, optimisation-level, and solar-optimisation tests
  • Native prediction kernel build and kernel parity/model/optimisation tests
  • Staged-file secret scan; reviewed all reported fixtures/placeholders as false positives

Rollback

The pre-merge fork state is preserved at backup/pre-upstream-sync-2026-07-16.

springfall2008 and others added 30 commits July 1, 2026 19:43
springfall2008#4164)

* Re-work multi-inverter support and freeze charging

* Coverage

* [pre-commit.ci lite] apply automatic fixes

* Potential fix for pull request finding

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

---------

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…ailable (springfall2008#4158)

An export tariff can be discovered (registered on the meter point) while its
standard-unit-rates endpoint returns no data — e.g. EDF SEG export tariffs
(EDF_EXPORT_SEG_12M) which 404 on /standard-unit-rates/. Previously run() wired
metric_octopus_export based solely on self.export_tariff, pointing fetch.py at an
empty export_rates sensor. fetch.py selects the export source by key presence, so
the empty metric_octopus_export shadowed the user's manual rates_export fallback,
zeroing out all export in the plan.

Track whether export rates were actually fetched (export_rates_available) and only
wire metric_octopus_export once real rate data exists; otherwise leave it unset so
fetch.py falls back to the manual rates_export.

Adds test_run_does_not_wire_export_when_rates_unavailable.

Fixes springfall2008#4157

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ngfall2008#4170)

* Fix sigenergy history issue, fix crash with solcast and no data

* Test fix

* Potential fix for pull request finding

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

* Sig fixes

---------

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

* feat(marginal): expose rate_min/rate_max on marginal energy costs sensor

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

* feat(gateway): publish rate_min/rate_max/export_rate in predbat_data

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

* test(gateway): cover rate-anchor payload wiring in _publish_predbat_data

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

* fix(gateway): anchor to base tariff rates + NaN guard + 2dp precision (F2,F5,F6)

F2: publish rate_min_base/rate_max_base (getattr fallback) in marginal.py instead
of session-distorted rate_min/rate_max; also snapshot rate_export_base pre-distortion
in fetch.py and prefer it for grid_export_now.
F5: extract_rate_anchors now rejects non-finite values (NaN/Inf) via math.isfinite,
preventing invalid JSON tokens from being sent to the device.
F6: round anchors to 2 dp (matching the marginal-cost matrix precision) instead of 1 dp,
preventing boundary GREEN/AMBER flips caused by rounding mismatch.
Tests: add test_non_finite_returns_none; update 1dp expectations to 2dp throughout.

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

* Tidy PR

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* test: populate rate_import_base/rate_export_base and assert values in marginal costs test

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Trefor Southwell <tdlj@tdlj.net>
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: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
…ll plan run (springfall2008#4150)

* Initial plan

* Fix: initialize max_days_previous in PredBat.__init__ to prevent AttributeError on every tick

Before this change, `max_days_previous` was only set during `fetch_sensor_data()` (the full plan
calculation). When `quick_inverter_data_update()` ran on any tick before the first full plan,
`inverter.py`'s `find_battery_size()` would crash with:
  AttributeError: 'PredBat' object has no attribute 'max_days_previous'

Fix: add `self.max_days_previous = max(self.days_previous) + 1` in `__init__` alongside the
`days_previous` initialization, matching the formula used in `fetch.py`.

Fixes springfall2008#4149

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
…n flat peaks) (springfall2008#4118)

* Sustain in-progress forced export across plan cycles (anti-flapping)

optimise_export already nudges the metric to "keep existing windows" when
an export is running, but the binding selection gate is the raw cost check
((cost + min_improvement_scaled) <= off_cost). On a near-flat multi-slot
price peak with live PV, exporting the current slot versus holding it and
exporting the adjacent equal-priced slot is a cost coin-toss, so the
per-recompute decision oscillated and the forced export "flapped"
(drive/stop/drive) instead of draining continuously through the peak.

When that same hysteresis condition fires (already exporting within this
window) also relax the cost gate by the commitment amount, so an
in-progress forced export is sustained across recomputes. This only
sustains an export that is already running and never opens a new one, so
it cannot reintroduce the metric_keep gaming guarded by issue springfall2008#2984.

Adds tests/test_export_commitment.py (registered as export_commitment),
which fails without the patch and passes with it.

* Review feedback: scope export commitment to in-progress option, reset test state

- optimise_export: only relax the cost gate when the candidate option's start is at/before
  minutes_now, so the commitment applies to a genuinely in-progress export rather than a
  future-starting option (the option start is varied during optimisation).
- test_export_commitment: restore the export flags, isExporting/export_window and the
  metric_min_improvement_export* thresholds before returning so the shared test instance does
  not leave later tests order-dependent.

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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>
…4169)

* Draft c++ kernel

* chore: update prediction kernel binaries for all platforms

* Unlock step 5

* Fixes to build/review feedback

* Catch rest timeout

* REST timeout

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Fix validator issues

* Review finding
* Sigenergy MQTT fixes, fix zero rate export

* Bump version

* Lint and docs
* Ram saving stage 1

* Cache compression

* Memory savings

* [pre-commit.ci lite] apply automatic fixes

* Review feedback

* Review feedback

---------

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
…o 41200 permanently) (springfall2008#4181)

* Fox: fall back to v2/v3 scheduler API for EVO series inverters

Fox's v1 scheduler OpenAPI endpoints return errno 41200 permanently for
EVO-series inverters (productType 812) even though the devices fully
support the scheduler — verified live against an EVO 10-5-H (v1 fails,
v0/v2/v3 succeed) and two KH-series controls. Each failed v1 call costs
FOX_RETRIES attempts with long sleeps, so EVO devices also mark
themselves via scheduler_api_v2 and skip v1 thereafter.

- get_scheduler: fall back to /op/v2/device/scheduler/get and flatten
  each group's extraParam back to the flat v1 shape (v2 has no
  properties block, so the existing fdPwr/fdSoc defaults apply)
- set_scheduler: fall back to /op/v3/device/scheduler/enable, nesting
  SOC/power fields inside extraParam and carrying only enabled groups
  (matching the request shape the foxesscloud reference library sends)
- KH-series and other devices where v1 works see no behaviour change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fox: detect EVO by productType, not v1 failure (code-review fixes)

Follow-up to the v2/v3 scheduler fallback addressing an xhigh code review.

The previous approach routed a device to the v2/v3 scheduler API whenever a
v1 call returned None, and made that sticky. But errno 41200 is ALSO Fox's
shared transient rate-limit/comms code, so a single network blip on a healthy
KH-series device permanently rerouted it to the property-less v2/v3 path — a
real regression. It also never marked the device from the write path (so EVO
writes re-probed the failing v1 endpoint, stalling the loop for up to an hour),
and the flag was lost across restarts.

Replace failure-based detection with deterministic productType detection:

- FOX_V2_SCHEDULER_PRODUCT_TYPES = {"812"} (verified EVO 10-5-H code); a new
  uses_v2_scheduler() helper reads device_detail productType
- get_scheduler / set_scheduler route by productType, so KH stays 100% on v1
  (zero regression) and EVO uses v2/v3 directly (no per-cycle v1 stall)
- drop the runtime scheduler_api_v2 sticky flag entirely (productType is
  re-derived each fetch, so it survives restarts and never mis-fires)
- harden get_scheduler_v2: null-safe groups, default per-group and top-level
  enable so an active EVO schedule is not mis-read as disabled/empty

Tests: EVO uses v2/v3 directly; KH stays v1 on failure (regression guards);
null/absent-enable v2 shapes; EVO v2 also-fails returns None without crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Improve weighting

* Move adjustments to consider weights correctly

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…l2008#4189)

* Enable prediciton kernel by default

* Enable days_previous_auto by default
* Fox v2 scheduler fixes

* Review feedback
* Sigenergy - add in 3rd party inverter data

* Fallback for current mode

* Adding caching

* Review feedback

* Review feedback
* Kraken test harness and fix for rate fetching

* Add caching to kraken

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

---------

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

* New random results

* [pre-commit.ci lite] apply automatic fixes

* Review feedback

* Update cases

* [pre-commit.ci lite] apply automatic fixes

---------

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
* Kraken dispatches

* Review feedback
)

* Initial plan

* Fix target_soc float to int conversion in service API calls

* New test

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Trefor Southwell <tdlj@tdlj.net>
…he re-calculated plan (springfall2008#4218)

run_single_debug --redo rebuilt load_minutes_step with scale_fixed=1.0, then
calculate_plan rebuilds it with the user's load_scaling (and load_adjust /
load_baseline). The original plan metric was therefore evaluated against
lighter load than the final plan, making identical plans look like a metric
regression (e.g. -1146.86 vs -1131.92 with load_scaling 1.05). Mirror the
calculate_plan load model parameters so the two printed metrics are comparable.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Curtis-VL and others added 11 commits July 11, 2026 11:08
Added support for Tesla Wall Connector integration

Co-authored-by: Curtis English <curtis@ovrtoolkit.co.uk>
…ringfall2008#4229)

* Initial plan

* Fix Last Started date format on web dashboard to match Last Updated

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
…ringfall2008#4234)

Fix: Remove Tesla Wall Connector session energy, incorrect format, improve documentation for this value

Co-authored-by: Curtis English <curtis@ovrtoolkit.co.uk>
* docs: add Enphase cloud integration design spec and plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add Enphase cloud component skeleton and registration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Enphase Enlighten login flow with lockout guard rails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix: Enphase _login_rejected must not signal fatal on transient rejections

fatal_error_occurred() sets the shared, app-wide PredBat fatal_error flag
that stops is_running() and triggers a full web-UI restart. Signalling it
on every rejected Enphase login (a single 401, "too many active sessions",
etc.) would take down the whole app for a transient blip.

_login_rejected() now takes unrecoverable=False; MFA-required and
account-blocked responses pass unrecoverable=True and still fail fast.
All other rejections keep the existing cooldown/suspend bookkeeping and
only signal fatal once login_reject_count reaches the 24h suspend tier
(LOGIN_MAX_REJECTS). Also widened the "too many active sessions" text
match to run regardless of HTTP status.

MockEnphaseAPI.fatal_error_occurred() now records fatal_signalled instead
of silently swallowing the call, so tests can assert the policy: a single
transient rejection stays non-fatal, MFA is fatal immediately, and the
3-strike guard rail becomes fatal only on the 3rd rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Enphase request helper with retries and 401 re-login

Adds request_json() and _is_login_wall() to EnphaseAPI: single 401
re-login, HTML login-wall detection, BatteryConfig header-variant
fallback, jittered retry/backoff for 429/5xx/connection errors, and
record_api_call() instrumentation via the module-level function in
predbat_metrics.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Enphase cloud reads, parsers and polling loop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Enphase: remove dead profile_label field from battery_status

battery_status.json has no "profile" key, so profile_label was always
an empty string. The battery profile name will instead be sourced from
self.profile[site_id]["profile"] (populated by get_profile) when the
battery-profile sensor is published in a later task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Enphase monitoring sensors with derived power estimates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Enphase schedule control entities and HA event handling

Adds publish_schedule_settings_ha/get_schedule_settings_ha, the local
schedule model, and async select/number/switch event handlers that
mutate it, plus a stub apply_battery_schedule (real write path is
Task 7). Export controls are only published when dtg_supported().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Enphase battery schedule write path with settle confirmation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix TypeError in schedules_equal() when cloud limit is None

cloud_entry.get("limit", -1) only substitutes -1 when the "limit" key
is absent, but get_schedules() always sets "limit": entry.get("limit"),
so the key is present with value None whenever an enabled, time-
matching cloud schedule has no numeric limit. int(None) then raised
TypeError, aborting the entire apply_battery_schedule write cycle for
that site every 5-minute poll until the cloud shape changed.

Read the cloud limit separately and treat a present-but-None value the
same as absent, so schedules_equal correctly reports "not equal" (a
write is needed) instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: EnphaseCloud inverter definition and automatic configuration

Add INVERTER_DEF["EnphaseCloud"] (output_charge_control: "none" - Enphase
has no charge/discharge rate control, so Predbat falls back to its default
max-rate charge_rate/discharge_rate handling), automatic_config() to wire
every generic inverter arg to the entities enphase.py publishes for the
first discovered site (discharge/export args only when dtg_supported()),
a one-shot call from run() on first successful data load when
self.automatic is set, and the enphase_cloud.yaml apps.yaml template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: Enphase cloud component documentation

Document the EnphaseAPI component in components.md, inverter-setup.md
and apps-yaml.md, extend the cspell workspace dictionary, and make the
feat/enphase branch pass the full validation gate (unit tests,
interrogate, pre-commit). Also fixes markdownlint/cspell issues in the
pre-existing Enphase planning docs (list indentation, an orphaned code
fence, unknown words) so the branch is fully lint-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix Enphase derived-power inflation and harden None user_id params

publish_data() polls every 60s but lifetime_energy only refreshes every
15 minutes, so derive_power() was advancing its baseline sample on every
call even when the kWh delta was zero - producing 0W for ~14 ticks then a
~15x inflated spike on the refresh tick. derive_power() now only advances
the baseline when the value actually changes, and publish_data() holds
the last computed watts per channel (self.last_power_w) otherwise.

Also guard get_profile()/apply_battery_schedule()'s reserve-PUT against a
None self.user_id, which would otherwise raise an uncaught TypeError from
aiohttp when building the request params.

Adds test_publish_data_derived_power_multicycle to prove the fix across
repeated unchanged ticks and a simulated 15-minute refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add standalone test harness to enphase.py

Adds a MockBase + main() CLI (all # pragma: no cover) matching the
fox.py/gecloud.py pattern, so the Enphase component can be exercised
against a real Enlighten account from the command line:

  python3 enphase.py --username <email> --password <pw> [--site-id ID]
  python3 enphase.py ... --write-schedule [--start-time --end-time --soc]

Default mode logs in and runs one poll cycle, printing discovered sites,
battery status/profile/settings/schedules and the published entities.
--write-schedule drives the control entities to write a test CFG charge
window, reads it back, then restores by disabling it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Enphase login false-reject + tolerate 'N/A' numeric fields

Two robustness fixes surfaced by PR review and live testing:

1. login() detected 'too many active sessions' with a bare "session"
   substring, which also matches happy-path bodies (they contain
   session_id / session cookies), so valid credentials were rejected and
   login never completed. Now matches the specific phrase via
   is_too_many_sessions() (mirrors the reference integration).

2. The live Enlighten API returns the string "N/A" for numeric fields it
   cannot report (e.g. current_charge on a sleeping battery), so float()/
   int() raised ValueError and aborted the poll. Added safe_float/safe_int
   helpers and applied them to every cloud-facing numeric conversion in the
   read methods (battery_status, latest_power, profile, battery_settings,
   schedule limits).

Adds regression tests for both, including a happy-path login whose body
text contains 'session_id' and battery_status/reads with 'N/A' values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Enphase single-site operation, dedupe sites, graceful no-battery config

Fixes surfaced by a PV-only, 2-site test account:

- login() now dedupes discovered sites by id (Enlighten returned the same
  site twice, so sensors were published twice under one id). When more than
  one distinct site is found and no enphase_site_id is set, it logs which
  site is used.
- run() operates on a single active site (configured enphase_site_id, else
  the first) instead of looping every site. The per-category refresh gates
  are keyed globally, so a multi-site loop left later sites with no data and
  published zeros; single-site keeps them correct and avoids duplicate
  publishing.
- automatic_config() raising ValueError (e.g. a PV-only site with no
  controllable battery) is now caught in run(): it logs a warning and
  returns False instead of letting the exception abort the whole poll.

Also dumps raw lifetime_energy channels in the standalone harness to help
verify *_today units/semantics. Adds regression tests for dedupe, single
publish, and no-battery -> run() returns False.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: verbose Enphase API-call logging (debug_api)

Adds _log_api_call(): logs every request (method, path, params) and a
truncated, token-redacted view of the response. Wired into request_json
(all data reads/writes) and the three login() calls. Gated by
self.debug_api (default True for now) so it can be turned off later.
Request bodies (which carry the password) are never logged; token/
auth_token/access_token response fields are redacted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Enphase energy units (Wh->kWh), CFG-support gate, real schedules shape

Driven by real-account logging:

- lifetime_energy arrays are per-day increments in Wh (array indexed daily
  from start_date; last element = today), so *_today sensors were ~1000x
  too big. energy_today now converts Wh->kWh. Updated tests to use Wh inputs.
- automatic_config now fails (ValueError -> run() returns False) when the
  site does not support charge-from-grid (CFG) scheduling, since Predbat
  cannot control charging without it - not just when there is no battery.
- get_schedules parses the real response shape: per-family scheduleStatus
  ('active' seen on real accounts) drives the 'supported' flag, and count/
  status are captured. The count>0 per-schedule detail shape still needs a
  battery account to finalise (documented).
- Standalone harness now probes GET /pv/systems/<site>/today read-only so
  its shape can be inspected for a cadence-independent today total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: source Enphase today totals + instantaneous power from /today endpoint

Real-account logging of GET /pv/systems/<site>/today showed it returns
each channel's today total (Wh) under stats[0].totals plus intra-day
15-minute buckets (interval_length seconds from start_time). This is the
cadence-independent source the reference integration uses for 'today'.

- *_today sensors now come from /today totals (kWh), replacing the fragile
  'last daily bucket of lifetime_energy' assumption.
- Instantaneous pv/grid/battery power now come from the most recent
  completed 15-minute bucket (interval_power), which is stable within an
  interval and needs no cross-poll delta tracking - so derive_power and its
  prev_energy_sample/last_power_w machinery are removed.
- get_lifetime_energy replaced by get_today; cache key lifetime_energy ->
  today; ENPHASE_CACHE_VERSION 1 -> 2 to drop stale caches.
- Standalone harness dumps today totals + bucket metadata.

Tests updated: today_channel_kwh, interval_power, get_today, and the
publish/run tests now use the /today shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Comment

* fix: Enphase parsing corrected against real battery-account responses

A battery account's logs exposed four response-shape mismatches:

- profile and batterySettings wrap their fields in a nested 'data' object
  ({"type":"profile-details","data":{...}}). We now unwrap it, so profile
  name, reserve (batteryBackupPercentage), chargeFromGrid and veryLowSoc*
  are read correctly (were empty/default before).
- battery schedule detail id is 'scheduleId', not 'id'. The write path uses
  this id to update a schedule in place, so without it every apply would
  have created a duplicate instead of editing the existing one. Also pick
  the first non-deleted schedule per family.
- current_charge is a percent string like '0%'/'50%'; safe_float/safe_int
  now strip a trailing '%' so SOC parses.
- /today totals have no charge/discharge/export keys for batteries - energy
  is reported as source_dest flows. charge=solar_battery+grid_battery,
  discharge=battery_home+battery_grid, export=solar_grid+battery_grid+
  generator_grid (summed when no direct total present).

Confirmed working: cfg schedule (01:10-05:29, limit 100, enabled) parsed
with its scheduleId; profile self-consumption reserve 30; settings
chargeFromGrid + veryLowSoc read. The battery in the test account was in
an error state (all flows 0), so the flow-sum values themselves still need
confirming on a healthy battery. Tests updated with the real shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: seed Enphase control entities from real cloud schedule/reserve

The control entities (number battery_schedule_reserve, charge/export window
selects, target-SOC numbers, enable switches) were published from the
default local schedule model, so they showed 0/00:00/off even when the
monitoring sensors showed the real cloud values (e.g. reserve 30, a live
CFG window 01:10-05:29 enabled). That was confusing and gave Predbat a
wrong initial view of the inverter (its reserve/charge-window args point at
these control entities).

sync_local_schedule_from_cloud() now seeds the local schedule/control model
once from the cloud profile (reserve) and schedules (cfg->charge window,
dtg->export window, rbd->freeze), so the control entities start out
mirroring the inverter's real state. Seeding is one-time per site, so a
later Predbat/user write (or an external app change) is not clobbered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: refresh Enphase SOC on the fast tier, config on the slow tier

Battery SOC/available energy was bundled with the profile/settings/schedule
reads under one gate, so raising the settings interval to 30 min would have
left SOC up to 30 min stale - too stale for Predbat, which replans every few
minutes from the current SOC. Split battery_status onto its own 5-minute
tier (ENPHASE_REFRESH_STATUS) while profile/settings/schedules stay on the
30-minute settings tier (they change rarely or only via our own writes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Enphase system-status + inverter-time (liveness) sensors; quieter logging

- New sensor.<...>_system_status published from the /today siteStatus
  ('normal'/'comm' etc.) with the cloud's statusSeverity and human-readable
  statusDesc as attributes, so a gateway-not-reporting fault is visible
  instead of the system just looking like a flat 0% battery.
- New sensor.<...>_inverter_time = the last time the battery/gateway
  actually reported (max per-battery last_report from battery_status, else
  the /today last_report_date). It stays current while online and freezes
  when the gateway goes offline, so Predbat's inverter-time skew/liveness
  detection flags a stale/offline system. Wired into automatic_config so
  Predbat picks it up.
- _log_api_call no longer dumps the tens-of-KB HTML login/marketing page
  returned by the primary BatteryConfig variant before the cookie fallback;
  it logs a short '(HTML page, N chars ...)' marker instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: drive Enphase freeze-export from the export target, drop the freeze switch

Predbat cannot control a bespoke freeze switch, so the manual freeze_enable
switch/toggle is removed (local model field, publish, event handler, cloud
seed and HA read-back).

Freeze export is now derived automatically from the export/discharge target
SOC in apply_battery_schedule:
  - target < 99  -> real forced export to that floor (DTG schedule)
  - target == 99 -> freeze export: restrict battery discharge (RBD) over the
                    export window, DTG left disabled
  - target == 100 -> disabled (same as no export): neither DTG nor RBD

Freeze charge is unchanged and handled by Predbat's existing mechanism
(raise the reserve to the current SOC and disable the charge window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: require DTG (export) support for Enphase auto-config; drop dtg branches

Predbat cannot plan properly without export control, so automatic_config now
fails a site that does not support the discharge-to-grid (DTG) schedule
family, alongside the existing charge-from-grid (CFG) requirement. Since a
configured inverter is therefore guaranteed to support DTG, the per-call
dtg_supported() branches are removed: automatic_config always sets the
discharge args, publish_schedule_settings_ha always publishes the export
window controls, and apply_battery_schedule always writes the DTG schedule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: don't hardwire Enphase export_limit; let the user configure it

The Enphase cloud does not report a grid export power limit, and hardcoding
export_limit=99999 in automatic_config overrode the user's apps.yaml
export_limit. automatic_config now leaves export_limit unset, so a user with
a known grid export cap can set it in apps.yaml (Predbat defaults to
unlimited when neither sets it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [pre-commit.ci lite] apply automatic fixes

* feat: Enphase siteSettings + XSRF-refresh; safe reserve write harness

- Add get_site_settings (GET /service/batteryConfig/api/v1/siteSettings/<site>)
  storing the capability flags (hasEncharge, hasAcb, showChargeFromGrid,
  isEnsemble, ...); fetched on the settings tier.
- Fix the write-XSRF risk the endpoint survey surfaced: request_raw now folds
  the BatteryConfig 'x-csrf-token' response header into the cookie dict, and
  request_json absorbs cookies on every response, so the X-XSRF-Token used for
  writes stays fresh (session-cookie rotation is handled too). apply_battery_
  schedule GETs siteSettings first as the web app's XSRF bootstrap before writing.
- Refactor the reserve write into a reusable set_reserve(site_id, reserve).
- Standalone harness: --write-reserve N writes the reserve to N, reads it back,
  then always restores the original value (a minimal, safe real-write test that
  leaves the customer system unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: don't let login-wall cookies corrupt the Enphase session

Regression from the XSRF change: request_json absorbed the full cookie jar on
every response, so when the primary BatteryConfig variant returned an HTML
login wall, its anonymous session cookies overwrote our authenticated session
- breaking the cookie-variant fallback and the site-family calls (401 'You
need to sign in first').

Now request_json never merges cookies wholesale; it captures only a fresh
XSRF token, and only from a genuine 200 success (never a login-wall/error
response). Session-cookie rotation stays confined to login(). Adds a
regression test that a login-wall response leaves cookie_header untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Enphase write 403 - supply XSRF as double-submit (cookie + header)

The reserve/schedule write PUT returned 403 Forbidden: BatteryConfig writes
use a double-submit CSRF check, requiring the XSRF token both as the
X-XSRF-Token header AND as the XSRF-TOKEN cookie in the Cookie header,
sourced from a fresh siteSettings GET. The earlier session-corruption fix
had stopped absorbing cookies entirely, so the XSRF cookie never reached
our Cookie header.

Now request_json absorbs cookies on a genuine success only (never on a
login-wall/error response, which was the original corruption source), so the
fresh XSRF token lands in both self.cookie_header and self.xsrf_token.
_absorb_cookies matches the token cookie by name pattern (XSRF-TOKEN /
BP-XSRF-Token, any case). set_reserve now GETs siteSettings immediately
before the PUT to bootstrap a fresh token (apply_battery_schedule already
did). request_raw still folds the x-csrf-token response header into cookies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: Enphase writes cache the written value and move on

Replace the pending-write / post-write-confirm-reread machinery with optimistic
caching, per the simpler model: on a successful write, update our cached cloud
copy to the written value and move on; the periodic settings re-read reconciles
it later if the write did not actually land.

- set_reserve caches self.profile[...]['reserve'] on success.
- _write_schedule optimistically caches the written schedule on success. An
  update (PUT, we already hold the id) just updates the cache; a create (POST)
  re-reads schedules once to learn the cloud-assigned scheduleId (write
  responses don't return it), so later edits update in place instead of
  creating duplicates.
- apply_battery_schedule no longer does the post-write get_schedules/get_profile
  confirm re-read. Removed pending_writes, _pending_active,
  clear_expired_pending_writes and ENPHASE_PENDING_TIMEOUT_MINUTES.

This prevents write churn (a second apply with the same desired state is a
cache hit and issues no write) without waiting on slow/again-pending cloud
activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: apply Enphase reserve immediately on the number change, like Fox

Checked against fox.py: a reserve number write applies immediately (fox
calls apply_battery_schedule inline in the reserve branch), only the
charge/discharge window settings wait for the write button. Our number_event
was deferring the reserve to the button too, which breaks Predbat's
freeze-charge (adjust_reserve expects the reserve to take effect at once).

number_event now writes a reserve change to Enphase immediately via
set_reserve (skipping a redundant write when it already matches the cached
value); the window target-SOC numbers still stage and apply on the button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: clamp Enphase DTG export floor to at least the reserve

We were writing Predbat's raw export target as the DTG limit, which can be
below the backup reserve. Enphase never discharges below the reserve
(batteryBackupPercentage), and Predbat's own discharge target is
max(export, reserve, best_soc_min), so the DTG floor is now clamped to
max(export_soc, reserve) - keeping the written schedule consistent instead
of requesting an export level the battery can never reach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* revert: remove IOG charge-skew changes accidentally included on this branch

The Octopus Intelligent earlier-charge skew work (plan.py gradient,
test_iog_charge_skew.py, and its unit_test.py registration) was committed to
this Enphase branch by mistake. It now lives on its own branch
(feat/iog_charge_skew). Restore plan.py to main, delete the IOG test module,
and drop its test registration, leaving only the Enphase work here.

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

* docs: fix stale Enphase component docs (freeze switch, DTG gating, write model)

Copilot review comment on PR springfall2008#4235: docs still described a manual freeze
switch and conditional DTG publishing that were removed in favour of
automatic freeze-export derivation (export SOC == 99%) and mandatory
CFG+DTG support. Also updated the power-sensor and write-confirmation
descriptions to match the current /today interval-bucket and
optimistic-cache implementations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: fix stale DTG-optional wording in inverter-setup.md

Same discrepancy Copilot flagged on enphase.py's automatic_config():
this doc still described DTG as region-optional and writes as
settle-and-wait, both superseded by the CFG+DTG hard requirement and
optimistic-cache write model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: correct design spec's energy-read endpoint to match implementation

Copilot review: the "Approved" design spec still described reading
/pv/systems/<site>/lifetime_energy (daily-bucketed totals), but the
implementation was switched to /pv/systems/<site>/today (cadence-
independent totals + 15-minute interval buckets) during live testing.
Updated the reads section with a superseding note so the spec stays
trustworthy against EnphaseAPI.get_today().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
…fall2008#4236)

* fix(axle): warn and continue when Axle component is unhealthy

A failed Axle Energy VPP integration (e.g. an expired/rotated API key that errors on every run) previously still influenced the plan: fetch.py trusted fetch_axle_sessions() and the fetch_axle_active() read-only handoff even when the component was in an error state. With stale/inconsistent Axle state this flip-flopped read-only mode and injected phantom forced import/export slots, thrashing battery charge/discharge on IOG tariffs (observed live: ~5-minutely charge<->discharge, battery 98%->49%, peak-rate import during an active IOG car session).

Add Fetch.axle_component_healthy() (same 'active but not alive' definition as the components_healthy dashboard sensor) and gate both Axle influence paths on it: skip loading Axle sessions and never enter Axle read-only control when the component is unhealthy. The integration degrades to absent (warn and continue) and PredBat plans normally on the tariff. Adds unit tests for the predicate.

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

* test(fetch_config_options): provide components stand-in for axle health guard

test_fetch_config_options runs against the shared my_predbat instance, whose
`components` was left as a bare _MockComponents by an earlier test (no is_active).
The new axle_control path now calls axle_component_healthy() → components.is_active
("axle"), so Test 2 crashed with AttributeError.

Inject a minimal _FakeComponents (is_active/is_alive) for the duration of the test
and restore the original in teardown. Axle is present+healthy here so the control
path is exercised; the unhealthy warn-and-continue path is covered in test_axle.py.

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

* fix(axle): republish state on every run instead of gating consumers on health

The real bug behind the stale-session thrashing was in axle.py itself:
publish_axle_event() only ran from inside the fetch functions' success paths
(or a now-unreachable "not fetch_due" branch), so once fetching got stuck
failing (e.g. an expired API key), the on/off sensor state froze forever -
including a since-ended event still being reported as active - because
nothing kept re-deriving it against the current time.

Move publish_axle_event() out of _fetch_byok_event()/_fetch_managed_price_curve()
and call it unconditionally at the end of run(), after every fetch attempt
regardless of outcome. This makes the state self-correcting: a frozen cached
event naturally reports "off" once its own end_time passes, with no need to
know whether Axle's backend is still reachable.

With that fixed at the source, the fetch.py-level axle_component_healthy()
gate (and its "active but not alive" duplication of the components_healthy
sensor's health formula) is no longer needed, so it's removed along with its
dedicated test scaffolding.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Trefor Southwell <tdlj@tdlj.net>
…pringfall2008#4239)

The inverter only retains the TOU-mode bit (bit 1) on storage mode CID 636
when a charge/discharge window is actually configured; with no active slot
it silently clears the bit on read-back. The V2 branch of
write_time_windows_if_changed always forced that bit on regardless of slot
state, causing a permanent write/verify-fail loop every cycle once both
charge and discharge were disabled. Mirror the V1 branch's existing
in_charge_slot/in_discharge_slot gating: fall back to the
'... - No Timed Charge/Discharge' mode variant when slot 1 has no enabled
window.

Cross-checked bit numbering, CID 636, and the V2 slot CIDs against the
independent mkuthan/solis-cloud-control integration - identical mapping,
and that project likewise withholds manual TOU control on TOU-V2 inverters.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(plan): pass best_import instead of duplicate best_carbon in second-pass log

The final log line in optimise_full_second_pass labelled the "import"
value but was actually formatting best_carbon a second time, so
best_import never appeared in the log output.

Fixes springfall2008#4240

* fix(plan): publish correct power values for debug power sensors

pv_power_debug, grid_power_debug, load_power_debug and
battery_power_debug all published final_soc (battery SoC) as their
state, a copy-paste error that made all four sensors report identical,
meaningless values. Each now reports its own current predicted power,
pulled from the same time-series dict already used for its attributes.

Fixes springfall2008#4241

* fix(car): match car_charging_planned/now response values case-insensitively

Sensor state was lowercased before comparison but the configured
response lists were not, so any Title Case entry (e.g. the myenergi
Zappi integration's "EV Connected"/"Charging" states) silently never
matched. This left the car permanently treated as unplugged, blocking
Octopus Intelligent slots from ever populating car_charging_slots -
so the plan's rates and Car column never reflected an active dispatch.

Fixes springfall2008#4238

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ot changes (springfall2008#4243)

* fix(octopus): refresh intelligent dispatches every 2 min and only replan on genuine slot changes

The intelligent dispatch sensor gate used wall-clock minute parity
(count_minutes % 2 == 0), but the component scheduler drifts relative to the
wall clock (it sleeps in 5s chunks and does not account for run() duration), so
runs could land only on odd minutes and starve the sensor indefinitely. Key the
2-minute cadence off the age of the last update instead, matching the
tariff/device gates, and move the intelligent dispatch fetch onto the same
2-minute cadence so new Octopus slots are picked up in ~2 min rather than ~10.

Because an in-progress dispatch has its start advanced to now and its
charge_in_kwh scaled to the remaining time on every refresh, the raw-slot
comparison in fetch_sensor_data would then force a replan on every cycle
throughout an active charging window. Compare on a change-detection signature
(octopus_slots_signature) that ignores that re-clocking for active slots while
still detecting new/removed slots, moved window ends, revised future slots, and
future->active transitions.

Also fix date-rot in test_kraken.py test_fetch_dispatches_populates_device which
hard-coded dispatch dates that fell outside the 5-day completed-dispatch prune
window.

Tests: new octopus_sensor_due and octopus_slots_change suites; updated
octopus_misc run-scheduling tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(octopus): guard slot signature parsing and normalise timestamp formats

Address review feedback on octopus_slots_signature:

- str2time raises ValueError on a non-empty but malformed timestamp, so an
  "unavailable"/"unknown" slot value from an entity would propagate out of
  fetch_sensor_data and break the whole update cycle (the raw str() comparison
  this replaced never parsed, so this was a new failure path). Parse via a
  guarded _parse_slot_time helper that returns None instead of raising, matching
  the try/except pattern used by the other slot decoders.

- Normalise timestamps to the parsed instant in the signature so equivalent
  values in different formats (+0000 vs +00:00 vs Z) no longer register as a
  change; unparseable values fall back to their raw string so genuine changes
  are still detected.

Tests: octopus_slots_change gains cases for offset-format equivalence and
unparseable-timestamp handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(octopus): correct Test 3 docstring in test_octopus_run

The summary line still described the old behaviour ("only device refreshed").
Test 3 now asserts only saving sessions refresh on device_due while the
intelligent dispatch fetch follows the sensor cadence. Align the docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@Scholdan
Scholdan merged commit 6f1bb8c into main Jul 16, 2026
2 checks passed
@Scholdan
Scholdan deleted the sync/upstream-v8.45.2 branch July 16, 2026 16:31
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.

6 participants