feat(sunsynk): Sunsynk Connect cloud inverter component (read path)#4283
feat(sunsynk): Sunsynk Connect cloud inverter component (read path)#4283mgazza wants to merge 12 commits into
Conversation
Injected-token model: sunsynk_key IS the Bearer access token (SaaS edge function owns the RSA publicKey -> login -> token exchange/refresh server-side), so SunsynkAPI is a plain Bearer client with no request signing and no re-login on 401/403 - it just flags needs_reauth. Skeleton only: initialize/get_headers/request_get/is_alive + MockBase, styled on fox.py. Device discovery, publish_data, automatic_config and control are deferred to later tasks.
Adds get_device_list() (single-plant scoped, with defensive multi-plant fallback), get_plant_flow(), get_battery(), get_grid(), get_input() and get_output() to SunsynkAPI, plus fox.py-style _needs_refresh/_save_cache/ _load_cache caching primitives (static interval for device list, realtime interval for telemetry). TDD: 15 new tests added RED-first, now green alongside the 8 existing Task 8 tests (23/23 passing).
Sunsynk's API family returns HTTP 200 with {"success": false} on failure
(stale token, invalid request) rather than a non-2xx status. request_get_func
now checks the parsed body and treats success:false the same as a hard
failure (log, record_api_call, return None) instead of silently unwrapping
it into an empty "data" payload that got cached as if fresh - mirrors
deye.py's success-check discipline.
Also fixes a shared-clock bug in _get_realtime(): all 5 telemetry buckets
(flow/battery/grid/input/output) x all inverter SNs previously shared one
data_age["device_values"] timestamp, so fetching any one bucket/sn reset
the clock for every other one, letting a genuinely-stale entry be served
from cache unrefreshed. Freshness is now tracked per bucket+sn via a
dedicated age_key, independent of the shared device_values persistence key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h guard + skip warnings - Fix 1 (High): publish per-SN load_power in _publish_multi_inverter_sn, sourced from the per-SN output endpoint's AC output (pac/pInv) rather than plant flow's plant-aggregate loadOrEpsPower (which would double-count). Marked unconfirmed per Risk 6, with a Warn: log matching the existing per-SN sign caveat discipline. Without this, Task 11's automatic_config would have nothing to map to for multi-inverter customers, mirroring solis.py's per-device load_power wiring. - Fix 2 (Medium): extract _flow_signed_powers(flow) as the single source of truth for the VERIFIED battery_power/grid_power sign mapping, used by both _publish_single_inverter and _publish_site_summary instead of two copy-pasted implementations. - Fix 3 (Medium): _publish_battery_extras() now defaults `battery` to a module-level _UNFETCHED sentinel instead of None, so an already-fetched-and-failed battery (explicit None from the multi-inverter path) is no longer indistinguishable from "not fetched yet" - a failing battery endpoint was being hit twice per publish cycle. - Fix 4 (Low): warn when a multi-inverter device has no SN and is skipped. - Fix 5 (Low): warn when the plant-flow fetch fails in the multi-inverter branch, for parity with the existing single-inverter warning. Adds 6 new tests (load_power per-SN, battery refetch guard, missing-SN skip warning, flow-fetch-failure warning) on top of the existing 29; all pass unchanged.
Adds SunsynkAPI.automatic_config() (fox.py/deye.py/solis.py-style), wiring Predbat's base args to the per-SN entities publish_data() (Task 10) already emits - soc_percent, battery_power, grid_power, load_power, pv_power, battery_temperature and soc_max, plus slot1 charge/discharge control entity names for the not-yet-built control write path (Task 15). Emits a single structured Info: log line with the full arg map (spec 3.1) so the outcome is Loki-greppable. Also fixes MockBase.set_arg() to record into self.args (matching the real base's set_arg() in userinterface.py), which it never did before - needed so automatic_config() can be asserted against api.base.args in tests.
…for Sunsynk instances All sunsynk args were required: False with no required_or gate, so Components.initialize()'s have_all_args check was unconditionally True and SunsynkAPI started (and made live calls to api.sunsynk.net with a None bearer token) for every instance, Sunsynk customer or not. Sunsynk uses a single injected-token credential like teslemetry, so mark key required rather than adding a required_or (that pattern is for deye/solis's multi-auth-path components).
Adds the if __name__ == "__main__" CLI harness (fox.py/deye.py-style) for HA add-on / self-managed users who supply a Sunsynk Connect username+password directly, since SaaS mode never does its own login (the edge function owns the RSA login chain server-side and injects a plain Bearer token instead - see the Task 8 module docstring). The standalone login helper does publicKey -> RSA-encrypt password -> /oauth/token/new in Python, mirroring scratchpad/sunsynk_auth_spike.py's verified flow. The cryptography import is guarded inside _sunsynk_rsa_encrypt_password() (try/except ImportError with an actionable message) so importing sunsynk.py as a SaaS component never requires it - only the standalone login path does. Verified in an isolated subprocess with cryptography genuinely blocked via sys.modules. cryptography is added to requirements.txt - flagging for Trefor's sign-off since it's a native dependency (Rust/C extensions) and this requirements.txt is what the predbat-saas-images Docker build installs into the production engine image, so every customer pod picks it up even though only the standalone-auth HA add-on path uses it.
A stray substring match during an earlier find/replace corrupted the expected sign string's raw_key[:10] slice (RAWKEY1234 became RAWKEY12345678904), which happened to still fail the assertion since the production code correctly computes raw_key[:10] - so the bad fixture would have permanently failed this test rather than silently passing. Caught by re-running the suite after the first commit.
Final-review fixes for the Sunsynk cloud inverter component: - HIGH: automatic_config() sets inverter_type="SUNSYNK" but INVERTER_DEF had no "SUNSYNK" entry, so Inverter.__init__() crashed with ValueError for every sunsynk_automatic customer. Registered "SUNSYNK" as a copy of the "DeyeCloud" template (same cloud 6-slot-TOU control model), adjacent to the other cloud entries. - Low: publish_data()'s single-inverter branch passed a possibly-None sn into _publish_entity's sn.lower() with no guard, unlike the multi-inverter branch. Added the same missing-SN skip+Warn guard. - Low: run() awaited publish_data()/automatic_config() with no try/except, so one bad cycle could abort the whole run. Wrapped both calls, mirroring deye.py's per-poll error-wrapping discipline. Adds tests for all three fixes to tests/test_sunsynk.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tart Registers test_sunsynk.py's 57 pytest-style tests (via a new run_sunsynk_tests() wrapper, deye_publish-style) in unit_test.py's TEST_REGISTRY - CI only runs `unit_test.py --quick`, not pytest, so these were never actually exercised in CI. Drops the pytest dependency (pytest.raises -> try/except) so the module imports cleanly under the CI harness. Also wires SunsynkAPI.load_cached_data() (previously defined but never called - flagged by three reviewers) into run() on the first cycle, before the first get_device_list() poll, fox.py-style, so a Predbat restart resumes from the persisted device_list/device_values cache instead of cold-re-polling the Sunsynk Connect API immediately. Adds round-trip and run()-ordering coverage for the restore path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code reviewRan the review pipeline (CLAUDE.md-compliance, component-parity, bug-scan reviewers). Found 3 items — all addressed in
61 tests pass under both pytest and Note: control/write path is intentionally deferred (needs a live-account write-permission spike); per-SN signs/load are fenced as unconfirmed until a live multi-inverter site. 🤖 Generated with Claude Code |
|
I can help test this against a live Deye HV installation. My setup is a Deye 15 kW three-phase HV hybrid inverter with approximately 29.3 kWh of battery storage. The inverter is currently registered in Deye Cloud rather than Sunsynk Connect, but it appears relevant because both platforms use the same Deye hardware/API family. I also tested the recently added
This suggests that the telemetry keys or endpoint response format for my inverter differ from the current Would this Sunsynk component be suitable for testing with a Deye-branded account/inverter, or does it specifically require a Sunsynk Connect account? I would be happy to test this branch and provide sanitized API response structures or debug logs. It may be useful to add an optional debug mode that logs the available JSON keys from the flow, battery, grid, input and output endpoints without exposing tokens or credentials. |
Hi, It would be fantastic if you could help with testing deye initially, if you could email me (tdlj@tdlj.net) or reach out on Facebook we can discuss futher. Trefor. |
Summary
Adds a Sunsynk Connect cloud inverter component (
apps/predbat/sunsynk.py), styled onfox.py. This is the engine side of Predbat.com's Sunsynk support (SaaS side: Predictive-Cloud-Ltd/predbat-saas#1455). Read path only — control (write) is deliberately deferred pending a live-account write-permission spike.Sunsynk Connect is the Deye cloud family (the demo battery even reports
batt1Factory: "Bat_Deye_HV"), so this mirrorsdeye.py/solis.pyclosely.What it delivers (Tasks 8–13)
SunsynkAPI(ComponentBase, OAuthMixin)— Bearer client (injected token viasunsynk_key; the SaaS edge fn does the RSA login).request_getretries, flagsneeds_reauthon 401/403, and treats HTTP 200 +{success:false}as a failure (the Solarman/Deye family's real bad-response behaviour).publish_datawith verified sign mapping —battery_power = battPower if batTo else -battPower;grid_power = gridOrMeterPower if toGrid else -gridOrMeterPower(verified live). Single-inverter/site derive from the plantflowbooleans; multi-inverter uses per-SN endpoints (no double-count). Per-SN signs/load are fenced withTODO(risk5/6)+ runtime Warns until a live multi-inverter site confirms them.automatic_configset_arg map (Loki-greppable startup log) +run()loop.COMPONENT_LIST(keyrequired → only starts for Sunsynk instances, not fleet-wide),config.pysunsynk_*keys,INVERTER_DEF["SUNSYNK"](mirrorsDeyeCloud).--user/--pass/--plants) for HA add-on users — the one place batpred does its own RSA login;cryptographyimport is guarded so component-mode load never requires it.57 unit tests. No write/control path exists yet (grep-confirmed) — reads only.
For Trefor
cryptographytorequirements.txt— imported only by the standalone CLI login (guarded), never by the SaaS component. Flagging since it lands in the engine image.INVERTER_DEF["SUNSYNK"]copiesDeyeCloud; the control-specific fields are inert placeholders until the write path lands.🤖 Generated with Claude Code