Skip to content

Commit f9f8b61

Browse files
Merge pull request #5185 from springfall2008/fix/solis-startup-reset-b0600-5177
2 parents 8de140d + 2d51c66 commit f9f8b61

2 files changed

Lines changed: 390 additions & 25 deletions

File tree

‎apps/predbat/solis.py‎

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -364,15 +364,27 @@ def compute_solis_mode_value(mode_enum, old_value, drop_tou_bit=False):
364364
"10404": "Not found",
365365
"10500": "Internal server error",
366366
"B0115": "Datalogger offline or disconnected",
367+
"B0600": "Datalogger returns data abnormally",
367368
"R0000": "Daily API request allowance exhausted",
368369
}
369370

370371
# Response codes that are settled for this cycle: retrying cannot change the answer, and every
371372
# attempt still spends one of the 200 daily requests. B0115 means the datalogger is offline, not
372373
# that the request was throttled, and R0000 means the daily allowance is already gone (issue #5087).
374+
# B0600 came back on every remote read and every retry, across days, on the account in issue #5177,
375+
# from a datalogger the owner could see online in SolisCloud.
373376
SOLIS_API_CODE_QUOTA_EXCEEDED = "R0000"
374377
SOLIS_API_CODE_DATALOGGER_OFFLINE = "B0115"
375-
SOLIS_API_CODES_NO_RETRY = {SOLIS_API_CODE_QUOTA_EXCEEDED, SOLIS_API_CODE_DATALOGGER_OFFLINE}
378+
SOLIS_API_CODE_DATALOGGER_ABNORMAL = "B0600"
379+
SOLIS_API_CODES_NO_RETRY = {SOLIS_API_CODE_QUOTA_EXCEEDED, SOLIS_API_CODE_DATALOGGER_OFFLINE, SOLIS_API_CODE_DATALOGGER_ABNORMAL}
380+
381+
# Codes that put one inverter into the datalogger cooldown, and how the log and run status describe
382+
# each. Both back off the same way; B0600 just must not be reported as offline, because the owner can
383+
# see the datalogger online in SolisCloud (issue #5177).
384+
SOLIS_DATALOGGER_COOLDOWN_REASONS = {
385+
SOLIS_API_CODE_DATALOGGER_OFFLINE: "offline or disconnected",
386+
SOLIS_API_CODE_DATALOGGER_ABNORMAL: "returning data abnormally (B0600)",
387+
}
376388

377389
# Time options for selectors (HH:MM:SS format)
378390
BASE_TIME = datetime(2000, 1, 1, 0, 0, 0)
@@ -455,6 +467,7 @@ def initialize(self, api_key=None, api_secret=None, inverter_sn=None, automatic=
455467
self.slots_reset = set() # Track which inverters had slots reset
456468
self.quota_exhausted_until = None # UTC time the daily API allowance pause lifts, None when not paused
457469
self.datalogger_offline_until = {} # {inverter_sn: UTC time that inverter's reads resume}
470+
self.datalogger_cooldown_code = {} # {inverter_sn: API code of its latest datalogger refusal}
458471
self.automatic_config_done = False # Auto-config succeeded, so it does not need re-running
459472
self.capacity_voltage_warned = set() # Inverters already warned about an estimated capacity voltage
460473
self.verify_settle_seconds = SOLIS_VERIFY_SETTLE_SECONDS # Pause before a verify read is re-taken, 0 in tests
@@ -564,36 +577,67 @@ def note_quota_restored(self):
564577
self.log("Solis API: SolisCloud daily API request allowance has been restored, resuming normal polling")
565578

566579
def datalogger_offline(self, inverter_sn):
567-
"""Whether this inverter's datalogger is in its offline cooldown and should not be polled"""
580+
"""Whether this inverter's datalogger is in its cooldown (B0115 or B0600) and should not be polled"""
568581
offline_until = self.datalogger_offline_until.get(inverter_sn)
569582
return bool(offline_until) and self.now_utc_exact.astimezone(UTC) < offline_until
570583

571-
def note_datalogger_offline(self, inverter_sn):
572-
"""Back off this inverter's reads after SolisCloud reports its datalogger is offline.
584+
def note_datalogger_offline(self, inverter_sn, code):
585+
"""Back off this inverter's reads after SolisCloud refuses them with a datalogger code.
573586
574587
Not retrying a B0115 is only half the cure: run() polls every inverter every minute, so an
575588
offline datalogger left in the rotation still spends a request a minute out of the 200 a day
576-
and is how the account in issue #5087 reached R0000 in the first place.
589+
and is how the account in issue #5087 reached R0000 in the first place. A B0600 is backed
590+
off the same way, but code keeps it from being reported as offline (issue #5177).
577591
"""
578-
if not inverter_sn or self.datalogger_offline(inverter_sn):
592+
if not inverter_sn:
593+
return
594+
# Always the latest refusal, so the run status follows a B0115 that turns into a B0600
595+
self.datalogger_cooldown_code[inverter_sn] = code
596+
if self.datalogger_offline(inverter_sn):
579597
# Already backed off - don't extend the cooldown or re-log on each refused read
580598
return
581599
resume_at = self.now_utc_exact.astimezone(UTC) + timedelta(seconds=SOLIS_DATALOGGER_OFFLINE_SECONDS)
582600
self.datalogger_offline_until[inverter_sn] = resume_at
583-
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")))
601+
self.log("Warn: Solis API: Datalogger for inverter {} is {}, pausing its reads until {}".format(inverter_sn, self.datalogger_cooldown_reason(inverter_sn), resume_at.strftime("%H:%M:%S UTC")))
602+
603+
def datalogger_cooldown_reason(self, inverter_sn):
604+
"""How the log and run status describe this inverter's latest datalogger refusal"""
605+
code = self.datalogger_cooldown_code.get(inverter_sn, SOLIS_API_CODE_DATALOGGER_OFFLINE)
606+
return SOLIS_DATALOGGER_COOLDOWN_REASONS.get(code, code)
584607

585608
def note_datalogger_online(self, inverter_sn):
586-
"""Clear an inverter's offline cooldown after one of its reads succeeds"""
609+
"""Clear an inverter's datalogger cooldown after one of its reads succeeds"""
587610
if inverter_sn and self.datalogger_offline_until.pop(inverter_sn, None):
588-
self.log("Solis API: Datalogger for inverter {} is back online".format(inverter_sn))
611+
self.datalogger_cooldown_code.pop(inverter_sn, None)
612+
self.log("Solis API: Datalogger for inverter {} is answering requests again".format(inverter_sn))
613+
614+
def forget_expired_datalogger_cooldown(self, inverter_sn):
615+
"""Drop an expired cooldown once a request for its inverter fails for a reason that is not a datalogger refusal.
616+
617+
The run status keeps a datalogger reason past its cooldown until a request succeeds, but once a
618+
re-probe fails for something else - an HTTP error, a timeout, R0000 - the datalogger is no
619+
longer the latest explanation, and reporting it would hide e.g. a SolisCloud outage.
620+
"""
621+
if inverter_sn and inverter_sn in self.datalogger_offline_until and not self.datalogger_offline(inverter_sn):
622+
self.datalogger_offline_until.pop(inverter_sn, None)
623+
self.datalogger_cooldown_code.pop(inverter_sn, None)
589624

590625
def health_message(self):
591626
"""Name the reason Solis is unhealthy so the run status says more than 'component errors: Solis'"""
592627
if self.quota_exhausted_until:
593628
return "Solis Cloud daily API limit reached, paused until {}".format(self.quota_exhausted_until.strftime("%H:%M UTC"))
594-
offline = sorted(sn for sn in self.datalogger_offline_until if self.datalogger_offline(sn))
595-
if offline:
596-
return "datalogger offline for inverter {}".format(", ".join(offline))
629+
# Every inverter still carrying a cooldown entry, expired or not: like the quota diagnostic it is
630+
# cleared by a request that succeeds, not by the cooldown expiring (or, once it has, by a re-probe
631+
# that fails for some other reason). A component stuck in startup backoff re-probes up to
632+
# two hours apart, far longer than the cooldown, so tying this to the cooldown would leave the run
633+
# status blank for most of that time (issue #5177).
634+
# Only inverters still being polled, since no request will ever clear one that has left the
635+
# rotation (dropped by discovery or by the inverter_sn setting).
636+
by_reason = {}
637+
for sn in sorted(sn for sn in self.datalogger_offline_until if sn in self.inverter_sn):
638+
by_reason.setdefault(self.datalogger_cooldown_reason(sn), []).append(sn)
639+
if by_reason:
640+
return "; ".join("datalogger {} for inverter {}".format(reason, ", ".join(sns)) for reason, sns in by_reason.items())
597641
return None
598642

599643
async def _execute_request(self, endpoint, payload):
@@ -602,7 +646,7 @@ async def _execute_request(self, endpoint, payload):
602646
# are refused but still counted, so sending them is what keeps it exhausted (issue #5087).
603647
if self.quota_paused():
604648
raise SolisAPIError("SolisCloud daily API request allowance exhausted, requests paused until {}".format(self.quota_exhausted_until.strftime("%Y-%m-%d %H:%M:%S UTC")), response_code=SOLIS_API_CODE_QUOTA_EXCEEDED)
605-
# Which inverter this request is for, so a B0115 backs off that one rather than the fleet.
649+
# Which inverter this request is for, so a datalogger refusal (B0115, B0600) backs off that one rather than the fleet.
606650
# The read/control endpoints name it "inverterSn", the detail endpoint "sn"; the account-wide
607651
# inverter list names neither, and has no single inverter to blame.
608652
request_inverter_sn = payload.get("inverterSn") or payload.get("sn")
@@ -635,6 +679,7 @@ async def _execute_request(self, endpoint, payload):
635679
await self.handle_oauth_401()
636680
reason = "auth_error" if response.status in (401, 403) else "server_error"
637681
record_api_call("solis", False, reason)
682+
self.forget_expired_datalogger_cooldown(request_inverter_sn)
638683
raise SolisAPIError(f"HTTP error: {error_text}", status_code=response.status)
639684

640685
# Parse JSON response
@@ -648,12 +693,14 @@ async def _execute_request(self, endpoint, payload):
648693
error_msg = response_json.get("msg", "Unknown error")
649694
error_detail = SOLIS_API_CODES.get(str(code), f"Unknown code: {code}")
650695
record_api_call("solis", False, "server_error")
651-
if str(code) == SOLIS_API_CODE_QUOTA_EXCEEDED:
652-
self.note_quota_exhausted()
653-
elif str(code) == SOLIS_API_CODE_DATALOGGER_OFFLINE:
696+
if str(code) in SOLIS_DATALOGGER_COOLDOWN_REASONS:
654697
# Not throttling, whatever the old 10 second sleep assumed: the datalogger
655-
# is offline, so this inverter is backed off rather than retried.
656-
self.note_datalogger_offline(request_inverter_sn)
698+
# won't answer, so this inverter is backed off rather than retried.
699+
self.note_datalogger_offline(request_inverter_sn, str(code))
700+
else:
701+
self.forget_expired_datalogger_cooldown(request_inverter_sn)
702+
if str(code) == SOLIS_API_CODE_QUOTA_EXCEEDED:
703+
self.note_quota_exhausted()
657704
raise SolisAPIError(f"API error: {error_msg} ({error_detail} - {response_json})", response_code=str(code))
658705

659706
# Return data field
@@ -664,9 +711,11 @@ async def _execute_request(self, endpoint, payload):
664711

665712
except asyncio.TimeoutError as err:
666713
record_api_call("solis", False, "connection_error")
714+
self.forget_expired_datalogger_cooldown(request_inverter_sn)
667715
raise SolisAPIError(f"Timeout accessing {url}") from err
668716
except aiohttp.ClientError as err:
669717
record_api_call("solis", False, "connection_error")
718+
self.forget_expired_datalogger_cooldown(request_inverter_sn)
670719
raise SolisAPIError(f"Network error accessing {url}: {str(err)}") from err
671720

672721
async def _with_retry(self, operation, max_retry_time=SOLIS_MAX_RETRY_TIME):
@@ -683,8 +732,9 @@ async def _with_retry(self, operation, max_retry_time=SOLIS_MAX_RETRY_TIME):
683732
# burning the full retry window hitting the API with a known-bad token.
684733
if self.oauth_failed:
685734
raise err
686-
# An exhausted allowance or an offline datalogger will answer every retry the same
687-
# way, and each attempt still costs one of the 200 daily requests (issue #5087).
735+
# An exhausted allowance, or a datalogger that is offline (B0115) or refusing reads
736+
# (B0600), will answer every retry the same way, and each attempt still costs one of
737+
# the 200 daily requests (issues #5087, #5177).
688738
if err.response_code in SOLIS_API_CODES_NO_RETRY:
689739
raise err
690740
elapsed_time = time.monotonic() - start_time
@@ -3555,15 +3605,33 @@ async def run(self, seconds, first):
35553605

35563606
# Get inverter details for all inverters
35573607
for sn in self.inverter_sn:
3608+
# An inverter whose datalogger is in its cooldown is left to the first startup attempt after the
3609+
# cooldown expires: early backoff re-runs this block every 2-8 minutes, well inside the cooldown,
3610+
# and every request sent here would be refused yet still count against the 200 a day (issue #5177).
3611+
# Failing the poll keeps the component in startup, so that later attempt does come.
3612+
if self.datalogger_offline(sn):
3613+
poll_success = False
3614+
continue
35583615
await self.fetch_inverter_details(sn)
3559-
await self.poll_inverter_data(sn, [SOLIS_CID_TOU_V2_MODE]) # Get TOU V2 mode status
3616+
if not self.datalogger_offline(sn):
3617+
await self.poll_inverter_data(sn, [SOLIS_CID_TOU_V2_MODE]) # Get TOU V2 mode status
3618+
if self.datalogger_offline(sn):
3619+
# Refused on this attempt, so its TOU mode and startup reset wait for the next one
3620+
poll_success = False
3621+
continue
35603622
if self.is_tou_v2_mode(sn):
35613623
self.log(f"Solis API: Inverter {sn} is in Time of Use V2 mode")
35623624
else:
35633625
self.log(f"Solis API: Inverter {sn} is in standard Time of Use mode")
35643626
if self.control_enable:
35653627
if self.is_battery_inverter(sn):
3566-
await self.startup_reset_registers(sn) # Reset registers on startup to ensure we have write access and correct initial state
3628+
# Best effort: it only lowers one setting. Left to raise, a datalogger that refuses the
3629+
# read (B0600, B0115) aborted every startup attempt, so the inverter was never
3630+
# configured at all (issue #5177).
3631+
try:
3632+
await self.startup_reset_registers(sn) # Reset registers on startup to ensure we have write access and correct initial state
3633+
except Exception as e:
3634+
self.log(f"Warn: Solis API: Startup register reset failed for inverter {sn}, continuing without it: {e}")
35673635
else:
35683636
# automatic_config() logs its own version of this, but only when auto-config is on
35693637
self.log(f"Solis API: Inverter {sn} reports no battery attached, so Predbat will poll it but not write any control registers to it")

0 commit comments

Comments
 (0)