Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.13.1] - 2026-02-27

### Fixed

- **CM1200 zero channel data regression** - Fixed redundant authentication call that caused basic_http modems (CM1200, C7000v2, CM600, C3700, TC4400) to make an extra HTTP request per poll cycle. The `_pre_authenticate()` gate condition incorrectly used HTML presence as a proxy for "auth was performed," but basic_http auth is stateless and never returns HTML. This caused connection resets on modems that rate-limit rapid HTTPS requests. (#121)

## [3.13.0] - 2026-02-24

### Added
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ git push --force-with-lease
## PR and Issue Rules

**NEVER use "Closes #X", "Fixes #X", or similar auto-close keywords.**
- This applies to **both PR bodies AND commit messages**
- GitHub scans all commit messages in a merge — a `Fixes #X` buried in any commit on the branch will auto-close the issue when merged to main
- Users should close their own tickets after confirming fixes work
- Use "Related to #X" or "Addresses #X" instead

Expand Down
2 changes: 1 addition & 1 deletion custom_components/cable_modem_monitor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# IMPORTANT: Do not edit VERSION manually!
# Use: python scripts/release.py <version>
# The script updates this file, manifest.json, and test_version_and_startup.py
VERSION = "3.13.0"
VERSION = "3.13.1"

DOMAIN = "cable_modem_monitor"
CONF_HOST = "host"
Expand Down
24 changes: 15 additions & 9 deletions custom_components/cable_modem_monitor/core/data_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,7 +1232,7 @@ def _parse_data(self, html: str) -> dict:

return self.parser.parse(soup)

def _pre_authenticate(self) -> tuple[bool, str | None]:
def _pre_authenticate(self) -> tuple[bool, str | None, bool]:
"""Pre-authenticate before data fetch for modems requiring auth.

For modems with an auth strategy configured, this authenticates BEFORE
Expand All @@ -1243,25 +1243,27 @@ def _pre_authenticate(self) -> tuple[bool, str | None]:
poll causes 401 errors due to server-side session tracking.

Returns:
tuple[bool, str | None]: (success, html_from_auth)
tuple[bool, str | None, bool]: (success, html_from_auth, auth_performed)
- success: True if auth succeeded or no auth needed
- html_from_auth: HTML returned during auth (some strategies return data), or None
- auth_performed: True if authentication was actually executed (not skipped)
"""
if not self._auth_strategy or not self.username or not self.password:
return (True, None)
return (True, None, False)

# URL token auth: skip pre-auth, use reactive auth flow instead
# These modems return login pages (not 401s) so reactive detection works.
# Pre-authenticating every poll causes 401 due to server-side session tracking.
if self._auth_strategy == "url_token_session":
_LOGGER.debug("Skipping pre-auth for url_token_session (using reactive auth)")
return (True, None)
return (True, None, False)

_LOGGER.debug(
"Pre-authenticating with strategy %s before data fetch",
self._auth_strategy,
)
return self._login()
success, html = self._login()
return (success, html, True)

def get_modem_data(self, capture_raw: bool = False) -> dict: # noqa: C901
"""Fetch and parse modem data.
Expand Down Expand Up @@ -1303,7 +1305,7 @@ def get_modem_data(self, capture_raw: bool = False) -> dict: # noqa: C901
_LOGGER.debug("Session has no cookies - will need to authenticate")

# Pre-authenticate for modems that require auth before any request
success, pre_auth_html = self._pre_authenticate()
success, pre_auth_html, pre_auth_performed = self._pre_authenticate()
if not success:
_LOGGER.error("Pre-authentication failed")
return self._create_error_response("auth_failed")
Expand All @@ -1325,9 +1327,13 @@ def get_modem_data(self, capture_raw: bool = False) -> dict: # noqa: C901
if not self._ensure_parser(html, successful_url, suggested_parser):
return self._create_error_response("offline")

# Additional auth check if we didn't pre-authenticate
# (handles session expiration for no-auth modems that later require login)
if not pre_auth_html:
# Additional auth check only if pre-auth was NOT performed
# Strategies like basic_http that pre-authenticated don't return HTML but
# DID authenticate — calling _authenticate() again would redundantly
# re-verify credentials (extra HTTP request to root URL per poll).
# This caused failures on modems that rate-limit or reset connections
# on rapid HTTPS requests (e.g., CM1200). Related: Issue #121.
if not pre_auth_performed:
authenticated = self._authenticate(html, data_url=successful_url)
if authenticated is None:
return self._create_error_response("auth_failed")
Expand Down
2 changes: 1 addition & 1 deletion custom_components/cable_modem_monitor/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"defusedxml==0.7.1",
"har-capture>=0.3.3,<0.4.0"
],
"version": "3.13.0"
"version": "3.13.1"
}
14 changes: 7 additions & 7 deletions tests/components/test_data_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1895,8 +1895,8 @@ def test_skips_pre_auth_for_url_token_session(self, mocker):

result = orchestrator._pre_authenticate()

# Should return success without calling _login
assert result == (True, None)
# Should return success without calling _login, auth_performed=False
assert result == (True, None, False)
mock_login.assert_not_called()

def test_pre_authenticates_for_hnap_session(self, mocker):
Expand All @@ -1918,9 +1918,9 @@ def test_pre_authenticates_for_hnap_session(self, mocker):

result = orchestrator._pre_authenticate()

# Should call _login for HNAP
# Should call _login for HNAP, auth_performed=True
mock_login.assert_called_once()
assert result == (True, "<html>auth</html>")
assert result == (True, "<html>auth</html>", True)

def test_pre_authenticates_for_form_plain(self, mocker):
"""Test that pre-auth IS performed for form_plain strategy."""
Expand All @@ -1937,7 +1937,7 @@ def test_pre_authenticates_for_form_plain(self, mocker):
result = orchestrator._pre_authenticate()

mock_login.assert_called_once()
assert result == (True, None)
assert result == (True, None, True)

def test_pre_authenticates_for_form_ajax(self, mocker):
"""Test that pre-auth IS performed for form_ajax strategy."""
Expand Down Expand Up @@ -1969,7 +1969,7 @@ def test_skips_pre_auth_when_no_credentials(self, mocker):

result = orchestrator._pre_authenticate()

assert result == (True, None)
assert result == (True, None, False)
mock_login.assert_not_called()

def test_skips_pre_auth_when_no_strategy(self, mocker):
Expand All @@ -1986,7 +1986,7 @@ def test_skips_pre_auth_when_no_strategy(self, mocker):

result = orchestrator._pre_authenticate()

assert result == (True, None)
assert result == (True, None, False)
mock_login.assert_not_called()

def test_url_token_uses_reactive_auth_flow(self, mocker):
Expand Down
2 changes: 1 addition & 1 deletion tests/components/test_version_and_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def test_current_version(self):
Use: python scripts/release.py <version>
The script updates const.py, manifest.json, and this file.
"""
assert VERSION == "3.13.0"
assert VERSION == "3.13.1"


class TestProtocolOptimizationIntegration:
Expand Down
Loading