From 4476516b5df5ca4e3683002272b61997458d6be9 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sun, 5 Jul 2026 20:22:33 +0200 Subject: [PATCH] Implement tolerant JSON decoding for non-UTF-8 BSB-LAN responses and add corresponding tests --- src/bsblan/_transport.py | 30 +++++++++++++++- tests/test_bsblan_edge_cases.py | 4 +-- tests/test_response_encoding.py | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/test_response_encoding.py diff --git a/src/bsblan/_transport.py b/src/bsblan/_transport.py index bf2e1a6f..074b2339 100644 --- a/src/bsblan/_transport.py +++ b/src/bsblan/_transport.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import json import logging from typing import TYPE_CHECKING, Any, cast @@ -131,7 +132,7 @@ async def request_with_retry( headers=headers, ) as response: response.raise_for_status() - response_data = cast("dict[str, Any]", await response.json()) + response_data = await self._read_json(response) return self._process_response(response_data, base_path) except aiohttp.ClientResponseError as e: if e.status in HTTP_AUTH_STATUSES: @@ -142,6 +143,33 @@ async def request_with_retry( msg = ErrorMsg.INVALID_RESPONSE.format(e) raise BSBLANError(msg) from e + @staticmethod + async def _read_json(response: aiohttp.ClientResponse) -> dict[str, Any]: + """Decode a BSB-LAN JSON response tolerant of non-UTF-8 encodings. + + Some BSB-LAN firmwares serve custom parameter descriptions using + Latin-1 (ISO-8859-1) bytes (for example the ``§`` or ``°`` characters) + while declaring no charset. ``aiohttp`` assumes UTF-8 and raises + ``UnicodeDecodeError``. Read the raw body and fall back to Latin-1, + which can decode any byte sequence, before parsing the JSON. + + Args: + response: The active aiohttp response to read. + + Returns: + dict[str, Any]: The parsed JSON payload. + + Raises: + ValueError: If the body is not valid JSON. + + """ + raw = await response.read() + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + text = raw.decode("latin-1") + return cast("dict[str, Any]", json.loads(text)) + def _process_response( self, response_data: dict[str, Any], base_path: str ) -> dict[str, Any]: diff --git a/tests/test_bsblan_edge_cases.py b/tests/test_bsblan_edge_cases.py index 74278645..a996d739 100644 --- a/tests/test_bsblan_edge_cases.py +++ b/tests/test_bsblan_edge_cases.py @@ -71,13 +71,13 @@ async def test_value_error_path(monkeypatch: Any) -> None: # Mock a successful response but with invalid JSON processing mock_response = MagicMock() mock_response.raise_for_status = MagicMock() - mock_response.json = AsyncMock(side_effect=ValueError("Invalid JSON")) + mock_response.read = AsyncMock(return_value=b"Invalid JSON") mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=None) monkeypatch.setattr(session, "request", MagicMock(return_value=mock_response)) - with pytest.raises(BSBLANError, match="Invalid JSON"): + with pytest.raises(BSBLANError, match="Invalid response format"): await bsblan._request() diff --git a/tests/test_response_encoding.py b/tests/test_response_encoding.py new file mode 100644 index 00000000..b1ad3e39 --- /dev/null +++ b/tests/test_response_encoding.py @@ -0,0 +1,62 @@ +"""Tests for tolerant decoding of non-UTF-8 BSB-LAN responses.""" + +# file deepcode ignore W0212: this is a testfile +# pylint: disable=protected-access + +import aiohttp +import pytest +from aresponses import ResponsesMockServer + +from bsblan import BSBLAN +from bsblan.bsblan import BSBLANConfig + + +@pytest.mark.asyncio +async def test_request_decodes_latin1_response( + aresponses: ResponsesMockServer, +) -> None: + """Latin-1 responses (e.g. custom descriptions with '§') are decoded.""" + # "§" is byte 0xa7 in Latin-1 and is an invalid UTF-8 start byte. + payload = '{"700": {"name": "x", "desc": "Betrieb \u00a7", "value": "1"}}' + aresponses.add( + "example.com", + "/JQ", + "POST", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + body=payload.encode("latin-1"), + ), + ) + + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + response = await bsblan._request() + + assert response["700"]["desc"] == "Betrieb \u00a7" + + +@pytest.mark.asyncio +async def test_request_decodes_utf8_response( + aresponses: ResponsesMockServer, +) -> None: + """Valid UTF-8 responses keep decoding correctly.""" + payload = '{"700": {"name": "x", "desc": "Außentemperatur", "value": "1"}}' + aresponses.add( + "example.com", + "/JQ", + "POST", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + body=payload.encode("utf-8"), + ), + ) + + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + response = await bsblan._request() + + assert response["700"]["desc"] == "Außentemperatur"