Skip to content
Open
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
29 changes: 29 additions & 0 deletions custom_components/ocpp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -37,6 +38,33 @@
_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 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
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(
Expand Down Expand Up @@ -184,6 +212,7 @@ async def create(hass: HomeAssistant, entry: ConfigEntry):
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()
Expand Down
39 changes: 38 additions & 1 deletion tests/test_api_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -412,3 +412,40 @@ 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(caplog):
"""Existing Connection header is not overwritten and no warning is logged."""
import logging
from websockets.datastructures import Headers

headers = Headers()
headers["Connection"] = "Upgrade"
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 not any(r.levelname == "WARNING" for r in caplog.records)