From b52ff6a847ca483ac0356e355f833b94ae639ccd Mon Sep 17 00:00:00 2001 From: Yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 5 May 2026 20:05:34 -0400 Subject: [PATCH 1/3] Expand GPS diagnostics from repeater e4efc80 --- .../pymc_repeater/binary_sensor.py | 28 ++++ custom_components/pymc_repeater/manifest.json | 6 +- custom_components/pymc_repeater/sensor.py | 122 ++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/custom_components/pymc_repeater/binary_sensor.py b/custom_components/pymc_repeater/binary_sensor.py index f9e1e7a..7e7d9a5 100644 --- a/custom_components/pymc_repeater/binary_sensor.py +++ b/custom_components/pymc_repeater/binary_sensor.py @@ -75,6 +75,20 @@ def _any_mqtt_connected(data: dict[str, Any]) -> bool: entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: bool(_nested(data, "update_status", "has_update")), ), + PyMCBinarySensorDescription( + key="gps_enabled", + name="GPS enabled", + icon="mdi:satellite-uplink", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: bool(_nested(data, "gps", "enabled")), + ), + PyMCBinarySensorDescription( + key="gps_running", + name="GPS running", + icon="mdi:run-fast", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: bool(_nested(data, "gps", "running")), + ), PyMCBinarySensorDescription( key="gps_fix_valid", name="GPS fix valid", @@ -83,6 +97,20 @@ def _any_mqtt_connected(data: dict[str, Any]) -> bool: device_class=BinarySensorDeviceClass.CONNECTIVITY, value_fn=lambda data: bool(_nested(data, "gps", "status", "fix_valid")), ), + PyMCBinarySensorDescription( + key="gps_stale", + name="GPS stale", + icon="mdi:timer-off-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: bool(_nested(data, "gps", "status", "stale")), + ), + PyMCBinarySensorDescription( + key="gps_location_update_enabled", + name="GPS location updates enabled", + icon="mdi:map-marker-plus-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: bool(_nested(data, "gps", "location_update", "enabled")), + ), ) diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index 8906003..f4dc634 100644 --- a/custom_components/pymc_repeater/manifest.json +++ b/custom_components/pymc_repeater/manifest.json @@ -1,12 +1,14 @@ { "domain": "pymc_repeater", "name": "pyMC Repeater", - "codeowners": ["@pyMC-dev"], + "codeowners": [ + "@pyMC-dev" + ], "config_flow": true, "documentation": "https://github.com/pyMC-dev/pyMC-HA-Integration", "integration_type": "device", "iot_class": "local_polling", "issue_tracker": "https://github.com/pyMC-dev/pyMC-HA-Integration/issues", "requirements": [], - "version": "1.0.0" + "version": "1.0.1" } diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index 75f1c49..62cd61b 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import Any from homeassistant.components.sensor import ( @@ -41,6 +42,15 @@ def _nested(data: dict[str, Any], *keys: str) -> Any: return value +def _parse_datetime(value: str | None) -> datetime | None: + if not value or not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + def _packet_drop_rate(data: dict[str, Any]) -> float | None: total = _nested(data, "packet_stats", "total_packets") dropped = _nested(data, "packet_stats", "dropped_packets") @@ -183,11 +193,14 @@ class PyMCSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: _nested(data, "gps", "status", "state"), attrs_fn=lambda data: { + "enabled": _nested(data, "gps", "enabled"), + "running": _nested(data, "gps", "running"), "fix_valid": _nested(data, "gps", "status", "fix_valid"), "stale": _nested(data, "gps", "status", "stale"), "age_seconds": _nested(data, "gps", "status", "age_seconds"), "last_update": _nested(data, "gps", "status", "last_update"), "last_error": _nested(data, "gps", "status", "last_error"), + "source": _nested(data, "gps", "source"), }, ), PyMCSensorDescription( @@ -228,6 +241,15 @@ class PyMCSensorDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: _nested(data, "gps", "position", "altitude_m"), ), + PyMCSensorDescription( + key="gps_geoid_separation", + name="GPS geoid separation", + icon="mdi:image-filter-center-focus", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="m", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "gps", "position", "geoid_separation_m"), + ), PyMCSensorDescription( key="gps_speed", name="GPS speed", @@ -244,6 +266,31 @@ class PyMCSensorDescription(SensorEntityDescription): ), }, ), + PyMCSensorDescription( + key="gps_course", + name="GPS course", + icon="mdi:compass-outline", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="°", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "gps", "motion", "course_degrees"), + attrs_fn=lambda data: { + "magnetic_variation_degrees": _nested( + data, "gps", "motion", "magnetic_variation_degrees" + ), + }, + ), + PyMCSensorDescription( + key="gps_magnetic_variation", + name="GPS magnetic variation", + icon="mdi:compass-rose", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="°", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested( + data, "gps", "motion", "magnetic_variation_degrees" + ), + ), PyMCSensorDescription( key="gps_hdop", name="GPS HDOP", @@ -256,6 +303,81 @@ class PyMCSensorDescription(SensorEntityDescription): "vdop": _nested(data, "gps", "accuracy", "vdop"), }, ), + PyMCSensorDescription( + key="gps_pdop", + name="GPS PDOP", + icon="mdi:map-marker-distance", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "gps", "accuracy", "pdop"), + ), + PyMCSensorDescription( + key="gps_vdop", + name="GPS VDOP", + icon="mdi:axis-z-arrow", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "gps", "accuracy", "vdop"), + ), + PyMCSensorDescription( + key="gps_datetime_utc", + name="GPS UTC time", + icon="mdi:clock-time-eight-outline", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: _parse_datetime(_nested(data, "gps", "time", "datetime_utc")), + attrs_fn=lambda data: { + "utc_time": _nested(data, "gps", "time", "utc_time"), + "date": _nested(data, "gps", "time", "date"), + }, + ), + PyMCSensorDescription( + key="gps_location_update_state", + name="GPS location update state", + icon="mdi:map-marker-path", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: _nested(data, "gps", "location_update", "state"), + attrs_fn=lambda data: { + "enabled": _nested(data, "gps", "location_update", "enabled"), + "last_attempt": _nested(data, "gps", "location_update", "last_attempt"), + "last_success": _nested(data, "gps", "location_update", "last_success"), + "last_error": _nested(data, "gps", "location_update", "last_error"), + "last_latitude": _nested(data, "gps", "location_update", "last_latitude"), + "last_longitude": _nested(data, "gps", "location_update", "last_longitude"), + "interval_seconds": _nested(data, "gps", "location_update", "interval_seconds"), + }, + ), + PyMCSensorDescription( + key="gps_checksum_valid_count", + name="GPS valid checksums", + icon="mdi:check-decagram-outline", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "gps", "nmea", "valid_checksum_count"), + attrs_fn=lambda data: { + "invalid_checksum_count": _nested( + data, "gps", "nmea", "invalid_checksum_count" + ), + "missing_checksum_count": _nested( + data, "gps", "nmea", "missing_checksum_count" + ), + }, + ), + PyMCSensorDescription( + key="gps_last_sentence_type", + name="GPS last sentence type", + icon="mdi:message-text-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: _nested(data, "gps", "nmea", "last_sentence_type"), + attrs_fn=lambda data: { + "last_talker": _nested(data, "gps", "nmea", "last_talker"), + "last_sentence": _nested(data, "gps", "nmea", "last_sentence"), + "seen_sentence_types": _nested(data, "gps", "nmea", "seen_sentence_types"), + "sentence_counters": _nested(data, "gps", "nmea", "sentence_counters"), + "recent_sentences": _nested(data, "gps", "nmea", "recent_sentences"), + "raw_attributes": _nested(data, "gps", "raw_attributes"), + }, + ), PyMCSensorDescription( key="gps_satellites_used", name="GPS satellites used", From 40c4c71dfbdf38956c683e1ade48bb61415dcb1d Mon Sep 17 00:00:00 2001 From: Yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 5 May 2026 20:18:26 -0400 Subject: [PATCH 2/3] Use GPS stream for faster GPS updates --- custom_components/pymc_repeater/__init__.py | 3 + custom_components/pymc_repeater/api.py | 56 ++++++++++++- .../pymc_repeater/coordinator.py | 81 +++++++++++++++++++ custom_components/pymc_repeater/manifest.json | 2 +- 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index c1f6c1d..d290678 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -77,6 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = PyMCRepeaterDataUpdateCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() + await coordinator.async_start_runtime() repeater_name = get_repeater_name_from_stats(coordinator.data.get("stats", {})) if repeater_name and repeater_name != entry.title: @@ -97,6 +98,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: entry_data = hass.data[DOMAIN].pop(entry.entry_id, None) + if entry_data: + await entry_data["coordinator"].async_stop_runtime() if entry_data and (unsub := entry_data.get("unsub_options_listener")): unsub() return unload_ok diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index da0d6bc..392450c 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio +import json import socket from dataclasses import dataclass from typing import Any from urllib.parse import urlparse -from aiohttp import ClientError, ClientSession +from aiohttp import ClientError, ClientResponse, ClientSession from yarl import URL from .const import CLIENT_ID_PREFIX, DEFAULT_PACKET_WINDOW_HOURS @@ -253,6 +254,10 @@ async def async_get_gps(self) -> dict[str, Any]: """Return local GPS receiver diagnostics.""" return await self._async_request_wrapped("GET", "/api/gps") + async def async_open_gps_stream(self) -> ClientResponse: + """Open the GPS SSE stream.""" + return await self._async_open_stream("GET", "/api/gps_stream") + async def async_get_logs(self) -> dict[str, Any]: """Return buffered repeater logs.""" payload = await self._async_request_json("GET", "/api/logs", auth="api_token") @@ -753,6 +758,41 @@ async def _async_request_wrapped( raise PyMCRepeaterApiError(payload.get("error", f"Request failed for {path}")) return payload.get("data", payload) + async def _async_open_stream( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + ) -> ClientResponse: + headers = {"Accept": "text/event-stream"} + if not self.api_token: + raise PyMCRepeaterAuthenticationError("API token is not configured") + headers["X-API-Key"] = self.api_token + url = f"{self.base_url}{path}" + + try: + response = await self._session.request( + method, + url, + params=params, + headers=headers, + timeout=None, + ) + except ClientError as err: + raise PyMCRepeaterCannotConnect( + f"Cannot connect to {self.host}:{self.port}" + ) from err + + if response.status in (401, 403): + response.release() + raise PyMCRepeaterAuthenticationError(f"Authentication failed for {path}") + if response.status >= 400: + detail = await response.text() + response.release() + raise PyMCRepeaterApiError(f"HTTP {response.status} from {path}: {detail[:200]}") + return response + async def _async_request_json( self, method: str, @@ -815,6 +855,20 @@ async def _async_request_json( return payload + @staticmethod + def decode_sse_payload(line: bytes) -> dict[str, Any] | None: + """Decode one SSE data line from the GPS stream.""" + if not line.startswith(b"data:"): + return None + payload = line[5:].strip() + if not payload: + return None + try: + parsed = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return parsed if isinstance(parsed, dict) else None + def _build_client_id(self) -> str: """Create a stable-enough client identifier for HA bootstrap.""" hostname = socket.gethostname().lower().replace(" ", "-") diff --git a/custom_components/pymc_repeater/coordinator.py b/custom_components/pymc_repeater/coordinator.py index 0afa243..432f792 100644 --- a/custom_components/pymc_repeater/coordinator.py +++ b/custom_components/pymc_repeater/coordinator.py @@ -2,6 +2,9 @@ from __future__ import annotations +import asyncio +import contextlib +import json import logging from homeassistant.config_entries import ConfigEntry @@ -17,6 +20,7 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) +GPS_STREAM_RETRY_SECONDS = 15 class PyMCRepeaterDataUpdateCoordinator(DataUpdateCoordinator[dict]): @@ -36,6 +40,20 @@ def __init__( ) self.config_entry = entry self.api = api + self._gps_stream_task: asyncio.Task | None = None + + async def async_start_runtime(self) -> None: + """Start background runtime tasks.""" + if self._gps_stream_task is None: + self._gps_stream_task = self.hass.async_create_task(self._async_gps_stream_loop()) + + async def async_stop_runtime(self) -> None: + """Stop background runtime tasks.""" + if self._gps_stream_task is not None: + self._gps_stream_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._gps_stream_task + self._gps_stream_task = None async def _async_update_data(self) -> dict: try: @@ -44,3 +62,66 @@ async def _async_update_data(self) -> dict: raise ConfigEntryAuthFailed(str(err)) from err except PyMCRepeaterCannotConnect as err: raise UpdateFailed(str(err)) from err + + async def _async_gps_stream_loop(self) -> None: + """Listen for GPS stream snapshots and update GPS entities faster than polling.""" + while True: + try: + response = await self.api.async_open_gps_stream() + try: + _LOGGER.debug( + "Connected GPS stream for %s", self.config_entry.entry_id + ) + async for raw_line in response.content: + event = self.api.decode_sse_payload(raw_line) + if not event: + continue + if event.get("type") != "snapshot": + continue + snapshot = event.get("data") + if not isinstance(snapshot, dict): + continue + self._async_apply_gps_snapshot(snapshot) + finally: + response.close() + except asyncio.CancelledError: + raise + except PyMCRepeaterAuthenticationError as err: + _LOGGER.warning( + "GPS stream auth failed for %s: %s", + self.config_entry.entry_id, + err, + ) + return + except (PyMCRepeaterCannotConnect, UpdateFailed) as err: + _LOGGER.debug( + "GPS stream temporarily unavailable for %s: %s", + self.config_entry.entry_id, + err, + ) + except Exception as err: + _LOGGER.debug( + "GPS stream error for %s: %s", + self.config_entry.entry_id, + err, + ) + + await asyncio.sleep(GPS_STREAM_RETRY_SECONDS) + + def _async_apply_gps_snapshot(self, snapshot: dict) -> None: + """Apply a GPS snapshot from the SSE stream.""" + current = dict(self.data or {}) + old_snapshot = current.get("gps") + if self._same_snapshot(old_snapshot, snapshot): + return + current["gps"] = snapshot + self.async_set_updated_data(current) + + @staticmethod + def _same_snapshot(old_snapshot: object, new_snapshot: dict) -> bool: + """Compare two GPS snapshots.""" + if not isinstance(old_snapshot, dict): + return False + return json.dumps(old_snapshot, sort_keys=True, default=str) == json.dumps( + new_snapshot, sort_keys=True, default=str + ) diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index f4dc634..cf09210 100644 --- a/custom_components/pymc_repeater/manifest.json +++ b/custom_components/pymc_repeater/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pyMC-dev/pyMC-HA-Integration/issues", "requirements": [], - "version": "1.0.1" + "version": "1.0.2" } From c18f2e39a3673f487d68cd62aabd278dd1ffbd02 Mon Sep 17 00:00:00 2001 From: Yellowcooln Date: Tue, 5 May 2026 21:04:59 -0400 Subject: [PATCH 3/3] Bump version from 1.0.2 to 1.1.0 --- custom_components/pymc_repeater/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index cf09210..b54a7ba 100644 --- a/custom_components/pymc_repeater/manifest.json +++ b/custom_components/pymc_repeater/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pyMC-dev/pyMC-HA-Integration/issues", "requirements": [], - "version": "1.0.2" + "version": "1.1.0" }