Skip to content

fix(inverter): retry a service call the HA layer rejected instead of deduplicating it away - #4878

Merged
springfall2008 merged 1 commit into
mainfrom
fix/service-call-retry-on-failure
Sep 5, 2026
Merged

fix(inverter): retry a service call the HA layer rejected instead of deduplicating it away#4878
springfall2008 merged 1 commit into
mainfrom
fix/service-call-retry-on-failure

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Written by Claude, posted on behalf of @chalfontchubby.

Fixes #4876. Originally raised in discussion #4866.

call_service_template() recorded its deduplication hash before making any of the calls, and discarded each call's return value:

else:
    # Record the last service called
    self.base.last_service_hash[hash_index] = this_service_hash   # recorded up front
    service_repeat = False
...
    self.base.call_service_wrapper(service_name, **service_data)  # result discarded

A rejected call was therefore remembered as done, and every later cycle carrying the same target logged Skipped service ... as it was previously called. Predbat believed it had set a control it had not, with nothing to correct it until the payload itself changed — which for a held charge target can be hours.

Reported twice, on unrelated hardware

What this changes

Record the hash only once every call has been accepted, and drop any earlier record when one is rejected, so the next cycle reissues it naturally.

That is exactly what repeat: True on a service template already achieves manually (documented in docs/inverter-setup.md). Neither reporter had it set, and needing it is the thing being removed.

What this deliberately does not do

It is not read-back verification, which is what #4866 originally asked for. As noted in that discussion, the integrations involved cache writes asynchronously, so re-reading an entity cannot settle whether a value actually reached the inverter — the same report's charge_limit path shows a readback trailing the write by a full cycle, which is a separate false-failure problem rather than this one.

This checks only that the service call itself was accepted. That is a fact the HA layer already returns and this code was throwing away.

The return value is left at True. Callers use it to choose a fallback service:

if not self.call_service_template("charge_freeze_service", ...):
    self.call_service_template("charge_stop_service", ...)

Reporting a transient failure there would silently downgrade a freeze into a stop instead of retrying it, so the returned value keeps its existing "was a service configured and dispatched" meaning.

Caveat worth a reviewer's eye

call_service_wrapper returns None on an explicit websocket failure or a two-minute timeout, and the standalone loopback branch returns False when the service is not in EVENT_LISTEN_LIST. trigger_callback's docstring states that shared True/False contract is intentional, and Predbat.com does not drive inverters through HA services — but a vendor-specific service called in loopback mode would now look permanently rejected and be reissued each cycle rather than skipped.

Relationship to #4845

Adjacent, not overlapping. #4845 covers the same act-on-an-unverified-write class at the ten adjust_* notify/MQTT sites, whose failure mode is announcing changes that did not happen. This path has no write_and_poll_value() at all and fails the opposite way — silently, with no retry. #4876's log shows both halves within one cycle of each other.

Testing

New test_call_service_template_retry covering: a rejected call is retried next cycle; an accepted call is still deduplicated as before; and a later rejection drops the earlier record rather than leaving it stale. Confirmed it fails without the fix:

ERROR: a rejected call was recorded as done - the next cycle will skip it
ERROR: the call was not retried after being rejected - this is the #4876 hang
ERROR: the hash from the previous accepted call survived a rejection

./run_all --quick and ./run_all --test debug_cases both pass.

call_service_template() recorded its deduplication hash before making any of the
calls, and discarded each call's return value. A service that was rejected was
therefore remembered as done, and every later cycle carrying the same target
logged "Skipped service ... as it was previously called" - so Predbat believed
it had set a control it had not, with nothing to correct it until the payload
itself changed, which for a held target can be hours.

Reported twice now on unrelated hardware. #2903 left a Libbi un-charged for a
3.5 hour cheap-rate window after a failed switch turn-on. #4876 left a Deye
sitting in its idle program - confirmed on the physical display - while
predbat.status read Charging, until the reporter re-sent the identical
select_option by hand and the battery moved within seconds.

Record the hash only once every call has been accepted, and drop any earlier
record when one is rejected, so the next cycle reissues it naturally. This is
what "repeat: True" on a service template already achieves by hand; neither
reporter had it set, and needing it is the thing being removed.

Deliberately NOT read-back verification. The integrations involved cache writes
asynchronously, so re-reading an entity cannot settle whether a value reached
the inverter - the same report's charge_limit path shows a readback that trails
the write by a whole cycle. This checks only that the service call itself was
accepted, which is a fact the HA layer already returns and this code threw away.

The return value is left alone at True. Callers use it to choose a fallback
service ("if not charge_freeze_service: charge_stop_service"), so reporting a
transient failure there would silently downgrade a freeze into a stop instead of
retrying it.

Related but separate: #4845 covers the same act-on-an-unverified-write class at
the ten adjust_* notify/MQTT sites, which announce changes that did not happen;
this path has no write_and_poll_value() at all and its failure mode is the
opposite - silence, and no retry.

Fixes #4876

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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.

🟡 Changes recommended

The test cleanup mutates shared runner state and must restore its original values.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes inverter service deduplication so Home Assistant-rejected calls are retried instead of skipped.

Changes:

  • Records service hashes only after successful dispatch.
  • Clears stale hashes after rejected calls.
  • Adds retry and deduplication regression tests.
File summaries
File Review
apps/predbat/tests/test_inverter.py Adds regression coverage, but must restore all shared state—including retry_test—during cleanup.
apps/predbat/inverter.py Updates deduplication state based on service-call acceptance.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

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

Comment on lines +1528 to +1532
ha.service_store_fail = set()
# Drain anything this test dispatched, or the next test in the module sees it as its own.
ha.get_service_store()
ha.service_store_enable = False
my_predbat.last_service_hash = {}
@springfall2008
springfall2008 merged commit a772099 into main Sep 5, 2026
3 checks passed
@springfall2008
springfall2008 deleted the fix/service-call-retry-on-failure branch September 5, 2026 12:27
springfall2008 pushed a commit that referenced this pull request Sep 5, 2026
…lidated

Grounding pass over the PRs merged to main since the journal was last
touched. Two entries were actively wrong and would have misled a triage
run; the rest is new material those merges settled.

Corrections:

- The secrets section said the unmasked args_from_apps_yaml copy was
  "confirmed still present on main". PR #4910 fixed it at the source -
  predbat.py:1865 masks the copy as it is taken, so it cannot carry a
  secret however it is later dumped, and both dump paths mask it too.
  Left standing, that entry would have had a triage session tell a
  reporter to rotate credentials that never leaked. Pre-v8.55.0 dumps
  are still affected, so the read-it-locally rule is unchanged.
- "call_service_wrapper()'s return value is discarded at every
  inverter.py write site" is no longer true of every site: PR #4878
  made call_service_template() check it and retry next cycle. The other
  eleven still discard it.

New:

- A section for the calculate_yesterday() clock rewind. PR #4906 root
  caused GH#4900's day-shifted manual overrides to it, which promotes
  the mechanism from an unconfirmed theory in the Solcast row to a
  confirmed class with two symptoms - and adds the detail that the
  rewind also zeroes minutes_now, lasts ~0.8s and runs hourly, with the
  web server, the websocket thread and agent_tools all reading it live.
- AlphaESS: PR #4744's finding that the Open API cannot control export
  at all, so Force Export and Freeze Export are both undeliverable and
  Control charge is the documented mode; plus PR #4867's new hold, an
  enabled 10% / 100W profile rather than the old zero-rate one.
- Octopus: eventType now discriminates Power Down from a free hour, and
  the Weekend Happy Hours skip must sit after the reward/code/type maps
  or a free hour is priced as an 80p/kWh saving session.
- A trap for fixture dates built from the machine clock instead of
  Predbat's, and the bulk debug archive's need to stay compressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

charge_limit/program_point writes via SK driver show same "verification discarded" pattern as #4845

3 participants