Repository navigation
Solis: don't let a refused startup register read abort startup, and back off B0600 - #5185
Conversation
…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>
| # 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| # 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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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).
| 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}") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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"))) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
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.
…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>
|
Automated cleanup check from the triage bot - nothing for a maintainer to act on. Nothing new to address on this PR at 2d51c66:
No commit was pushed. |
…pringfall2008#5185/springfall2008#5142 merge-stale entries and the write-poll budget figure Co-Authored-By: Claude Code <noreply@anthropic.com>
…s); correct entries overtaken by the springfall2008#5109/springfall2008#5173/springfall2008#5185/springfall2008#5188/springfall2008#5189 merge batch Co-Authored-By: Claude Code <noreply@anthropic.com>
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, butrun()called it in the first-cycle block without a try/except. If the datalogger refuses the register read, the exception escapesrun()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:
run(). A failed startup reset now logsWarn: Solis API: Startup register reset failed for inverter <sn>, continuing without it: ...and startup continues. This is the change that fixes the bug.SOLIS_API_CODES, so the log no longer says "Unknown code", and toSOLIS_API_CODES_NO_RETRY, so each refusal costs 1 request instead of 8._execute_requestnow routes both codes throughnote_datalogger_offline(sn, code).datalogger_cooldown_code). The log and run status then report B0600 asdatalogger returning data abnormally (B0600), not as offline; the owner in the report could see the datalogger online in SolisCloud.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 solisfails without the fix and passes with it. I stashed onlyapps/predbat/solis.pyand checked that the file was back to main before the red run.test_run_survives_startup_register_reset_refusal(new) runs the realstartup_reset_registers()throughrun(0, True)against a canned SolisCloud refusal, for both B0600 and B0115. Without the fix both codes abortrun(). With the fix it checks:automatic_config()andpublish_entities()are still reachedtest_b0600_backs_off_without_calling_the_datalogger_offline(new) covers:Without the fix every one of these assertions fails.
test_with_retry_does_not_retry_settled_codesnow includes B0600. Without the fix it was attempted 5 times.MockSolisAPIgets the new attribute, so the initialize/mock parity test covers it too.Notes
datalogger_offline(),note_datalogger_offline(),datalogger_offline_untilandSOLIS_DATALOGGER_OFFLINE_SECONDSnow 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 todatalogger_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.inverterDetailpoll. On the reporting accountinverterDetailfailed 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.poll_successis False, sorun()returns False andfirststays 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, reachesautomatic_config(), costs one request per refusal, and leaves a named reason in the run status.datalogger offline for inverter Xtodatalogger 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 bareexcept Exception, like the discovery block and the event-handler drain in the same function, because the step is best-effort.impact):run,note_datalogger_offlineandhealth_messageare LOW.health_messageis called dynamically frompredbat.py's run-status builder viahasattr._execute_requestis MEDIUM, with its 5 request wrappers as direct callers; the change there only touches the error-code branch.detect_changesreports the single affected flowrun → _with_retry, as expected.🤖 Generated with Claude Code