Skip to content

feat(sunsynk): add Sunsynk Connect cloud inverter integration - #4565

Merged
springfall2008 merged 25 commits into
mainfrom
feat/sunsynk-cloud-integration
Aug 18, 2026
Merged

feat(sunsynk): add Sunsynk Connect cloud inverter integration#4565
springfall2008 merged 25 commits into
mainfrom
feat/sunsynk-cloud-integration

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Adds a Sunsynk Connect cloud component, modelled on the existing DEYE and Fox integrations, so a Sunsynk hybrid inverter can be monitored and battery-controlled by Predbat with no local hardware or Modbus dongle access — on both the self-hosted add-on and Predbat.com.

Closes nothing; complements #4560 (the same zero-length-window hole found in deye.py while building this).

What's here

File
apps/predbat/sunsynk.py The component (1,566 lines) plus a standalone diagnostics CLI
apps/predbat/sunsynk_const.py Endpoints, regions, TOU field templates, telemetry maps, per-field wire serialisation, and a pure-Python RSA helper
apps/predbat/tests/test_sunsynk_*.py Seven suites (~3,400 lines) mirroring the DEYE coverage
config.py / components.py / unit_test.py INVERTER_DEF["SunsynkCloud"], APPS_SCHEMA, registration, test registry
docs/ Component reference, setup walkthrough, apps.yaml keys

Design notes

No new dependency. Sunsynk RSA-encrypts the account password at login. Rather than add cryptography — which would put a Rust-built wheel on the armv7/armhf add-on targets — this implements PKCS#1 v1.5 public-key encryption directly (~50 lines, no private keys, no timing-sensitive operations), verified against a fixed test key for DER parsing, padding structure, round-trip recovery, randomisation and oversize rejection.

Three auth methods. password (RSA, default), password_legacy (the pre-2025 plaintext login, for regions still serving it) and oauth (token injected by Predbat.com). The RSA path deliberately never falls back to plaintext — that would turn an externally-triggerable public-key failure into a credential leak against a TLS-intercepting middlebox.

Control model. Predbat's charge and export windows become Sunsynk's six sequential TOU slots (sellTimeN / sellTimeNPac / capN / timeNon) plus a global work mode, applied by read-modify-write of the whole settings object so every installer setting Predbat does not own survives verbatim. The write is diff-gated (zero network I/O when the plan is unchanged), clamped to the inverter's own batteryLowCap floor at the API boundary, and skipped entirely if the settings read fails — there is no baseline to modify, and writing from an empty one would drop the user's safety settings.

The guiding rule for the many undocumented details: assume DEYE for semantics, never for encoding. Sunsynk inverters are rebadged DEYE hardware, so the registers — six TOU slots, three work modes, what each does, Ah-not-kWh capacity — transfer. The cloud wrapper does not: field names, enum representations and value types come only from Sunsynk sources.

⚠️ Control is opt-in, and here's why

Sunsynk publishes no API documentation. Every wire-format detail here is inferred from two third-party clients (solarsynkv3 and synkctl), and nobody on the project has a Sunsynk account to verify against.

So sunsynk_control_enable defaults to false. Monitoring works immediately; writing to the inverter needs an explicit opt-in. Inferred values carry # VERIFY@SPIKE, api_debug defaults on so a tester can capture raw traffic (credentials redacted), and python3 sunsynk.py --help gives a standalone diagnostics CLI that drives the real run() loop without Predbat around it.

A first tester should confirm, in this order:

  1. Which login their region serves — --auth-method password vs password_legacy.
  2. The battery_power sign convention (which direction is charging). Wrong here inverts the whole model.
  3. sysWorkMode's enum values. Assumed 0/1/2 for selling-first / zero-export-to-load / zero-export-to-CT, matching DEYE's mode order — but that ordering is an encoding claim, not a semantic one, and getting it wrong silently swaps export for charge.
  4. Whether timeNon and the day flags come back as bare booleans or strings.
  5. Whether battery capacity is per-battery or pack total, and whether the derived kWh matches the real battery — cell count is inferred as round(chargeVolt / 3.55), which is exact for common stacks but yields 23 cells instead of 24 at a gentler 3.45 V/cell.

Only once 1–4 are confirmed should sunsynk_control_enable: true be documented as safe.

Testing

7 test suites, all green, plus ./run_all --quick clean with the 20-scenario random regression matching baseline across 320 fields.

Coverage is at the level that matters rather than only at the mock boundary: _request is driven through a mocked aiohttp.ClientSession (retry, backoff, the 200-with-failure auth path, refresh-and-retry); apply_settings is driven through the real change-detection gate with only the transport faked; the discovery-termination tests use a SIGALRM watchdog so a hang fails rather than stalls; and run() is exercised with control enabled and a real apply_settings underneath, which is what catches "writes to the inverter before there is a plan".

🤖 Generated with Claude Code

springfall2008 and others added 22 commits August 17, 2026 12:17
Design for a Sunsynk Connect cloud component modelled on the existing
DEYE and Fox integrations: standalone sunsynk.py, pure-Python RSA login
(no cryptography dependency), three auth methods, multi-inverter
discovery, and 6-slot TOU control via the settings blob.

Sunsynk is rebadged DEYE hardware, so the spec adopts an explicit rule
for the many undocumented details: assume DEYE for semantics, never for
encoding. Register-level behaviour transfers; wire format does not.

Nobody on the project has a Sunsynk account, so the build is defensive
by design - debug tracing on by default, a standalone CLI for dumping
raw traffic, and control gated behind an opt-in until field-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine TDD tasks from constants through to the diagnostics CLI and docs,
each with a failing test, real implementation code, a run command and a
commit. Seven test modules mirror the DEYE suite's coverage.

The RSA helper's code and its test vectors were verified before writing:
a fixed 1024-bit key parses, round-trips four passwords, rejects an
oversize one and randomises its padding.

Ends with a verification checklist for the first tester, ordered by
cost-if-wrong, since every wire-format detail is inferred rather than
documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_init_oauth owns self.auth_method, so passing a collapsed ternary would
rewrite password_legacy to password and make the plaintext login
unreachable. Pass auth_method straight through, as deye.py does.

MockBase is the base object a component is built around, not a mixin.
Inheriting it put ComponentBase ahead of it in the MRO, so the CLI would
hit an unset self.base. Follow deye.py's harness pattern instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Endpoints, regions, the six-slot TOU field templates, telemetry maps and
per-field wire serialisation for the Sunsynk Connect API. Inferred encoding
values carry VERIFY@SPIKE; no live hardware has confirmed them.
Sunsynk RSA-encrypts the account password at login. Implemented directly
rather than adding a cryptography dependency, which would put a Rust-built
wheel on the armv7/armhf add-on targets. Public-key encryption only.

Tested against a fixed 1024-bit key: DER parsing, padding structure,
round-trip recovery, randomisation, and oversize rejection.
_read_tlv sliced data[offset:offset + length] without checking the declared
length fits in the remaining data. Python slicing truncates silently rather
than raising, so a truncated DER key "parsed" successfully with a corrupted
trailing field. On this key, truncating by 1-3 bytes produced exponent 256,
1 or 0 with no exception; exponent 1 would send the password effectively
unencrypted, since rsa_encrypt_pkcs1v15 would compute pow(block, 1, modulus).

Added bounds checks covering the fixed-length header, the long-form length
bytes, and the final value slice, and extended
test_parse_rsa_public_key_rejects_rubbish with near-full-length truncation
cases ([:-1], [:-2], [:-3]) that reproduce the corrupted-exponent bug and
would have caught it.
…lows

RSA login (default), the pre-2025 plaintext login (opt-in) and the
Predbat.com injected-token path. Transport retries with backoff, detects
Sunsynk's 200-with-failure auth errors from the body, and returns {} on
any failure so callers fail closed.

The RSA path never downgrades to plaintext: that would turn an
externally-triggerable public-key failure into a credential leak.
… retry

_request() looped transport attempts with `for attempt in
range(SUNSYNK_RETRIES)`. When a body-level auth error was detected on the
FINAL attempt, fetch_token() would succeed but the `continue` had nowhere
left to go - the for loop was exhausted, so execution fell through to
`return None` and the successful refresh was thrown away.

Switch to a `while attempt < SUNSYNK_RETRIES` loop where `attempt` only
counts genuine transport failures (non-200 / exception), never the
refresh branch. A refresh now always earns its own retry, regardless of
how much of the transport budget was already spent, while `refreshed`
still caps it to at most one extra pass so total work stays bounded.

Also adds real coverage of _request() itself (transport retries, backoff,
the None/{} success contract, and the auth-refresh path), since every
existing sunsynk test replaced _request() with a fake rather than
exercising it - the gap that let this bug through undetected. The new
regression test was confirmed to fail against the pre-fix code before
the fix was applied.
Pages /inverters with an optional serial filter, polls the four realtime
endpoints per inverter, and derives soc_max and battery_rate_max from an
amp-hour capacity and a pack voltage inferred from the BMS charge target.

Absent fields stay absent rather than publishing an invented zero, and a
capacity with no derivable voltage reports nothing rather than a guess.
…ess page

Round 1 review found get_device_list could loop forever: a page whose
entries never carry sn left the serial count stuck at 0 while a
server-reported total stayed above it, and a bogus multi-million total
ran millions of calls before terminating even when every page carried
real serials. Overlapping pages also duplicated serials unconditionally.

Fixes with progress-based termination: dedupe into a set while keeping
first-seen order (automatic_config builds arg lists positionally from
it), break as soon as a page contributes zero new serials, and add a
SUNSYNK_MAX_DISCOVERY_PAGES hard cap as defence in depth against a total
that is corrupt or never satisfied, logging a warning if it is hit.

Also adds direct coverage for fetch_device_detail's asymmetric write
(an omitted or failed re-fetch must never clear a previously known
rated power), and documents - without changing - the cell-count
rounding assumption in nominal_pack_voltage, which is exact only when
the BMS charge target is itself an exact multiple of 3.55V/cell.
Maps Predbat's charge and export windows onto sellTimeN/sellTimeNPac/capN/
timeNon plus a global work mode, applied by read-modify-write so every
installer setting Predbat does not own survives verbatim.

The global mode follows the window active now, not a static precedence,
because Sunsynk has only one. Writes are diff-gated, clamped to the
inverter's own SOC floor, and skipped entirely if the read fails.
- build_tou_slots now skips a zero-length window (start == end, compared
  normalised) instead of leaving an unterminated action segment - an
  enabled charge/export window whose time fields have not yet been set
  by update_local_schedule was becoming a multi-hour full-power grid
  charge.
- note_settle decodes both sides through encode_setting before comparing,
  so a read-back that renders time{n}on as a string ("true"/"1") no
  longer looks like a permanent mismatch against a healthy inverter.
- build_settings_payload returns {} when there is no baseline in
  device_settings for the serial, and apply_settings treats an empty
  payload as "skip" - the guard against an owned-keys-only payload now
  lives in the function that builds it, not only in one caller.
- apply_settings gates on an owned-fields-only diff (schedule,
  current_soc, now_minutes and the cached battery_reserve_min) BEFORE
  any network call, so a no-op tick costs zero GETs and zero POSTs
  instead of reading and comparing the whole settings blob every cycle -
  which also means an unowned field that changes on every read (e.g. a
  server timestamp) can no longer force a write every tick. Only a real
  change (or force=True) triggers the read-modify-write, immediately
  before the POST.

Also corrected test_payload_clamps_to_the_inverter_soc_floor, which
exercised the hold_charge fallback (reserve, not the requested target)
rather than a genuine sub-floor charge target.
Sensors carry units and are omitted entirely when underivable, so Predbat
is never pointed at an entity that never appears. Control entities use
HH:MM:SS to match INVERTER_DEF, and the reserve entity deliberately
publishes what Predbat wrote rather than the inverter floor, to avoid a
write-read-mismatch retry storm.

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

The write-button handler passed force=True, bypassing apply_settings'
applied-payload change-detection gate on every routine per-cycle button
press (INVERTER_DEF time_button_press), not only when the plan changed.
deye.py hit this exact bug first: PR #4371 (3e1de75) measured 40 button
presses producing 36 byte-identical control orders over two hours on a
live site once the button forced the write. Sunsynk's write button now
calls apply_schedule(sn) unforced, same as deye.py's fix.

Also extends the publish/omit tests to cover the battery_voltage
telemetry leaf and the battery_reserve_min rating, which were previously
implemented but untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One file per tier so each gets an independent storage.age() clock, seeded
at startup so the refresh cadence survives a restart. Telemetry is not
cached; HA already retains the last published value.

The applied-payload cache restores only within a 15-minute window: it
asserts the inverter still holds what Predbat wrote, and after a longer
outage that assertion is false and the next write must not be skipped.

Fixes a call-site bug versus the real Storage.load(module, filename)
signature (no `default` kwarg) that would have made restore_state() a
silent no-op in production while passing tests against a looser fake.

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

restore_state() called self.storage.age(...) directly with no try/except,
unlike load_cache/save_cache. A raising storage backend or self.storage
being None (the normal standalone-CLI state, per mock_base.py) propagated
an unhandled exception out of the method. Worse, _cache_restored was set
True before any of this ran, so the outer run loop survived the crash but
restore_state() then permanently no-op'd on every later call - one
transient storage hiccup on first boot silently disabled cache restoration
for the life of the process.

Adds an age_cache(name) helper alongside load_cache/save_cache that fails
soft the same way. Moves the _cache_restored assignment to the end of the
method and gates it on a new _restore_had_error flag that load_cache/
age_cache set when they actually catch a storage exception (as opposed to
a cache simply not existing yet, which is not an error) - so a failed
attempt is retried on a later call once storage recovers, while a
successful attempt still sets the guard so restore can never run twice and
clobber live state with stale cached state.

Also tightens the test double's FakeStorage.load/save signatures to match
the real Storage component exactly (no `default` kwarg on load; format/
expiry on save), per the reviewer's note that the previous, more
permissive fake was the exact mechanism that had masked the earlier
default=None bug. Adds regression tests for a raising storage backend and
for storage being None, both confirmed to fail against the pre-fix code.

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

Registers SunsynkCloud in INVERTER_DEF (identical capability flags to
DeyeCloud, because the same registers sit behind both clouds), adds the
sunsynk_* apps.yaml schema, and wires the component into COMPONENT_LIST
gated on having at least one usable auth path.

automatic_config maps an arg only when every discovered inverter reports
the underlying value, warning the user to set the rest in apps.yaml.

run() explicitly returns True/False rather than falling through to an
implicit None on success, which ComponentBase treats identically to a
failure - without it the component would never clear its startup "first"
flag and would stay in the ever-growing 60s-to-128min backoff forever.

apply_settings/apply_schedule gain a settings_fresh flag so a tick where
the config tier happens to fire AND a genuine plan change lands reads the
settings object once, not twice - the run loop passes it through only
when refresh_config actually ran this tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…config, guard its cache save

Round-1 review findings on the Task 8 run loop:

1. (CRITICAL) settings_fresh let apply_settings reuse a cached settings
   baseline instead of reading fresh before a write. The unguarded case was
   a STALE, non-empty cache: fetch_settings only updates device_settings on
   a successful read, so a per-serial config-tier read failure left a stale
   baseline with no signal that it hadn't just refreshed, and restore_state
   seeds device_settings from the config cache with no age bound at all
   (unlike the control cache's SUNSYNK_RESTORE_MAX_CONTROL). Traced through
   run() with two inverters and a failing config read for one of them, this
   could revert a user's phone-app change and lower the battery's
   protective floor. Removed entirely - apply_settings/apply_schedule/run()
   revert to always reading immediately before a write. The saving
   (~10 GETs/day/inverter, ~2% of daily calls) doesn't pay for weakening a
   safety invariant on the one call that writes to the battery.

2. test_run_first_cycle_polls_and_publishes discarded run()'s return value,
   so a regression of the "must return True/False" fix (Task 8's headline
   catch) would go unnoticed. Now asserts the return value, plus new cases
   for a login failure and an empty device_list both returning False.

3. refresh_config let fetch_settings overwrite device_settings in place
   before comparing old vs new, so an external change (e.g. the phone app)
   was silently absorbed instead of triggering note_external_change - the
   design spec's named mitigation for the unavoidable read-modify-write
   race. Fixed by capturing each inverter's previous baseline before the
   read.

4. refresh_config called save_config() unconditionally, so a tick where
   every read failed re-stamped a possibly days-stale on-disk cache as
   fresh; after a restart the config tier would then be skipped for a full
   TTL while running on stale settings. Mirrors deye.py's got_any pattern.

Minor: publish_schedule_settings_ha now runs every tick, not just first
(matches deye.py and the spec); the duplicated empty-schedule literal is
now one _empty_schedule() helper; automatic_config uses set_arg_auto so an
apps.yaml override gets logged once instead of silently discarded.

New tests for all of the above were written first and confirmed to fail
against the pre-fix code (verified via git stash against the parent
commit), then confirmed to pass once each fix landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI logs in, dumps detail, telemetry and the settings object, and can
build a harmless self-use payload and offer to send it. --auth-method lets
a tester establish which login their region serves in one command.

Nobody on the project has a Sunsynk account, so this is the tool that makes
remote verification of the inferred wire format possible. Docs cover the
component config table, an inverter-setup walkthrough distinct from the
existing local-Modbus SunSynk integration, and the apps.yaml keys - all
stressing that control_enable defaults off, password never silently
downgrades to password_legacy, writes land via the dongle's next poll, and
the phone app and Predbat can race on the single whole-object endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fix round 1 on the Sunsynk diagnostics CLI, from review:
- input() at the write-test confirmation was unguarded, so a closed or
  redirected stdin (SSH, CI, a container with no TTY) raised an uncaught
  EOFError and killed the CLI with a traceback instead of the clean "not
  sent" it already produced for a typed "n". Now EOFError and
  KeyboardInterrupt are both caught and treated as "no". This is the only
  verification tool a remote tester has, so it must survive a
  non-interactive channel.
- _build_sunsynk passed the real args into a second initialize() call after
  ComponentBase.__init__ already ran it once with all-default args via the
  constructor - harmless, but it printed a duplicate "SunsynkAPI
  initialising" pair before the CLI had even reported which region it is
  using. Now built in one call, matching deye.py's _build_deye.
- Removed the dead `client.device_list = [sn]` per-serial assignment in
  run_cli; none of the methods it calls read device_list, they all take sn
  directly.

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

run() applied a schedule for every inverter on every non-first tick, and seeded
the first tick from an empty schedule. Two consequences, both reproduced:

- With sunsynk_control_enable true, tick 2 posted a full settings object built
  from control entities Predbat had never written - sellTime1..6 replaced by the
  filler times, cap1..6 forced to 20, peakAndVallery flipped on, the day flags
  turned on and sysWorkMode rewritten - wiping the user's own time-of-use
  programme on first startup, before there was any plan.
- dashboard_item reaches set_state_wrapper, so publishing the empty first-cycle
  seed overwrote Predbat's live plan in the control entities. A restart at 03:00
  inside a 02:00-05:00 charge left charge_enable off and a 00:00 window, and the
  next tick turned grid charge off mid-window.

Mirror deye.py: read the control entities on every tick including the first, and
only re-apply for inverters whose write button has been pressed (control_active,
until now a vestigial attribute that was never added to or read).

Also, all DEYE-parity gaps from the same review:

- redact() rewrote top-level keys only, but _request traces the whole {code, msg,
  success, data} envelope and the login response nests its tokens inside `data`,
  so the bearer token was logged verbatim by a debug trace that is on by default
  precisely so testers paste it into issue reports. Now recursive over dicts and
  lists, as deye.py's is.
- oauth mode never refreshed its injected token: run() now calls
  check_and_refresh_oauth_token(), and a body-level auth failure goes through a
  reauthenticate() helper that tries handle_oauth_401() before fetch_token().
- refresh_live() now reports whether any telemetry arrived and run() defers
  startup when the first poll returns nothing, so automatic_config() - which runs
  on the first cycle alone - can no longer lose soc_max and friends for the whole
  session. The live tier clock only starts on a successful poll, so the retry
  really re-polls.
- refresh_static() keeps the known inverters when discovery comes back empty, and
  neither marks the tier fresh nor writes {'device_list': []} to the cache.
- refresh_config, refresh_live, the schedule read and the apply are each wrapped
  per serial, so one inverter's failure no longer aborts the tick for the others
  or skips publish_data()/update_success_timestamp().
- sysWorkMode now routes through encode_setting like every other owned field;
  note_settle's comment corrected (config-tier polls, ~45 minutes, not cycles);
  dead cached_values/device_capacity/device_pack_voltage and the unreferenced
  TOU_FIELD_PRESERVED/SUNSYNK_PACK_VOLTAGE_FIELD removed.

Tests: the new run()-altitude tests drive run() with control enabled and only the
transport patched - the altitude no previous test reached, which is why the
unplanned write survived nine scoped reviews - and the redaction test now uses a
realistic nested envelope. Every new test was confirmed failing before the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rename run_cli to test_sunsynk_api (the house name every other component's
diagnostics entry point uses - test_deye_api, test_fox_api, test_solis_api,
etc.), add the missing # pragma: no cover on main(), and restructure the
default path to call client.run(seconds=0, first=True) instead of the
individual fetch_* methods. Nobody on the project has a Sunsynk account, so
this CLI is the only tool a remote tester has, and run() is where the real
orchestration lives (refresh tiers, the control_active gate, publish_data,
automatic_config) - a CLI that bypassed it could not smoke-test what
actually runs. run()'s False return (failed login, empty device list, or
failed first telemetry) is now reported explicitly instead of falling
through into an empty dump. --dump-settings, --write-test, --auth-method,
--region and --serial are preserved; --serial now filters discovery itself
via inverter_sn, matching deye.py's _build_deye.

The rename is safe for the test runner: TEST_REGISTRY in unit_test.py is a
manually curated list of explicit `from tests.test_X import ...` imports,
not a glob-based collector, so a module-level test_* function inside
apps/predbat/sunsynk.py is never auto-discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 17, 2026 19:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new Sunsynk Connect cloud integration to Predbat, enabling monitoring (by default) and opt-in inverter control via a read-modify-write settings model, intended to work both self-hosted and on Predbat.com.

Changes:

  • Introduces the SunsynkAPI component plus a supporting constants module (endpoints, field maps, wire encoding, and RSA login helper).
  • Registers the new inverter type/component/config keys and adds extensive test coverage across auth, API, control derivation, publishing, storage, and auto-config.
  • Updates documentation to describe setup, configuration options, and diagnostics/verification workflow.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
docs/superpowers/specs/2026-08-17-sunsynk-cloud-integration-design.md Design spec for the Sunsynk cloud integration (auth, control model, safety/verification).
docs/inverter-setup.md Adds end-user setup + diagnostics CLI instructions for Sunsynk Cloud.
docs/components.md Documents the new sunsynk component and configuration options.
docs/apps-yaml.md Adds Sunsynk Cloud configuration reference and security notes.
apps/predbat/sunsynk.py New Sunsynk cloud component implementation + standalone diagnostics CLI.
apps/predbat/sunsynk_const.py New constants/field maps/encoding rules + pure-Python RSA helper.
apps/predbat/components.py Registers the sunsynk component and maps sunsynk_* config keys to args.
apps/predbat/config.py Adds INVERTER_DEF["SunsynkCloud"] and APPS_SCHEMA keys for sunsynk config.
apps/predbat/unit_test.py Registers Sunsynk test suites in TEST_REGISTRY.
apps/predbat/tests/test_sunsynk_api.py Transport/request semantics, discovery paging safety, telemetry mapping, rating derivations.
apps/predbat/tests/test_sunsynk_auth.py RSA/DER parsing tests + auth-method behavior + debug redaction tests.
apps/predbat/tests/test_sunsynk_control.py Schedule→TOU slot derivation + payload building + apply_settings gating/behavior.
apps/predbat/tests/test_sunsynk_publish.py Entity naming/publishing + control entity round-trip + write-button behavior.
apps/predbat/tests/test_sunsynk_storage.py Cache save/restore + tier age seeding + robustness to storage failures/absence.
apps/predbat/tests/test_sunsynk_const.py Validates constants/templates/maps + encode_setting behavior.
apps/predbat/tests/test_sunsynk_config.py Validates inverter/component/schema registration + auto-config + run() orchestration behaviors.
.cspell/custom-dictionary-workspace.txt Adds new integration-related vocabulary for spellchecking.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/sunsynk.py Outdated
springfall2008 and others added 3 commits August 17, 2026 21:22
note_settle hand-rolled its own field list (work mode plus slot
time/soc/grid_charge), so a partial apply that dropped a per-slot power
limit, solarSell, peakAndVallery or a day flag compared equal on
everything checked and silently reset the settle counter, masking a
real divergence. Switch to _owned_fields() (minus the echoed sn) so
the settle check tracks the same fields the component actually writes.

Compare only over keys present in the read-back, and leave the counter
untouched when none are: nobody has a Sunsynk account to confirm every
owned field is echoed by a real /read, so a missing key means "no
information", not "diverged" - treating it as divergence would
reintroduce the cry-wolf failure (warning every poll against a healthy
inverter) this component was already built to avoid.

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

Real user feedback from the Sunsynk diagnostics CLI: a bad-credentials login
produced eight unrelated "NoneType has no attribute" warnings (load_cache/
save_cache/age_cache all assume a Storage component exists, but a standalone
CLI run has none) and a bare "run() returned False" with three possible
causes, forcing the user to dig through Warn: lines for the API's own
"Account or password error".

- load_cache/save_cache/age_cache now check `self.storage is None` first and
  return silently - that is the normal standalone-CLI condition, not a fault.
  A real (raising) storage failure still warns and still retries; only the
  "no storage configured at all" case goes quiet. Absent storage no longer
  sets _restore_had_error, so restore_state() completes and marks itself done
  instead of retrying the same no-op forever.
- _request() now captures the API's own failure message on last_api_error
  (never a credential - the response's `msg` field only), so fetch_token and
  the CLI can report the real reason.
- get_device_list() now tracks discovery_ok, distinguishing a genuinely empty
  account from a discovery call that failed outright (both used to coerce to
  the same empty result); refresh_static()'s warning and the CLI both use it.
- The CLI's test_sunsynk_api() now inspects component state after a failed
  run() and names precisely which stage failed - login (with the API's
  reason), discovery (empty account vs. failed call, with a --serial
  filtering hint), or the first telemetry poll (naming the serials found).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sunsynk_control_enable now defaults to true, matching solis_control_enable.
An inverter component that does not drive the inverter is not what a user
configuring it expects, and no other Predbat inverter component gates
control behind an extra flag.

Set it to false for monitoring only. The write format is still inferred
rather than documented, so the docs now recommend running the diagnostics
CLI against your own inverter first and switching control off if anything
looks wrong, rather than requiring an opt-in to switch it on.

Pins the registered defaults in test_component_registered - control_enable
decides whether Predbat writes to a real inverter at all, so a silent flip
either way should fail the suite. Verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit c1aeb5f into main Aug 18, 2026
2 checks passed
@springfall2008
springfall2008 deleted the feat/sunsynk-cloud-integration branch August 18, 2026 15:45
@mdeakin99

mdeakin99 commented Aug 18, 2026

Copy link
Copy Markdown

I have a Sunsynk inverter and I am currently using the ModBus route (I believe) in predbat on HA.

Happy to test this out if you guys are willing to help me understand what needs to be configured etc.

I have a lot that currently uses the ModBus entities but happy to jump off that for a while if it helps.

perhaps I could switch predbat self hosted to monitor and sign up for a trial of predbat.com and test it there? Saves me having to mess with my self hosted setup?

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