diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 4111fbd..b5cb657 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ -blank_issues_enabled: true -contact_links: - - name: Discussions - url: https://github.com/renjfk/OpenNeato/discussions - about: Ask questions, share ideas, or get help with setup before opening an issue. +blank_issues_enabled: true +contact_links: + - name: Discussions + url: https://github.com/Leicas/OpenNeato/discussions + about: Ask questions, share ideas, or get help with setup before opening an issue. diff --git a/custom_components/openneato/api.py b/custom_components/openneato/api.py index e0bf9c8..5f75556 100644 --- a/custom_components/openneato/api.py +++ b/custom_components/openneato/api.py @@ -231,19 +231,30 @@ async def get_history_session(self, filename: str) -> str: async with timeout(TIMEOUT): async with self._session.get(url) as response: response.raise_for_status() - raw = await response.content.read(MAX_HISTORY_RESPONSE_BYTES + 1) - if len(raw) > MAX_HISTORY_RESPONSE_BYTES: - raise OpenNeatoApiError( - f"Session {filename} exceeds size cap " - f"({MAX_HISTORY_RESPONSE_BYTES} bytes)" - ) + # The firmware serves this endpoint as an HTTP chunked + # transfer with no Content-Length (beginChunkedResponse + # in web_server.cpp). On a chunked aiohttp response, + # content.read(n) returns as soon as ANY buffered data is + # available — it does NOT block until n bytes or EOF — so + # a single bounded read silently truncates the JSONL to + # the first chunk, producing a PARTIAL map. Drain the full + # stream to EOF (matching the frontend's res.text()), + # enforcing the size cap incrementally as we accumulate. + buf = bytearray() + async for chunk in response.content.iter_chunked(65536): + buf.extend(chunk) + if len(buf) > MAX_HISTORY_RESPONSE_BYTES: + raise OpenNeatoApiError( + f"Session {filename} exceeds size cap " + f"({MAX_HISTORY_RESPONSE_BYTES} bytes)" + ) # Firmware emits UTF-8 JSONL; hardcode rather than # call response.get_encoding(), which raises in # modern aiohttp when content was streamed via - # response.content.read() (the streaming path - # doesn't populate the response's _body buffer - # that get_encoding's chardet fallback needs). - return raw.decode("utf-8", errors="replace") + # response.content (the streaming path doesn't + # populate the response's _body buffer that + # get_encoding's chardet fallback needs). + return bytes(buf).decode("utf-8", errors="replace") except aiohttp.ClientConnectionError as err: raise OpenNeatoConnectionError( f"Unable to connect to OpenNeato at {self._host}: {err}" diff --git a/custom_components/openneato/history_renderer.py b/custom_components/openneato/history_renderer.py index 1f8ad6b..48d7375 100644 --- a/custom_components/openneato/history_renderer.py +++ b/custom_components/openneato/history_renderer.py @@ -18,6 +18,7 @@ import io import json +import logging import math import re from typing import Any @@ -43,6 +44,8 @@ MOTION_TOTAL_MS, ) +_LOGGER = logging.getLogger(__name__) + # ── JSONL parsing (ported from history-data.ts) ───────────────────── @@ -90,13 +93,23 @@ def parse_session_jsonl(raw: str) -> dict[str, Any]: poses: list[dict[str, float]] = [] recharges: list[tuple[float, float]] = [] - for line in lines: + for idx, line in enumerate(lines): try: obj = json.loads(line) except json.JSONDecodeError: repaired = _try_repair_pose(line) if repaired: poses.append(repaired) + elif idx == len(lines) - 1: + # A final line that parses as neither JSON nor a repairable + # pose is the classic signature of a truncated download (the + # stream was cut mid-line). Surface it so a silently-partial + # map is observable rather than rendered without warning. + _LOGGER.warning( + "Final history line failed to parse (%d chars); " + "session data may be truncated, map will be partial", + len(line), + ) continue obj_type = obj.get("type") diff --git a/custom_components/openneato/lidar_renderer.py b/custom_components/openneato/lidar_renderer.py index 4954d8c..15a2e53 100644 --- a/custom_components/openneato/lidar_renderer.py +++ b/custom_components/openneato/lidar_renderer.py @@ -114,7 +114,10 @@ def render_lidar_scan( # ── Helpers ────────────────────────────────────────────────────── def to_canvas(angle_deg: float, dist: float) -> tuple[float, float]: - rad = math.radians(90 - angle_deg) + # Match the frontend reference (lidar-map.tsx): rad = (90 + angle). + # Using (90 - angle) here flips the cos sign and mirrors the scan + # horizontally relative to the web dashboard. + rad = math.radians(90 + angle_deg) return (cx + dist * scale * math.cos(rad), cy - dist * scale * math.sin(rad)) def opacity(age: int) -> float: