@@ -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.
373376SOLIS_API_CODE_QUOTA_EXCEEDED = "R0000"
374377SOLIS_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)
378390BASE_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