Skip to content

Commit 9696d3d

Browse files
committed
Fix CM3000 driver returning 0 channels on first poll
The status page cache was cleared after get_device_info() consumed it, forcing get_docsis_data() to make a second HTTP request that could return different data. The cache now persists for the entire collect cycle so all methods use the same validated HTML. Also made regex patterns robust against nested JS braces and added diagnostic logging when 0 channels are parsed. Ref #164
1 parent f73566c commit 9696d3d

File tree

2 files changed

+52
-9
lines changed

2 files changed

+52
-9
lines changed

app/drivers/cm3000.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,28 +28,30 @@
2828
# Match the single-quoted live tagValueList in each function.
2929
# Commented-out examples use double quotes or /* */ blocks, so
3030
# targeting single quotes skips them reliably.
31+
# Uses .*? (lazy) instead of [^}]*? to support nested braces in
32+
# function bodies (e.g. if-blocks in some firmware versions).
3133
_RE_DS_QAM = re.compile(
32-
r"function\s+InitDsTableTagValue\s*\(\)\s*\{[^}]*?"
34+
r"function\s+InitDsTableTagValue\s*\(\)\s*\{.*?"
3335
r"var\s+tagValueList\s*=\s*'([^']+)';",
3436
re.DOTALL,
3537
)
3638
_RE_US_ATDMA = re.compile(
37-
r"function\s+InitUsTableTagValue\s*\(\)\s*\{[^}]*?"
39+
r"function\s+InitUsTableTagValue\s*\(\)\s*\{.*?"
3840
r"var\s+tagValueList\s*=\s*'([^']+)';",
3941
re.DOTALL,
4042
)
4143
_RE_DS_OFDM = re.compile(
42-
r"function\s+InitDsOfdmTableTagValue\s*\(\)\s*\{[^}]*?"
44+
r"function\s+InitDsOfdmTableTagValue\s*\(\)\s*\{.*?"
4345
r"var\s+tagValueList\s*=\s*'([^']+)';",
4446
re.DOTALL,
4547
)
4648
_RE_US_OFDMA = re.compile(
47-
r"function\s+InitUsOfdmaTableTagValue\s*\(\)\s*\{[^}]*?"
49+
r"function\s+InitUsOfdmaTableTagValue\s*\(\)\s*\{.*?"
4850
r"var\s+tagValueList\s*=\s*'([^']+)';",
4951
re.DOTALL,
5052
)
5153
_RE_SYS_INFO = re.compile(
52-
r"function\s+InitTagValue\s*\(\)\s*\{[^}]*?"
54+
r"function\s+InitTagValue\s*\(\)\s*\{.*?"
5355
r"var\s+tagValueList\s*=\s*'([^']+)';",
5456
re.DOTALL,
5557
)
@@ -120,6 +122,18 @@ def get_docsis_data(self) -> dict:
120122
ds31 = self._parse_ds_ofdm(html)
121123
us31 = self._parse_us_ofdma(html)
122124

125+
total = len(ds30) + len(us30) + len(ds31) + len(us31)
126+
if total == 0:
127+
log.warning(
128+
"CM3000 parsed 0 channels (DS QAM regex=%s, US ATDMA regex=%s, "
129+
"DS OFDM regex=%s, US OFDMA regex=%s, page length=%d)",
130+
_RE_DS_QAM.search(html) is not None,
131+
_RE_US_ATDMA.search(html) is not None,
132+
_RE_DS_OFDM.search(html) is not None,
133+
_RE_US_OFDMA.search(html) is not None,
134+
len(html),
135+
)
136+
123137
return {
124138
"channelDs": {"docsis30": ds30, "docsis31": ds31},
125139
"channelUs": {"docsis30": us30, "docsis31": us31},
@@ -160,11 +174,11 @@ def _fetch_status_page(self) -> str:
160174
"""Fetch the raw HTML of /DocsisStatus.htm.
161175
162176
Reuses the validated HTML captured during login when available.
177+
The cache persists until the next login() call overwrites it,
178+
so all methods in a single collect cycle use the same page.
163179
"""
164180
if self._status_html is not None:
165-
html = self._status_html
166-
self._status_html = None
167-
return html
181+
return self._status_html
168182

169183
try:
170184
r = self._session.get(

tests/test_cm3000_driver.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,8 @@ def test_fetch_status_page_uses_cached_login_html(self, driver):
474474
with patch.object(driver._session, "get") as mock_get:
475475
assert driver._fetch_status_page() == STATUS_HTML
476476
mock_get.assert_not_called()
477-
assert driver._status_html is None
477+
# Cache persists for the entire collect cycle
478+
assert driver._status_html == STATUS_HTML
478479

479480

480481
# -- Regex patterns --
@@ -503,6 +504,34 @@ def test_all_patterns_match(self):
503504
assert _RE_SYS_INFO.search(html) is not None
504505

505506

507+
# -- Collect cycle (cache reuse) --
508+
509+
class TestCollectCycle:
510+
def test_device_info_and_docsis_data_share_cached_html(self, driver):
511+
"""Both get_device_info() and get_docsis_data() must use the same
512+
cached HTML from login(), without a second HTTP fetch."""
513+
driver._status_html = STATUS_HTML
514+
515+
with patch.object(driver._session, "get") as mock_get:
516+
info = driver.get_device_info()
517+
data = driver.get_docsis_data()
518+
mock_get.assert_not_called()
519+
520+
assert info["model"] == "CM3000"
521+
assert len(data["channelDs"]["docsis30"]) == 32
522+
assert len(data["channelUs"]["docsis30"]) == 4
523+
524+
def test_regex_handles_nested_braces(self, driver):
525+
"""Functions with nested braces (if-blocks) must still parse."""
526+
html = _build_status_html().replace(
527+
"function InitDsTableTagValue()\n{",
528+
"function InitDsTableTagValue()\n{\n if (true) { console.log('ok'); }",
529+
)
530+
driver._status_html = html
531+
data = driver.get_docsis_data()
532+
assert len(data["channelDs"]["docsis30"]) == 32
533+
534+
506535
# -- Analyzer integration --
507536

508537
class TestAnalyzerIntegration:

0 commit comments

Comments
 (0)