From 83a0ee4bd5a26c2d312c293351c73a636cb653d9 Mon Sep 17 00:00:00 2001 From: Antoine Weill--Duflos Date: Sat, 30 May 2026 12:27:11 -0400 Subject: [PATCH 1/3] fix: drain chunked history stream so HA renders full map The HA integration rendered only a partial lidar/history map while the web dashboard showed the full one. get_history_session() did a single response.content.read(N) against the firmware GET /api/history/, which is served as a chunked transfer with no Content-Length. aiohttp read(n) returns the first buffered chunk rather than blocking to EOF, so the JSONL was silently truncated and parse_session_jsonl dropped every pose past the cut. Drain the stream to EOF via iter_chunked (matching the frontend res.text()), enforcing the size cap incrementally, and warn when the final history line fails to parse so future truncation is observable. Fixes renjfk/OpenNeato#132 Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/openneato/api.py | 31 +++++++++++++------ .../openneato/history_renderer.py | 15 ++++++++- 2 files changed, 35 insertions(+), 11 deletions(-) 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") From 33b0fa5717daacbd7620c5d82df9c08d4a4beccd Mon Sep 17 00:00:00 2001 From: Antoine Weill--Duflos Date: Sat, 30 May 2026 12:27:51 -0400 Subject: [PATCH 2/3] fix: match HA live lidar orientation to dashboard to_canvas() used radians(90 - angle) which flips the cos sign and mirrors the live lidar scan horizontally versus the web dashboard. Use radians(90 + angle) to match the frontend reference (lidar-map.tsx). Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/openneato/lidar_renderer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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: From 7565c1ebaab5906d3aa1f78a625911f3bdf41334 Mon Sep 17 00:00:00 2001 From: Antoine Weill--Duflos Date: Sat, 30 May 2026 12:27:51 -0400 Subject: [PATCH 3/3] chore: point issue-template discussions link to fork Discussions are now enabled on this fork; repoint the contact link from the upstream repo to Leicas/OpenNeato. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/ISSUE_TEMPLATE/config.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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.