diff --git a/CHANGELOG.md b/CHANGELOG.md index 69891755..dd57f368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 35b535bc..1b0e36ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/custom_components/cable_modem_monitor/const.py b/custom_components/cable_modem_monitor/const.py index b88e0fb1..1edee424 100644 --- a/custom_components/cable_modem_monitor/const.py +++ b/custom_components/cable_modem_monitor/const.py @@ -5,7 +5,7 @@ # IMPORTANT: Do not edit VERSION manually! # Use: python scripts/release.py # 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" diff --git a/custom_components/cable_modem_monitor/core/data_orchestrator.py b/custom_components/cable_modem_monitor/core/data_orchestrator.py index a8f3ab1b..18d335ba 100644 --- a/custom_components/cable_modem_monitor/core/data_orchestrator.py +++ b/custom_components/cable_modem_monitor/core/data_orchestrator.py @@ -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 @@ -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. @@ -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") @@ -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") diff --git a/custom_components/cable_modem_monitor/manifest.json b/custom_components/cable_modem_monitor/manifest.json index 0d6c9497..70008db7 100644 --- a/custom_components/cable_modem_monitor/manifest.json +++ b/custom_components/cable_modem_monitor/manifest.json @@ -17,5 +17,5 @@ "defusedxml==0.7.1", "har-capture>=0.3.3,<0.4.0" ], - "version": "3.13.0" + "version": "3.13.1" } diff --git a/tests/components/test_data_orchestrator.py b/tests/components/test_data_orchestrator.py index 801ac69a..5f5c426f 100644 --- a/tests/components/test_data_orchestrator.py +++ b/tests/components/test_data_orchestrator.py @@ -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): @@ -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, "auth") + assert result == (True, "auth", True) def test_pre_authenticates_for_form_plain(self, mocker): """Test that pre-auth IS performed for form_plain strategy.""" @@ -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.""" @@ -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): @@ -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): diff --git a/tests/components/test_version_and_startup.py b/tests/components/test_version_and_startup.py index 33164ae3..d170fb4b 100644 --- a/tests/components/test_version_and_startup.py +++ b/tests/components/test_version_and_startup.py @@ -113,7 +113,7 @@ def test_current_version(self): Use: python scripts/release.py The script updates const.py, manifest.json, and this file. """ - assert VERSION == "3.13.0" + assert VERSION == "3.13.1" class TestProtocolOptimizationIntegration: