Skip to content

Solis: don't let a refused startup register read abort startup, and back off B0600 - #5185

Merged
springfall2008 merged 2 commits into
mainfrom
fix/solis-startup-reset-b0600-5177
Sep 23, 2026
Merged

springfall2008 merged 2 commits into
mainfrom
fix/solis-startup-reset-b0600-5177

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

This is an automated draft PR generated from issue #5177 — a maintainer should review it before merging.

Fixes #5177

Summary

startup_reset_registers() only lowers the over-discharge SoC, but run() called it in the first-cycle block without a try/except. If the datalogger refuses the register read, the exception escapes run() and ends that startup attempt. automatic_config() never runs, so Predbat has no inverter ("no source is configured"). That happens with B0600 after 7 retries, and with B0115 on the first attempt.

This PR:

  • Guards the call in run(). A failed startup reset now logs Warn: Solis API: Startup register reset failed for inverter <sn>, continuing without it: ... and startup continues. This is the change that fixes the bug.
  • Adds B0600 to SOLIS_API_CODES, so the log no longer says "Unknown code", and to SOLIS_API_CODES_NO_RETRY, so each refusal costs 1 request instead of 8.
  • Puts a B0600 inverter into the same per-inverter cooldown B0115 uses (fix(solis): quota-aware retries with exponential backoff #5088). _execute_request now routes both codes through note_datalogger_offline(sn, code).
  • Records which code started the cooldown (datalogger_cooldown_code). The log and run status then report B0600 as datalogger returning data abnormally (B0600), not as offline; the owner in the report could see the datalogger online in SolisCloud.
  • Keeps the datalogger diagnostic in health_message() until a request succeeds. Before, it lasted only for the 15-minute cooldown. The quota diagnostic already works this way. A component in startup backoff re-probes up to 128 minutes apart, so a diagnostic tied to the cooldown would leave the run status blank most of the time.

Testing

  • cd coverage && ./run_pre_commit: all hooks passed and the quick suite passed (4 slow tests skipped).
  • tools/triage_test.sh solis fails without the fix and passes with it. I stashed only apps/predbat/solis.py and checked that the file was back to main before the red run.
    • test_run_survives_startup_register_reset_refusal (new) runs the real startup_reset_registers() through run(0, True) against a canned SolisCloud refusal, for both B0600 and B0115. Without the fix both codes abort run(). With the fix it checks:

      • exactly one request is sent
      • the warning is logged
      • automatic_config() and publish_entities() are still reached
      • the inverter is in cooldown
      • the health message names it
    • test_b0600_backs_off_without_calling_the_datalogger_offline (new) covers:

      • B0600 is a known code and puts the inverter in cooldown
      • neither the pause log nor the health message says "offline"
      • two inverters with different codes each get their own reason
      • the diagnostic outlasts the cooldown
      • a successful read clears it

      Without the fix every one of these assertions fails.

    • test_with_retry_does_not_retry_settled_codes now includes B0600. Without the fix it was attempted 5 times.

    • MockSolisAPI gets the new attribute, so the initialize/mock parity test covers it too.

Notes

  • Names left wider than they read. datalogger_offline(), note_datalogger_offline(), datalogger_offline_until and SOLIS_DATALOGGER_OFFLINE_SECONDS now cover B0600 too, where the datalogger is online but refuses reads. I updated their docstrings and gave the one new attribute an accurate name (datalogger_cooldown_code). I did not rename the existing symbols, to keep this diff focused; a rename to datalogger_cooldown* would be a clean follow-up. The recovery log line now reads "is answering requests again" instead of "is back online", because that is true for both codes.
  • B0600 pauses the whole inverter, as B0115 does. That includes the every-minute inverterDetail poll. On the reporting account inverterDetail failed too (code 1), so nothing is lost there. If B0600 turns out to block only remote register reads while details keep working, a narrower gate on the CID read/control endpoints would keep live data flowing every minute (the "poll-only" outcome the issue hopes for). I did not build that on a single report.
  • The component still stays in startup backoff while the inverter can't be polled: poll_success is False, so run() returns False and first stays True. That is Solis: one unreachable inverter keeps the whole component in startup backoff #5093's territory and out of scope here. What this PR changes is that each attempt now completes, reaches automatic_config(), costs one request per refusal, and leaves a named reason in the run status.
  • The health message wording for B0115 changed slightly, from datalogger offline for inverter X to datalogger offline or disconnected for inverter X. It now also persists past the cooldown until a request succeeds, as described above.
  • startup_reset_registers() is caught with a bare except Exception, like the discovery block and the event-handler drain in the same function, because the step is best-effort.
  • Blast radius (GitNexus impact): run, note_datalogger_offline and health_message are LOW. health_message is called dynamically from predbat.py's run-status builder via hasattr. _execute_request is MEDIUM, with its 5 request wrappers as direct callers; the change there only touches the error-code branch. detect_changes reports the single affected flow run → _with_retry, as expected.

🤖 Generated with Claude Code

…ack off B0600

startup_reset_registers() only lowers the over-discharge SoC, but its read
was unguarded in run()'s first-cycle block, so a datalogger that refuses
every remote read (B0600, and B0115 the same way) raised out of run() on
every startup attempt. automatic_config() was never reached and Predbat ran
with no inverter at all (issue #5177).

- Catch and log a failed startup reset and carry on with startup.
- Name B0600 in SOLIS_API_CODES, don't retry it, and put the inverter
  into the same per-inverter cooldown B0115 uses.
- Record which code started the cooldown, so the log and run status
  report B0600 as abnormal data rather than an offline datalogger.
- Keep the datalogger diagnostic in health_message() until a request
  succeeds, like the quota one, so it is still shown while the component
  sits in startup backoff longer than the cooldown.

Fixes #5177

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 self-assigned this Sep 21, 2026
@springfall2008 springfall2008 added the BOT_REVIEW Trigger an autotriage label Sep 21, 2026
Comment thread apps/predbat/solis.py
# read (B0600, B0115) aborted every startup attempt, so the inverter was never
# configured at all (issue #5177).
try:
await self.startup_reset_registers(sn) # Reset registers on startup to ensure we have write access and correct initial state

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The guard makes the reset best-effort, but the whole startup block above (fetch_inverter_details, the TOU-v2 poll, this reset read — the for sn loop at line 3580) never checks datalogger_offline(sn). During startup backoff each re-probe still sends up to 3 requests to a datalogger its own cooldown says not to poll: the 2/4/8-minute early backoff gaps all fall inside the 15-minute cooldown, so those attempts each spend ~3 refused requests out of the 200/day allowance (the budget pressure issue #5087 was about). The new run-path test asserts 'exactly one request is sent' only because fetch_inverter_details/poll_inverter_data are mocked — production sends ~4 per attempt. Gating the first block's per-inverter calls on datalogger_offline(sn) (they still fire once the cooldown expires, so re-probing is unaffected) would make production match the test's claim.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated reply from the triage bot.

Fixed in 2d51c66. The first-cycle loop now skips an inverter whose datalogger is in its cooldown. It also re-checks after the detail read and after the TOU mode read, so it stops at the first refusal that starts a cooldown. A skipped inverter sets poll_success = False, which keeps the component in startup, so the first attempt after the cooldown expires re-probes it.

The new test_startup_leaves_an_inverter_in_datalogger_cooldown_alone drives the real detail, poll and reset code against a session that answers B0600 to everything:

  • the first attempt sends 1 request;
  • the attempts at 2, 6 and 14 minutes send none;
  • the attempt at 30 minutes re-probes with 1.

Against the previous commit, the first attempt sent 3.

Comment thread apps/predbat/solis.py Outdated
# two hours apart, far longer than the cooldown, so tying this to the cooldown would leave the run
# status blank for most of that time (issue #5177).
by_reason = {}
for sn in sorted(self.datalogger_offline_until):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This rewrite drops the old datalogger_offline(sn) filter, so an entry now survives until a request for that inverter succeeds. For an SN that leaves the polling rotation — removed from the inverter_sn config, or dropped by discovery — no such request ever comes, and the run status reports 'datalogger offline or disconnected / returning data abnormally for inverter X' for the rest of the process lifetime. The old active-cooldown filter was the implicit expiry; nothing replaces it for SNs no longer polled. Worth evicting entries for SNs not in self.inverter_sn, or keeping the filter and handling the outlast-the-cooldown case separately.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated reply from the triage bot.

Fixed in 2d51c66. health_message() now reports only inverters in self.inverter_sn. I filtered there rather than evicting, because the run status is the only place an expired entry has any effect: expired entries gate no polling. Test: test_datalogger_diagnostic_dropped_once_the_inverter_leaves_the_rotation.

Comment thread apps/predbat/solis.py Outdated
by_reason = {}
for sn in sorted(self.datalogger_offline_until):
code = self.datalogger_cooldown_code.get(sn, SOLIS_API_CODE_DATALOGGER_OFFLINE)
by_reason.setdefault(SOLIS_DATALOGGER_COOLDOWN_REASONS.get(code, code), []).append(sn)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Persistence is keyed to the code recorded when the cooldown started, so once the 15-minute cooldown expires a re-probe failing for an unrelated reason (HTTP 500, timeout, R0000) leaves the run status claiming the datalogger is offline/returning-abnormal while the real cause is e.g. a cloud outage. The old code went quiet once the cooldown expired. Consider clearing the entry when the post-cooldown probe fails with a non-datalogger error.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated reply from the triage bot.

Fixed in 2d51c66. The new forget_expired_datalogger_cooldown() drops an entry whose cooldown has expired when a request for that inverter fails for anything other than B0115 or B0600: an HTTP error, a timeout, a network error, or another API code. R0000 counts as another code, and while the quota is exhausted the quota diagnostic is reported first anyway. Inside an active cooldown it does nothing. Test: test_datalogger_diagnostic_cleared_by_an_unrelated_failure_after_its_cooldown, which covers an HTTP 500 and API code 1, the code the detail endpoint returned on the reporting account.

Comment thread apps/predbat/solis.py Outdated
and is how the account in issue #5087 reached R0000 in the first place. A B0600 is backed
off the same way, but code keeps it from being reported as offline (issue #5177).
"""
if not inverter_sn or self.datalogger_offline(inverter_sn):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The early return also skips updating datalogger_cooldown_code, so if a B0115 cooldown is in force and the refusals turn into B0600 (or vice versa), the pause log and the health message keep reporting the older reason for the rest of the cooldown. Fine when the code never changes, but the reason can then lag the latest refusal until some request succeeds.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated reply from the triage bot.

Fixed in 2d51c66. note_datalogger_offline() now records the code before the early return, so the run status follows the latest refusal. The cooldown is still not extended, and the pause is still logged only once. Test: test_datalogger_reason_follows_the_latest_refusal (B0115, then B0600 five minutes in).

Comment thread apps/predbat/solis.py
try:
await self.startup_reset_registers(sn) # Reset registers on startup to ensure we have write access and correct initial state
except Exception as e:
self.log(f"Warn: Solis API: Startup register reset failed for inverter {sn}, continuing without it: {e}")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The try/except only ever catches the initial read: read_and_write_cid() swallows its own failures and returns False, which startup_reset_registers() (line 3524) ignores. A refused/failed write still leaves the over-discharge SoC above the 20% default with only read_and_write_cid's generic 'Failed to read and set ...' warn — this 'Startup register reset failed' warning and the issue-#5177 framing never fire for the write half, so the survivable step is only observable on its read half.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated reply from the triage bot.

Not changed, because the write half cannot run today.

startup_reset_registers() does value = await self.read_cid(...), but read_cid() returns a (value, info) tuple. It has done so since #3654, a week before this function arrived in 9f43e43 and c52ae1d. float() of that tuple raises TypeError, the except sets value = 0, and the > 20 write never executes. So there is no refused write to report, and a warning there would be dead code.

Fixing the unpacking would switch on a write that has never happened in production: lowering every inverter's over-discharge SoC to 20% at each startup. That is a behaviour change for a maintainer to decide, not a review fix, so I've kept it out of this PR. If it is wanted, the unpacking fix and a write-failure warning belong together in a follow-up.

Comment thread apps/predbat/solis.py Outdated
if str(code) == SOLIS_API_CODE_QUOTA_EXCEEDED:
self.note_quota_exhausted()
elif str(code) == SOLIS_API_CODE_DATALOGGER_OFFLINE:
elif str(code) in SOLIS_DATALOGGER_COOLDOWN_REASONS:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Comments now trail the code: the request-sn comment above (line 627) still says 'so a B0115 backs off that one', and _with_retry's comment (line 704) still says 'an offline datalogger will answer every retry the same way' — both now cover B0600 too. Worth touching so the next reader doesn't have to re-derive that B0600 settles retries as well.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated reply from the triage bot.

Fixed in 2d51c66. The request-sn comment in _execute_request() and the no-retry comment in _with_retry() now name both B0115 and B0600.

Comment thread apps/predbat/solis.py Outdated
self.datalogger_offline_until[inverter_sn] = resume_at
self.log("Warn: Solis API: Datalogger for inverter {} is offline or disconnected, pausing its reads until {}".format(inverter_sn, resume_at.strftime("%H:%M:%S UTC")))
self.datalogger_cooldown_code[inverter_sn] = code
self.log("Warn: Solis API: Datalogger for inverter {} is {}, pausing its reads until {}".format(inverter_sn, SOLIS_DATALOGGER_COOLDOWN_REASONS.get(code, code), resume_at.strftime("%H:%M:%S UTC")))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Simplification: SOLIS_DATALOGGER_COOLDOWN_REASONS.get(code, code) is duplicated here and in health_message (line 617), and the new default code=SOLIS_API_CODE_DATALOGGER_OFFLINE exists only for test callers — the one production caller passes str(code) explicitly. A tiny cooldown_reason(code) helper (or an explicit code everywhere) keeps the two fallbacks from drifting.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated reply from the triage bot.

Fixed in 2d51c66. datalogger_cooldown_reason(sn) is now the only place the code-to-wording fallback lives, and both the pause log and health_message() use it. note_datalogger_offline() now takes code explicitly, with no default. The two test callers that relied on the default now pass SOLIS_API_CODE_DATALOGGER_OFFLINE.

@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Sep 21, 2026
@springfall2008
springfall2008 marked this pull request as ready for review September 21, 2026 19:33
Copilot AI lite review requested due to automatic review settings September 21, 2026 19:33

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.

Copilot review overview

🟢 Approval recommended

The changes are targeted, covered by new/updated Solis tests, and the updated error handling/backoff paths appear consistent with existing Solis retry and cooldown mechanisms.

Review effort: Lite
Findings: None

What changed in this PR

This PR fixes Solis component startup robustness when SolisCloud refuses startup register reads (notably API code B0600), ensuring the component can still complete its first-cycle configuration and publish entities rather than aborting startup.

Changes:

  • Guard startup_reset_registers() during the first-cycle startup path so a refused read logs a warning and startup continues.
  • Treat B0600 as a known, non-retryable Solis API code and route it through the existing per-inverter cooldown/backoff mechanism.
  • Improve run-status/health reporting by persisting the datalogger diagnostic until a request succeeds, and by recording which code triggered the cooldown.
File Description
apps/​predbat/​solis.py Adds B0600 handling, per-inverter cooldown attribution, persistent health messaging, and guards startup register reset so startup doesn’t abort on refused reads.
apps/​predbat/​tests/​test_solis.py Extends the Solis test suite with new regressions for #5177 and updates mocks/settled-code retry expectations to include B0600.

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

@springfall2008 springfall2008 added the BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push label Sep 21, 2026
…agnostic current (#5177 review)

- The first-cycle block now skips an inverter whose datalogger is in its
  cooldown, and stops at the first refusal that starts one, so early startup
  backoff no longer spends 3 refused requests per attempt inside the cooldown.
- The run status only reports inverters still being polled, follows the
  latest datalogger refusal, and drops an expired cooldown once a re-probe
  fails for an unrelated reason.
- One datalogger_cooldown_reason() helper replaces the duplicated fallback;
  note_datalogger_offline() now takes its code explicitly.
- Comments that still said B0115 only now cover B0600.

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

Copy link
Copy Markdown
Owner Author

Automated cleanup check from the triage bot - nothing for a maintainer to act on.

Nothing new to address on this PR at 2d51c66:

  • The branch is up to date with main; merging origin/main changed nothing.
  • All 7 inline review threads already have a reply, posted with 2d51c66. Six were fixed in that commit, and one (the write half of startup_reset_registers()) was declined with reasoning.
  • The Copilot review had no findings, and there are no top-level comments.
  • CI passed on 2d51c66 (pre-commit, kernel-binaries).

No commit was pushed.

@springfall2008 springfall2008 removed the BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push label Sep 21, 2026
@springfall2008
springfall2008 merged commit f9f8b61 into main Sep 23, 2026
2 checks passed
springfall2008 added a commit that referenced this pull request Sep 24, 2026
…9-24

docs(debug-journal): fold in the 2026-09-24 queue slice; correct the #5185/#5142 merge-stale entries and the write-poll budget figure
Speshman pushed a commit to Speshman/batpred that referenced this pull request Sep 24, 2026
…pringfall2008#5185/springfall2008#5142 merge-stale entries and the write-poll budget figure

Co-Authored-By: Claude Code <noreply@anthropic.com>
springfall2008 added a commit that referenced this pull request Sep 26, 2026
…9-26

docs(debug-journal): fold in the 2026-09-26 queue slice (15 candidates); correct entries overtaken by the #5109/#5173/#5185/#5188/#5189 merge batch
CossieRob pushed a commit to CossieRob/batpred that referenced this pull request Sep 26, 2026
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.

Solis: B0600 "Datalogger returns data abnormally" on the startup register read stops the component starting, and retrying never clears it

2 participants