feat(myenergi): Zappi and Eddi monitoring, auto-config and boost controls - #4667
Conversation
Design for a myenergi component supporting both the direct director API and the official 3rd party OAuth API, with Zappi and Eddi monitoring, automatic wiring of their energy sensors, and boost controls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Introduces the myenergi module with the shared MyEnergiDevice dataclass and normalisers for both the direct and cloud API payload shapes, so that the transports added next can share every layer above the wire format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the interface both transports implement, and lands the controls that are out of scope for this release as single-warning stubs so the follow-up work is purely additive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Digest-authenticated access to the director API, following the X_MYENERGI-asn header to the account's active server and treating its absence as a credential failure rather than a transport error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review caught the nested test helper failing 100% interrogate docstring coverage, which the pre-commit hook set does not check for locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_resolve_asn had no timeout/status handling of its own, so a network failure or director outage during the cold-start request escaped as a raw aiohttp exception, or worse, was misdiagnosed as bad credentials when a non-200 error page happened to be missing the ASN header. It now applies the same status/timeout handling as _request, checking the status code before treating a missing header as a credential failure. Also switches the reason values passed to record_api_call to the vocabulary predbat_metrics.record_api_call documents and every other component already uses (auth_error, connection_error, server_error, client_error), replacing this task's non-standard unauthorised/timeout/http_<status> strings so failures aggregate correctly by reason. Adds direct-transport coverage for 401, non-200, timeout, ASN migration mid-session, and the missing-header-vs-401 precedence rule, plus the two new _resolve_asn failure paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bearer-token access to api.s18.myenergi.net with a cached device list and per-device-class boost bodies, so a Zappi never receives durationMinutes and an Eddi never receives mode or parameters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on label Wraps the cloud transport's JSON decode in the same try/except pattern as the rest of _request, so an undecodable 200 body (an HTML error page slipping through content_type=None) or a decoded-but-non-dict device list surfaces as MyEnergiApiError with reason="decode_error" instead of a raw ValueError or AttributeError reaching Task 5's MyEnergiError-only catch. Moves the success record below the decode so a failed decode is no longer counted as a successful call. Also fixes MyEnergiDirectTransport._request's aiohttp.ClientError handler, which still recorded reason="client_error" after Task 3's review settled on "connection_error" for this case everywhere else (_resolve_asn and both cloud-transport branches already used it). Strengthens test_cloud_sets_bearer_header to prove the token is re-read on every request, not just captured once, and adds direct assertions on the record_api_call reason label for every failure branch in both transports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MyEnergiDirectTransport._request had the same undecodable-body hole the cloud transport's decode fix closed last round: response.json(content_type=None) disables aiohttp's content-type guard, so a captive portal or misconfigured proxy in front of the resolved ASN host could return a 200 with a body that raises json.JSONDecodeError, escaping as a raw ValueError instead of MyEnergiApiError. The direct transport is the default path for self-hosted users and Task 5's run loop depends on only MyEnergiError ever leaving a transport, so this could not stay deferred. _resolve_asn never decodes a body (it only reads the ASN response header), so it needed no change. fetch_devices already guards the decoded shape (isinstance(payload, list) / isinstance(group, dict)), so no redundant shape guard was added here - only the decode path was open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selects a transport from the configured credentials, polls on the component base cadence and publishes per-device entities. A failed poll returns False without republishing, so the last good reading survives a transient outage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ponent test initialize() has two distinct "no transport" branches - direct and oauth - and only the direct one had an assertion. Add the oauth case so both paths that leave self.transport None are actually exercised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_init_oauth() overwrites self.auth_method with its own "oauth"/"api_key" vocabulary, so the direct branch was logging "starting with the api_key transport" regardless of what the user configured. Keep the user-facing value in its own auth_method_config attribute instead. check_and_refresh_oauth_token()'s return value was discarded, unlike every other OAuth component in the codebase. When a token needs re-authorisation (oauth_failed), the poll now stops instead of hitting the API every cycle with a dead token and never surfacing the problem. Also: assert the boost switch's "on" state and the charging binary sensor's value (previously only ever exercised against an "off"/falsy fixture), cover fetch_devices() returning an empty list (the warn branch and the "must not wipe existing devices" behaviour), and share one STATUS_CHARGING constant across the state tables and publish_data() instead of repeating the literal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the COMPONENT_LIST entry with required_or on the two credential sets so the component only starts when one transport is fully configured, plus the matching APPS_SCHEMA keys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…check, required_or comment The registered display name was the only non-descriptive one in COMPONENT_LIST and was leaking into user-facing "Initialising ..." log lines as bare "myenergi" - changed to "myenergi Zappi/Eddi" to match sibling entries. The registration test only checked that every declared arg matches an initialize() parameter, not the reverse, so a parameter added to initialize() without a matching args entry would pass silently; added the missing direction. Documented why required_or gates on api_key/key, matching the precedent set by deye and solis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zappi session energy sensors are set as a list on car_charging_energy so several chargers sum, and the first Eddi feeds iboost_energy_today. Uses set_arg_auto so an explicit apps.yaml value is reported rather than silently replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…set_arg_auto The two-Zappi/two-Eddi test now inserts devices out of serial order so it can only pass if automatic_config() genuinely sorts by serial - the previous version happened to match dict insertion order and would not have caught a missing sort. automatic_config() itself now checks device.kind == DEVICE_KIND_EDDI before picking "the first Eddi", instead of treating any non-Zappi as one, so a future third device kind cannot be silently wired into iboost_energy_today. Added tests for an Eddi-only site, for set_arg_auto's apps.yaml-override reporting (previously indistinguishable from plain set_arg under MockBase), and strengthened the disabled/runs-once tests to assert the poll actually reached the wiring code rather than only that nothing changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Boost switches with a companion number entity for the amount, queued onto the run loop so API calls never run on the event thread. A Zappi outside Eco or Eco+ is refused locally rather than issuing a call myenergi would reject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Standalone CLI for exercising either transport against the live API, plus the components and apps.yaml documentation, including the iboost_today limitation that follows from the session-scoped energy sensors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… gate
The CLI printed OAuthMixin's internal auth_method vocabulary ("api_key") instead
of the user-facing auth_method_config ("direct") that run() already uses, and
ignored --token-hash on its own when picking a transport. The docs described
poll_seconds as rounding up rather than to the nearest multiple of 60, implied
token_hash alone is a usable OAuth config despite the required_or activation
gate, implied car_charging_hold defaults off, and overstated how often the
not-implemented-control warnings repeat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
There was a problem hiding this comment.
🟡 Changes recommended
The myenergi component activation gate currently prevents valid OAuth refresh-only configurations (token_hash-only) from ever constructing the component, and the cloud transport’s boost path should reject unsupported device kinds rather than treating everything as Eddi.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new pluggable myenergi component to Predbat to monitor Zappi (EV charger) and Eddi (hot water diverter) devices, auto-wire their energy/plug-status sensors into Predbat’s optimiser inputs, and expose boost controls—supporting both the direct digest API and the official cloud OAuth API behind a shared transport interface.
Changes:
- Introduces
apps/predbat/myenergi.pyimplementing device normalisation, two transports (direct + cloud), publishing, auto-config, boost controls, and a CLI harness. - Registers the new component + config keys, and adds comprehensive unit tests wired into the existing test runner.
- Updates shipped templates to accept the additional Zappi plug state
ev ready to charge, plus documentation and spellcheck dictionary updates.
File summaries
| File | Description |
|---|---|
| templates/teslemetry.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/tesla_powerwall.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/sunsynk.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/solis_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/solax_sx4.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/solax_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/solaredge.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/solar_assistant_growatt_sph.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/solar_assistant_growatt_spa.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/sofar.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/sofar_modbus.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/sigenergy_sigenstor.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/sigenergy_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/luxpower.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/hanchu_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/givenergy_givtcp.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/givenergy_ems.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/givenergy_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/ginlong_solis.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/ge_cloud_octopus_standalone.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/fronius.yaml | Adds ev ready to charge to car-plug planned-response values (commented template list). |
| templates/fox_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/ep_cube_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| templates/enphase_cloud.yaml | Adds ev ready to charge to car-plug planned-response values (active list). |
| requirements.txt | Pins aiohttp>=3.12 to support digest middleware usage. |
| docs/superpowers/specs/2026-08-23-myenergi-integration-design.md | Adds a detailed design spec for the integration. |
| docs/components.md | Documents the new component, options, entities, controls, and limitations. |
| docs/apps-yaml.md | Documents new myenergi_* configuration keys and behaviour. |
| apps/predbat/unit_test.py | Registers the new myenergi test suite in the test runner. |
| apps/predbat/tests/test_myenergi.py | Adds a comprehensive test suite for normalisation, transports, publishing, auto-config, controls, and template acceptance. |
| apps/predbat/myenergi.py | Implements the myenergi component, transports, publishing, auto-config, controls, and CLI harness. |
| apps/predbat/config.py | Adds new config schema keys for the myenergi component. |
| apps/predbat/components.py | Registers the myenergi component in COMPONENT_LIST. |
| .cspell/custom-dictionary-workspace.txt | Adds new domain words used by the integration and docs. |
Review details
- Files reviewed: 33/35 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # transport's local hub credential, key is the cloud transport's access token. | ||
| # Without this the component would start for every instance since all | ||
| # individual args are optional to allow either auth mode. | ||
| "required_or": ["api_key", "key"], |
| if device.kind == DEVICE_KIND_ZAPPI: | ||
| body = {"mode": "normal", "parameters": {"energy": _boost_units(amount)}} | ||
| if target_time: | ||
| body = {"mode": "smart", "parameters": {"energy": _boost_units(amount), "targetTime": target_time}} | ||
| else: | ||
| body = {"durationMinutes": _boost_units(amount)} | ||
| await self._request("POST", "/devices/{}/boost".format(device.device_id), body=body) | ||
| return True |
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>
Adds a
myenergicomponent providing Zappi (EV charger) and Eddi (hot water diverter) monitoring, automatic wiring of their energy sensors into the optimiser, and boost controls.Two APIs, one interface
myenergi exposes two unrelated APIs, and Predbat needs both:
director.myenergi.net, redirecting viaX_MYENERGI-asnapi.s18.myenergi.netBoth sit behind a
MyEnergiTransportABC and normalise to oneMyEnergiDevicedataclass, so publishing, auto-config, controls and tests are written once.myenergi_auth_methodselects between them, defaulting todirect.The cloud transport reuses
oauth_mixin.pyexactly asfox.py/deye.py/solis.pydo. It cannot be exercised end to end until myenergi issue partner credentials; it is built against the published OpenAPI schema and unit-tested on recorded payloads.What it does
sensor.predbat_myenergi_{kind}_{serial}_*, so multi-device sites work.myenergi_automatic, default on) — Zappi session energy intocar_charging_energyas a list (several chargers sum), pluscar_charging_plannedfrom the plug-status sensor; the first Eddi intoiboost_energy_today. All viaset_arg_auto(), so an explicit apps.yaml value is reported rather than silently replaced.False. Libbi, webhooks, managed mode and super schedules are out of scope.python3 myenergi.py --hub-serial … --api-key …exercises either transport against a live account without running Predbat.Configuration
Get the key from myaccount.myenergi.com → Advanced → API Key; the hub serial is on the hub and in the app.
Notes for reviewers
car_charging_plannedtemplates. The shipped templates matchsensor.myenergi_zappi_[0-9a-z]+_plug_status, which targets the third-party ha-myenergi HACS integration and does not match this component's entity names — so charge planning silently fell back to thecar_charging_thresholdheuristic.automatic_config()now wires it, and'ev ready to charge'(pilot states C1/D1) was added tocar_charging_planned_responsein the 24 templates carrying the myenergi vocabulary. It was the only connected pilot state missing while less-ready states were already accepted. Nothing underapps/predbat/readstemplates/, so existing installs are unaffected.aiohttp>=3.12is now pinned.DigestAuthMiddlewareandClientSession(middlewares=…)require it; the direct transport guards on availability with an actionable message rather than failing with a rawAttributeError.myenergi_poll_secondsis capped at 30 minutes so a large value cannot tripcomponents.py's 60-minute staleness check now that the success timestamp only advances on cycles that actually polled.Known limitation
Session energy sensors reset to zero at session end. Predbat's incrementing-counter cleanup handles that correctly —
iboost_todayandcar_charging_energyboth come out right. Howeverclean_incrementing_reverseonly treats a drop as a reset when it exceeds ~1 kWh, so a session ending below that can be under-counted, in both figures. Documented indocs/components.md.Testing
apps/predbat/tests/test_myenergi.py— 74 assertions covering normalisation, both transports (including every error path and therecord_api_callreason labels), transport selection, publishing, auto-config, controls, the stubs, and last-good-reading retention. Registered asmyenergiinTEST_REGISTRY.Full
./run_all --quickpasses.🤖 Generated with Claude Code