@@ -81,6 +81,9 @@ def __init__(self, url: str, user: str, password: str):
8181 # HMAC algorithm -- auto-detected during login.
8282 # S34 uses SHA256, SB8200 uses MD5.
8383 self ._hmac_algo : str = ""
84+ # Action namespace: "" = unknown, "Customer" (S34) or "Moto" (SB8200).
85+ # Persists across session resets (firmware property, not session state).
86+ self ._action_ns : str = ""
8487
8588 def _fresh_session (self ) -> None :
8689 """Reset HTTP session to clear stale cookies/state."""
@@ -99,36 +102,56 @@ def login(self) -> None:
99102 a fresh login when no session exists or after a failed request
100103 invalidated the session.
101104
102- Retries with a fresh session on ConnectionError or when the
103- modem returns no challenge (stale session / concurrent login).
105+ RELOAD handling (modem considers previous session still active):
106+ 1. First RELOAD: wait 5s, retry on same session
107+ 2. Second RELOAD: fresh session + 15s wait, retry
108+ 3. Third RELOAD: fail
109+
110+ ConnectionError: fresh session + retry (transport failure).
104111 """
105112 if self ._logged_in :
106113 return
107114
108- for attempt in range (3 ):
115+ reload_count = 0
116+ conn_errors = 0
117+ while True :
109118 try :
110- self ._fresh_session ()
111119 self ._do_login ()
112120 log .info ("SURFboard HNAP login OK" )
113121 self ._logged_in = True
114122 return
115123 except requests .ConnectionError :
116- if attempt < 2 :
117- log .warning ("SURFboard connection lost, retrying with fresh session" )
118- time .sleep (1 )
119- continue
120- raise RuntimeError ("SURFboard login failed: connection refused after retry" )
124+ conn_errors += 1
125+ self ._fresh_session ()
126+ if conn_errors >= 3 :
127+ raise RuntimeError (
128+ "SURFboard login failed: connection refused after retry"
129+ )
130+ log .warning (
131+ "SURFboard connection lost, retrying with fresh session"
132+ )
133+ time .sleep (1 )
121134 except RuntimeError as e :
122- if "no challenge received" in str (e ) and attempt < 2 :
123- delay = 10 * (attempt + 1 )
135+ if "no challenge received" not in str (e ):
136+ raise
137+ reload_count += 1
138+ if reload_count == 1 :
124139 log .warning (
125140 "SURFboard RELOAD (stale session on modem), "
126- "waiting %ds for session to expire (attempt %d/3)" ,
127- delay , attempt + 1 ,
141+ "waiting 5s, retrying on same session (reload %d/3)" ,
142+ reload_count ,
128143 )
129- time .sleep (delay )
130- continue
131- raise
144+ time .sleep (5 )
145+ elif reload_count == 2 :
146+ log .warning (
147+ "SURFboard RELOAD persists, fresh session + "
148+ "15s wait (reload %d/3)" ,
149+ reload_count ,
150+ )
151+ self ._fresh_session ()
152+ time .sleep (15 )
153+ else :
154+ raise
132155 except requests .RequestException as e :
133156 raise RuntimeError (f"SURFboard login failed: { e } " )
134157
@@ -236,37 +259,85 @@ def _try_phase2(self, algo, challenge: str, public_key: str) -> None:
236259 def get_docsis_data (self ) -> dict :
237260 """Retrieve DOCSIS channel data via HNAP GetMultipleHNAPs.
238261
239- Retries once with a fresh login if the request fails (expired session).
262+ On HTTP 500: tries the other action namespace before re-authenticating.
263+ On other HTTP errors: re-authenticates (session expired).
240264 """
241265 try :
242266 return self ._fetch_docsis_data ()
243267 except requests .HTTPError as e :
244- log .warning ("DOCSIS data fetch failed (HTTP %d), re-authenticating" ,
245- e .response .status_code if e .response is not None else 0 )
268+ status = e .response .status_code if e .response is not None else 0
269+
270+ if status == 500 :
271+ current = self ._action_ns or "Customer"
272+ other = "Moto" if current == "Customer" else "Customer"
273+ log .warning (
274+ "HTTP 500, trying %s namespace (was %s)" , other , current ,
275+ )
276+ prev_ns = self ._action_ns
277+ self ._action_ns = other
278+ try :
279+ return self ._fetch_docsis_data ()
280+ except requests .HTTPError :
281+ self ._action_ns = prev_ns
282+
283+ log .warning (
284+ "DOCSIS data fetch failed (HTTP %d), re-authenticating" ,
285+ status ,
286+ )
246287 self ._logged_in = False
247288 self .login ()
248289 return self ._fetch_docsis_data ()
249290
250291 def _fetch_docsis_data (self ) -> dict :
251- """Internal: fetch and parse DOCSIS channel data."""
292+ """Internal: fetch and parse DOCSIS channel data.
293+
294+ Auto-detects action namespace (Customer vs Moto) on first call
295+ by trying Customer first, then Moto if no data is returned.
296+ """
252297 body = {
253- "GetMultipleHNAPs" : {
254- "GetCustomerStatusDownstreamChannelInfo" : "" ,
255- "GetCustomerStatusUpstreamChannelInfo" : "" ,
256- }
298+ "GetMultipleHNAPs" : self ._make_actions (
299+ "DownstreamChannelInfo" , "UpstreamChannelInfo"
300+ )
257301 }
258302 resp = self ._hnap_post ("GetMultipleHNAPs" , body )
259303 multi = resp .get ("GetMultipleHNAPsResponse" , {})
260304
261305 ds_raw = (
262- multi .get ("GetCustomerStatusDownstreamChannelInfoResponse" , {})
263- .get ("CustomerConnDownstreamChannel" , "" )
306+ multi .get (self . _response_key ( "DownstreamChannelInfo" ) , {})
307+ .get (self . _conn_field ( "DownstreamChannel" ) , "" )
264308 )
265309 us_raw = (
266- multi .get ("GetCustomerStatusUpstreamChannelInfoResponse" , {})
267- .get ("CustomerConnUpstreamChannel" , "" )
310+ multi .get (self . _response_key ( "UpstreamChannelInfo" ) , {})
311+ .get (self . _conn_field ( "UpstreamChannel" ) , "" )
268312 )
269313
314+ # Auto-detect namespace: if no data and namespace unknown, try Moto
315+ if not ds_raw and not us_raw and not self ._action_ns :
316+ self ._action_ns = "Moto"
317+ log .info ("No channel data with Customer namespace, trying Moto" )
318+ body = {
319+ "GetMultipleHNAPs" : self ._make_actions (
320+ "DownstreamChannelInfo" , "UpstreamChannelInfo"
321+ )
322+ }
323+ resp = self ._hnap_post ("GetMultipleHNAPs" , body )
324+ multi = resp .get ("GetMultipleHNAPsResponse" , {})
325+ ds_raw = (
326+ multi .get (self ._response_key ("DownstreamChannelInfo" ), {})
327+ .get (self ._conn_field ("DownstreamChannel" ), "" )
328+ )
329+ us_raw = (
330+ multi .get (self ._response_key ("UpstreamChannelInfo" ), {})
331+ .get (self ._conn_field ("UpstreamChannel" ), "" )
332+ )
333+ if not ds_raw and not us_raw :
334+ self ._action_ns = ""
335+ log .warning (
336+ "Neither Customer nor Moto namespace returned channel data"
337+ )
338+ elif (ds_raw or us_raw ) and not self ._action_ns :
339+ self ._action_ns = "Customer"
340+
270341 ds30 , ds31 = self ._parse_downstream (ds_raw )
271342 us30 , us31 = self ._parse_upstream (us_raw )
272343
@@ -278,30 +349,92 @@ def _fetch_docsis_data(self) -> dict:
278349 def get_device_info (self ) -> dict :
279350 """Retrieve device model and firmware from HNAP."""
280351 try :
281- body = {
282- "GetMultipleHNAPs" : {
283- "GetCustomerStatusStartupSequence" : "" ,
284- "GetCustomerStatusConnectionInfo" : "" ,
285- }
286- }
287- resp = self ._hnap_post ("GetMultipleHNAPs" , body )
288- multi = resp .get ("GetMultipleHNAPsResponse" , {})
352+ model , sw = self ._fetch_device_fields ()
289353
290- cust = multi .get ("GetCustomerStatusConnectionInfoResponse" , {})
354+ # Fallback to other namespace if no model and namespace unknown
355+ if not model and not self ._action_ns :
356+ self ._action_ns = "Moto"
357+ model , sw = self ._fetch_device_fields ()
358+ if not model :
359+ self ._action_ns = ""
291360
292361 return {
293362 "manufacturer" : "Arris" ,
294- "model" : cust . get ( "StatusSoftwareModelName" , "" ) ,
295- "sw_version" : cust . get ( "StatusSoftwareSfVer" , "" ) ,
363+ "model" : model ,
364+ "sw_version" : sw ,
296365 }
297366 except Exception :
298367 log .warning ("Failed to retrieve device info, will retry next poll" )
299368 return {"manufacturer" : "Arris" , "model" : "" , "sw_version" : "" }
300369
370+ def _fetch_device_fields (self ) -> tuple [str , str ]:
371+ """Fetch model and firmware from HNAP, with HTTP 500 namespace fallback.
372+
373+ Returns (model, sw_version) strings. On HTTP 500 with unknown
374+ namespace, tries the other namespace before propagating.
375+ """
376+ try :
377+ body = {
378+ "GetMultipleHNAPs" : self ._make_actions (
379+ "StartupSequence" , "ConnectionInfo"
380+ )
381+ }
382+ resp = self ._hnap_post ("GetMultipleHNAPs" , body )
383+ except requests .HTTPError as e :
384+ status = e .response .status_code if e .response is not None else 0
385+ if status == 500 and not self ._action_ns :
386+ current = self ._action_ns or "Customer"
387+ other = "Moto" if current == "Customer" else "Customer"
388+ log .warning (
389+ "Device info HTTP 500, trying %s namespace" , other ,
390+ )
391+ self ._action_ns = other
392+ body = {
393+ "GetMultipleHNAPs" : self ._make_actions (
394+ "StartupSequence" , "ConnectionInfo"
395+ )
396+ }
397+ resp = self ._hnap_post ("GetMultipleHNAPs" , body )
398+ else :
399+ raise
400+
401+ multi = resp .get ("GetMultipleHNAPsResponse" , {})
402+ conn = multi .get (self ._response_key ("ConnectionInfo" ), {})
403+ return (
404+ conn .get ("StatusSoftwareModelName" , "" ),
405+ conn .get ("StatusSoftwareSfVer" , "" ),
406+ )
407+
301408 def get_connection_info (self ) -> dict :
302409 """Standalone modem -- no connection info available."""
303410 return {}
304411
412+ # -- Namespace helpers --
413+
414+ def _make_actions (self , * suffixes : str ) -> dict :
415+ """Build HNAP action dict using current namespace.
416+
417+ Returns e.g. {"GetCustomerStatusDownstreamChannelInfo": "", ...}
418+ """
419+ ns = self ._action_ns or "Customer"
420+ return {f"Get{ ns } Status{ s } " : "" for s in suffixes }
421+
422+ def _response_key (self , suffix : str ) -> str :
423+ """Build response key for the given action suffix.
424+
425+ Returns e.g. "GetCustomerStatusDownstreamChannelInfoResponse"
426+ """
427+ ns = self ._action_ns or "Customer"
428+ return f"Get{ ns } Status{ suffix } Response"
429+
430+ def _conn_field (self , field : str ) -> str :
431+ """Build connection data field name.
432+
433+ Returns e.g. "CustomerConnDownstreamChannel"
434+ """
435+ ns = self ._action_ns or "Customer"
436+ return f"{ ns } Conn{ field } "
437+
305438 # -- HNAP transport --
306439
307440 def _hnap_post (self , action : str , body : dict , * ,
0 commit comments