From 1c7bb6ad81d505a43333c1cbdcc0abb506f3dafe Mon Sep 17 00:00:00 2001 From: Vincent Vallee Date: Sat, 28 Mar 2026 15:13:58 -0300 Subject: [PATCH 1/4] Fix WebSocket handshake failure for chargers missing Connection: Upgrade header Some charger firmware (confirmed: Autel MaxiCharger US AC LW10-N14, firmware v1.38.00 / v1.22.00) sends a WebSocket opening handshake without the required `Connection: Upgrade` header. websockets 14+ strictly enforces this header in `ServerProtocol.accept()` and raises `InvalidUpgrade: missing Connection header`, causing the handshake to fail and the charger to be permanently unable to connect. Fix: add a `process_request` hook to the `websockets.serve()` call that injects `Connection: upgrade` when the header is absent, before the library's validation runs. A warning is logged so operators are aware and can chase a firmware update from the charger vendor. (cherry picked from commit 0a7e855b6457e423f63903890184f590a073112d) --- custom_components/ocpp/api.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 0dec30dc..9b702ce7 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -16,6 +16,7 @@ import homeassistant.helpers.config_validation as cv import voluptuous as vol from websockets import Subprotocol, NegotiationError +from websockets.http11 import Request, Response import websockets.server from websockets.asyncio.server import ServerConnection @@ -180,10 +181,33 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): else: self.ssl_context = None + async def _fix_missing_connection_header( + connection: ServerConnection, request: Request + ) -> Response | None: + """Work around a bug in some charger firmware (e.g. Autel MaxiCharger + US AC LW10-N14, firmware v1.38.00/v1.22.00) that omits the required + Connection: Upgrade header from the WebSocket opening handshake. + + websockets 14+ strictly validates this header and raises + InvalidUpgrade when it is absent, preventing the charger from + ever connecting. We inject the header before the library validates + it so that the handshake can proceed normally. + """ + if not request.headers.get_all("Connection"): + _LOGGER.warning( + "Charger at %s sent a WebSocket upgrade request without the " + "required 'Connection: Upgrade' header. Injecting header as " + "a workaround — consider updating the charger firmware.", + connection.remote_address, + ) + request.headers["Connection"] = "upgrade" + return None + server = await websockets.serve( self.on_connect, self.settings.host, self.settings.port, + process_request=_fix_missing_connection_header, select_subprotocol=self.select_subprotocol, subprotocols=self.subprotocols, ping_interval=None, # ping interval is not used here, because we send pings mamually in ChargePoint.monitor_connection() From d7c8b4bb5566029b323edf13c8710e209346adac Mon Sep 17 00:00:00 2001 From: root Date: Sun, 29 Mar 2026 01:52:37 -0300 Subject: [PATCH 2/4] refactor: lift _fix_missing_connection_header to module level and add unit tests Makes the function directly importable for testing without needing to instantiate CentralSystem or mock websockets.serve. Adds two async unit tests: one verifying header injection + warning log when Connection is absent, one verifying the header is left intact when already present. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 4729f1b5a0ad03104633d724aa3e1edfcaff71fa) --- custom_components/ocpp/api.py | 45 ++++++++++++++++++----------------- tests/test_api_paths.py | 36 +++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 9b702ce7..0ff339b3 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -38,6 +38,29 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) # Uncomment these when Debugging # logging.getLogger("asyncio").setLevel(logging.DEBUG) + + +async def _fix_missing_connection_header( + connection: ServerConnection, request: Request +) -> Response | None: + """Work around a bug in some charger firmware (e.g. Autel MaxiCharger + US AC LW10-N14, firmware v1.38.00/v1.22.00) that omits the required + Connection: Upgrade header from the WebSocket opening handshake. + + websockets 14+ strictly validates this header and raises + InvalidUpgrade when it is absent, preventing the charger from + ever connecting. We inject the header before the library validates + it so that the handshake can proceed normally. + """ + if not request.headers.get_all("Connection"): + _LOGGER.warning( + "Charger at %s sent a WebSocket upgrade request without the " + "required 'Connection: Upgrade' header. Injecting header as " + "a workaround — consider updating the charger firmware.", + connection.remote_address, + ) + request.headers["Connection"] = "upgrade" + return None # logging.getLogger("websockets").setLevel(logging.DEBUG) UFW_SERVICE_DATA_SCHEMA = vol.Schema( @@ -181,28 +204,6 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): else: self.ssl_context = None - async def _fix_missing_connection_header( - connection: ServerConnection, request: Request - ) -> Response | None: - """Work around a bug in some charger firmware (e.g. Autel MaxiCharger - US AC LW10-N14, firmware v1.38.00/v1.22.00) that omits the required - Connection: Upgrade header from the WebSocket opening handshake. - - websockets 14+ strictly validates this header and raises - InvalidUpgrade when it is absent, preventing the charger from - ever connecting. We inject the header before the library validates - it so that the handshake can proceed normally. - """ - if not request.headers.get_all("Connection"): - _LOGGER.warning( - "Charger at %s sent a WebSocket upgrade request without the " - "required 'Connection: Upgrade' header. Injecting header as " - "a workaround — consider updating the charger firmware.", - connection.remote_address, - ) - request.headers["Connection"] = "upgrade" - return None - server = await websockets.serve( self.on_connect, self.settings.host, diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index 48a899e3..14cc2a7f 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError from websockets import NegotiationError -from custom_components.ocpp.api import CentralSystem +from custom_components.ocpp.api import CentralSystem, _fix_missing_connection_header from custom_components.ocpp.const import DOMAIN from custom_components.ocpp.enums import ( HAChargerServices as csvcs, @@ -412,3 +412,37 @@ def test_del_metric_variants(hass): # --- Case C: unknown cpid -> returns None, no exception assert cs.del_metric("unknown_cpid", "Voltage") is None + + +@pytest.mark.asyncio +async def test_fix_missing_connection_header_injects_when_absent(caplog): + """Header is injected and warning logged when Connection header is missing.""" + import logging + from websockets.datastructures import Headers + + headers = Headers() # empty — no Connection header + request = SimpleNamespace(headers=headers) + connection = SimpleNamespace(remote_address=("192.168.1.100", 9000)) + + with caplog.at_level(logging.WARNING): + result = await _fix_missing_connection_header(connection, request) + + assert result is None + assert request.headers.get("Connection") == "upgrade" + assert "Connection: Upgrade" in caplog.text + + +@pytest.mark.asyncio +async def test_fix_missing_connection_header_leaves_existing_intact(): + """Existing Connection header is not overwritten and no warning is logged.""" + from websockets.datastructures import Headers + + headers = Headers() + headers["Connection"] = "Upgrade" + request = SimpleNamespace(headers=headers) + connection = SimpleNamespace(remote_address=("192.168.1.100", 9000)) + + result = await _fix_missing_connection_header(connection, request) + + assert result is None + assert request.headers.get("Connection") == "Upgrade" From 16201990532a7754d8ab171781ffcb6789958675 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 29 Mar 2026 02:03:41 -0300 Subject: [PATCH 3/4] test: assert no WARNING logged when Connection header already present test_fix_missing_connection_header_leaves_existing_intact claimed "no warning is logged" in its docstring but never verified it. Added caplog fixture, wrapped the call in caplog.at_level(WARNING), and asserted no WARNING-level records were emitted. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 4247ac41ecd072c6410f150f531f64b3511d3d89) --- tests/test_api_paths.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index 14cc2a7f..6a4fd00b 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -433,8 +433,9 @@ async def test_fix_missing_connection_header_injects_when_absent(caplog): @pytest.mark.asyncio -async def test_fix_missing_connection_header_leaves_existing_intact(): +async def test_fix_missing_connection_header_leaves_existing_intact(caplog): """Existing Connection header is not overwritten and no warning is logged.""" + import logging from websockets.datastructures import Headers headers = Headers() @@ -442,7 +443,9 @@ async def test_fix_missing_connection_header_leaves_existing_intact(): request = SimpleNamespace(headers=headers) connection = SimpleNamespace(remote_address=("192.168.1.100", 9000)) - result = await _fix_missing_connection_header(connection, request) + with caplog.at_level(logging.WARNING): + result = await _fix_missing_connection_header(connection, request) assert result is None assert request.headers.get("Connection") == "Upgrade" + assert not any(r.levelname == "WARNING" for r in caplog.records) From 9c709a6ff92e27a8eeb2891eabff9dd35f7bb2ef Mon Sep 17 00:00:00 2001 From: root Date: Wed, 1 Apr 2026 22:21:59 -0300 Subject: [PATCH 4/4] Fix ruff lint and format errors in _fix_missing_connection_header Add missing blank lines after function definition (E302) and restructure docstring to have a one-line summary (D205). Co-Authored-By: Claude Opus 4.6 (cherry picked from commit bfdb4ee5b7b505e2604fd4158af531e24d795a18) --- custom_components/ocpp/api.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 0ff339b3..a3b55c79 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -43,9 +43,11 @@ async def _fix_missing_connection_header( connection: ServerConnection, request: Request ) -> Response | None: - """Work around a bug in some charger firmware (e.g. Autel MaxiCharger - US AC LW10-N14, firmware v1.38.00/v1.22.00) that omits the required - Connection: Upgrade header from the WebSocket opening handshake. + """Work around charger firmware that omits the Connection: Upgrade header. + + Some charger firmware (e.g. Autel MaxiCharger US AC LW10-N14, + firmware v1.38.00/v1.22.00) omits the required Connection: Upgrade + header from the WebSocket opening handshake. websockets 14+ strictly validates this header and raises InvalidUpgrade when it is absent, preventing the charger from @@ -61,6 +63,8 @@ async def _fix_missing_connection_header( ) request.headers["Connection"] = "upgrade" return None + + # logging.getLogger("websockets").setLevel(logging.DEBUG) UFW_SERVICE_DATA_SCHEMA = vol.Schema(