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
30 changes: 29 additions & 1 deletion src/bsblan/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import asyncio
import json
import logging
from typing import TYPE_CHECKING, Any, cast

Expand Down Expand Up @@ -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:
Expand All @@ -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))
Comment on lines +167 to +171

def _process_response(
self, response_data: dict[str, Any], base_path: str
) -> dict[str, Any]:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_bsblan_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down
62 changes: 62 additions & 0 deletions tests/test_response_encoding.py
Original file line number Diff line number Diff line change
@@ -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"