Skip to content

Commit 6da7816

Browse files
authored
fix: surfboard session lifecycle and multi-firmware namespace (#176)
* fix: surfboard session lifecycle and multi-firmware namespace (#165) - Remove _fresh_session() from default login path to stop destroying sessions the modem still considers active (root cause of RELOAD loop) - RELOAD retry: 1st wait 5s same session, 2nd fresh session + 15s, 3rd fail - Add action namespace support (Customer for S34, Moto for SB8200) with auto-detection on first data fetch - HTTP 500 tries other namespace before re-authenticating - 12 new tests covering session lifecycle, namespace detection, HTTP 500 * fix: device info HTTP 500 namespace fallback (#165) Handle the case where an SB8200 rejects the Customer ConnectionInfo bundle with HTTP 500 before docsis-data detection has run. Without this, the collector caches empty device info permanently because get_device_info() is called once before get_docsis_data(). Extract _fetch_device_fields() to catch HTTPError 500 and try the other namespace before the broad except swallows it. Add test_device_info_http_500_namespace_fallback. --------- Co-authored-by: itsDNNS <itsDNNS@users.noreply.github.com>
1 parent f66dab6 commit 6da7816

File tree

2 files changed

+443
-39
lines changed

2 files changed

+443
-39
lines changed

app/drivers/surfboard.py

Lines changed: 172 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)