Skip to content

feat(sunsynk): Sunsynk Connect cloud inverter component (read path)#4283

Open
mgazza wants to merge 12 commits into
mainfrom
feat/sunsynk-cloud-inverter
Open

feat(sunsynk): Sunsynk Connect cloud inverter component (read path)#4283
mgazza wants to merge 12 commits into
mainfrom
feat/sunsynk-cloud-inverter

Conversation

@mgazza

@mgazza mgazza commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a Sunsynk Connect cloud inverter component (apps/predbat/sunsynk.py), styled on fox.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 mirrors deye.py/solis.py closely.

What it delivers (Tasks 8–13)

  • SunsynkAPI(ComponentBase, OAuthMixin) — Bearer client (injected token via sunsynk_key; the SaaS edge fn does the RSA login). request_get retries, flags needs_reauth on 401/403, and treats HTTP 200 + {success:false} as a failure (the Solarman/Deye family's real bad-response behaviour).
  • Device discovery + telemetry — single-plant scoped; per-entry cache freshness.
  • publish_data with verified sign mappingbattery_power = battPower if batTo else -battPower; grid_power = gridOrMeterPower if toGrid else -gridOrMeterPower (verified live). Single-inverter/site derive from the plant flow booleans; multi-inverter uses per-SN endpoints (no double-count). Per-SN signs/load are fenced with TODO(risk5/6) + runtime Warns until a live multi-inverter site confirms them.
  • automatic_config set_arg map (Loki-greppable startup log) + run() loop.
  • RegistrationCOMPONENT_LIST (key required → only starts for Sunsynk instances, not fleet-wide), config.py sunsynk_* keys, INVERTER_DEF["SUNSYNK"] (mirrors DeyeCloud).
  • Standalone CLI harness (--user/--pass/--plants) for HA add-on users — the one place batpred does its own RSA login; cryptography import is guarded so component-mode load never requires it.

57 unit tests. No write/control path exists yet (grep-confirmed) — reads only.

For Trefor

  • Adds cryptography to requirements.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"] copies DeyeCloud; the control-specific fields are inert placeholders until the write path lands.

🤖 Generated with Claude Code

mgazza and others added 12 commits July 22, 2026 10:19
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>
@mgazza

mgazza commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Code review

Ran the review pipeline (CLAUDE.md-compliance, component-parity, bug-scan reviewers). Found 3 items — all addressed in 0892f418:

  1. Tests never ran in CI (blocker). .github/workflows/code-quality.yml runs unit_test.py --quick (not pytest), which executes only run_*_tests functions registered in TEST_REGISTRY. The 57 Sunsynk tests were pytest-style and unregistered — so they didn't execute in CI at all (same gap DEYE feat(deye): DEYE Cloud inverter integration (supersedes #3917) #4274 needed a follow-up commit for). Fixed: dropped the pytest dependency, added run_sunsynk_tests(my_predbat) (mirroring run_deye_publish_tests), and registered it in unit_test.py. Verified: unit_test.py --test sunsynk PASSED, and full --quick green.
  2. Reboot cache never restored (real, flagged by 3 reviewers). _save_cache() persisted device_list/device_values every cycle but _load_cache()/SUNSYNK_CACHE_KEYS were never called — every restart cold-re-polled the Sunsynk API (no reboot-storm protection, unlike fox.py). Fixed: added load_cached_data() called from run() on the first cycle before get_device_list(), mirroring fox.py, with a round-trip + ordering tests.
  3. Nested fake_import test helper missing a docstring (interrogate 100% rule). Fixed.

61 tests pass under both pytest and unit_test.py. Sign-mapping, None-handling, envelope parsing, OAuthMixin ordering, key-required gating, and INVERTER_DEF["SUNSYNK"] all verified correct by the reviewers.

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

@Kripits

Kripits commented Jul 22, 2026

Copy link
Copy Markdown

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 deye.py component. Authentication and inverter discovery work, but telemetry does not:

  • The inverter is discovered successfully.
  • Predbat creates the expected SOC, battery power, grid power, PV power, load power and temperature entities.
  • All telemetry entities remain at 0.
  • Last Updated remains Never updated.
  • /config/battery returns:
    config point not supported
  • My existing local Home Assistant Deye integration reports valid live values from the same inverter.

This suggests that the telemetry keys or endpoint response format for my inverter differ from the current deye.py assumptions.

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.

@springfall2008

Copy link
Copy Markdown
Owner

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 deye.py component. Authentication and inverter discovery work, but telemetry does not:

  • The inverter is discovered successfully.
  • Predbat creates the expected SOC, battery power, grid power, PV power, load power and temperature entities.
  • All telemetry entities remain at 0.
  • Last Updated remains Never updated.
  • /config/battery returns:
    config point not supported
  • My existing local Home Assistant Deye integration reports valid live values from the same inverter.

This suggests that the telemetry keys or endpoint response format for my inverter differ from the current deye.py assumptions.

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants