Skip to content

Control entities echo Predbat's own writes on Deye/Fox/Enphase, so write read-back can't detect external changes #4636

Description

@mgazza

Summary

On several cloud components the inverter control entities are republished from Predbat's own local_schedule rather than from vendor state. This is deliberate and well documented in the code — it is what makes write_and_poll_* read-back succeed. But it has two consequences that I don't think are intended:

  1. A successful write_and_poll_value / write_and_poll_switch proves only that Predbat's local cache was updated. It is not evidence the inverter accepted the setting.
  2. A change made outside Predbat (phone app, installer EMS, vendor portal) is overwritten in the control surface before it can be observed, so it is undetectable there.

This came up while designing a "something else is fighting Predbat" detector. I abandoned the settings-level approach because of (2), so I wanted to write up the finding regardless of whether the detector ever ships.

Where

apps/predbat/deye.py:945 states the mechanism plainly:

This is what makes a write stick. Predbat writes these entities … then reads the entity back to confirm (see inverter.write_and_poll_value / write_and_poll_switch). Nothing else republishes them, so if the incoming value is not applied here and the entity re-emitted, the read-back returns the old value and every write is reported failed.

and for reserve at apps/predbat/deye.py:998-1000:

Reserve goes to the inverter immediately … and is republished so Predbat's read-back sees the new value whether or not the inverter write itself succeeded.

So on Deye a failed physical write still produces a confirmed read-back.

The four components carrying a local_schedule differ in how exposed they are:

Component Control surface Genuine vendor read of settings?
deye.py echo Nodeye_const.py:58 describes the control cache as having "no API read-back"
fox.py echo (write_battery_schedule_event) settings/scheduler polled, but only hourly (fox.py:72)
enphase.py echo after a one-time seed (enphase.py:671) seed only; the docstring notes later external changes show in the monitoring sensors
sunsynk.py echo Yesrefresh_config() reads real settings on the 15-min config tier (sunsynk.py:1378)

gecloud.py has no local_schedule; its control entities reflect GE cloud settings.

Why it matters beyond the read-back

deye_const.py:58-63 already documents the sharp end of this, in the context of bounding how long applied_payload may be restored across a restart:

this is a change-detection cache with no API read-back, so restoring it asserts the inverter still holds what Predbat last wrote. If it was changed externally while Predbat was down that assertion is false, the next write is wrongly SKIPPED and the battery silently diverges from the plan.

That reasoning is correct and the 15-minute restore bound mitigates the restart case. The same assertion is made continuously while Predbat is running, though, and there it isn't bounded: if a user changes a schedule in the vendor app, change-detection can compare Predbat's plan against Predbat's own cache, match, and skip the write — leaving the inverter on the user's setting until something else forces a rewrite.

Two things that already point at the fix

  • sunsynk.py has the pattern that works. refresh_config() reads actual settings on its own cadence, note_settle() tracks writes that haven't landed, and note_external_change() (sunsynk.py:913) logs settings changed outside Predbat. That is exactly the missing piece on the others.
  • enphase.py:671's docstring already identifies where truth lives — external changes "shown by the monitoring sensors". Telemetry (battery_power, soc, etc.) is genuinely fetched on every component; only the control surface is an echo.

Possible directions

Not prescriptive, and happy to be told this is working as intended:

  1. Distinguish "written to local cache" from "confirmed at the inverter" in whatever write_and_poll_* reports, so a log line or health sensor can't imply the latter when only the former happened. Deye's unconditional reserve republish is the clearest case.
  2. Where the vendor API can read settings back, do so on a slow tier and compare, as sunsynk.refresh_config() does — even at 15 or 60 minutes this is enough to notice divergence.
  3. Where it cannot (Deye today), consider periodically forcing a write rather than trusting change detection against a cache that has no read-back.

Happy to put a PR together for any of these if there's a preferred direction.

Not reported as

A security or safety issue, and not urgent — the mechanism is intentional and the components work. This is about the confirmation signal meaning less than it appears to, and about external changes being unobservable in the control surface.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions