From 94f94027740e712379597e6f1a5b586ec597b6e8 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:04:43 -0400 Subject: [PATCH 01/12] feat: align integration with current repeater dev API Add advert modes and schedules, neighbor scope and MQTT publication actions, multi-radio diagnostics, current external sensor metadata, stronger diagnostics redaction, translations, dashboard coverage, and contract tests. --- CHANGELOG.md | 12 + README.md | 17 +- custom_components/pymc_repeater/__init__.py | 60 +++++ custom_components/pymc_repeater/api.py | 96 ++++++- custom_components/pymc_repeater/button.py | 18 +- .../pymc_repeater/diagnostics.py | 30 ++- custom_components/pymc_repeater/number.py | 51 +++- custom_components/pymc_repeater/sensor.py | 126 ++++++++- custom_components/pymc_repeater/services.yaml | 51 +++- .../pymc_repeater/translations/en.json | 25 ++ dashboards/openhop_repeater_dashboard.yaml | 35 ++- tests/test_dev_api_alignment.py | 243 +++++++++++++++++- 12 files changed, 733 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b76e3..060d3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- aligned advert controls with current Repeater dev support for explicit flood/direct sends and independent flood/direct schedules +- added MQTT neighbors publication status, a manual publication control, stored neighbor-scope retrieval, and one-neighbor scope queries +- allowed the neighbor-scope query action to wait through the Repeater's 45-second response window instead of hitting the integration's normal 10-second API timeout +- documented multi-radio `radio_id`, MQTT custom base topics, and neighbor-publisher payload support on the existing raw configuration actions +- exposed current Repeater dev sensor-manager payloads generically, including BME280 pressure and normalized openHop Modem voltage/current/power readings with Home Assistant units, numeric-string coercion, and an additive battery-percentage alias, without changing compatibility identifiers +- added English translation keys for the new advert controls, MQTT-neighbor status, and radio-stack diagnostics +- added radio-stack mode and configured-radio-count diagnostics for the Repeater's current multi-radio API payload +- aligned the flood advert interval control with the Repeater's disjoint `0` or `3-168` hour validation instead of allowing invalid one- and two-hour writes +- stopped retaining raw transport-key and private identity configuration in coordinator polling and redacted runtime location, identity, network, and credential fields from downloadable diagnostics + ## 1.1.6 - changed the integration-wide API refresh interval to a configurable option, defaulting to 15 seconds diff --git a/README.md b/README.md index 71ae836..b86c47b 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,13 @@ The integration uses coordinated local polling instead of making a separate API - UI-based setup and options flows - Configurable integration-wide polling interval -- Repeater, radio, packet, routing, and signal-quality telemetry -- MQTT broker and handler status +- Repeater, single/multi-radio stack, packet, routing, and signal-quality telemetry +- MQTT broker, handler, and neighbor-publication status - Hardware, process, network, database, and metrics diagnostics - GPS position, fix, satellite, time-sync, and location-update data - External sensor-manager entities, including supported modem and UPS readings -- Neighbor-link counts and on-demand neighbor history -- Default-region, duty-cycle, advert-rate, and Repeater-mode controls +- Neighbor-link counts, neighbor-scope queries, and on-demand neighbor history +- Default-region, duty-cycle, advert-rate, advert-schedule, and Repeater-mode controls - Update status, update-channel selection, and update actions - CAD calibration controls and manual CAD checks - Native Home Assistant diagnostics and an extensive example dashboard @@ -111,7 +111,7 @@ A comprehensive native Lovelace view is included at: [`dashboards/openhop_repeater_dashboard.yaml`](dashboards/openhop_repeater_dashboard.yaml) -The template covers radio health, packet flow, LBT diagnostics, routing, neighbor links, controls, advert tuning, MQTT, companions, GPS, external modem readings, updates, and database metrics. +The template covers radio health and stack diagnostics, packet flow, LBT diagnostics, routing, neighbor links, controls, advert tuning, MQTT, companions, GPS, external modem power readings, updates, and database metrics. To use it: @@ -129,15 +129,18 @@ The dashboard uses only built-in Home Assistant cards. The integration exposes Home Assistant actions for supported Repeater operations, including: -- Sending adverts and restarting the Repeater service +- Sending flood or direct adverts and restarting the Repeater service - Checking for and installing updates -- Reading broker presets and neighbor-link history +- Reading broker presets, neighbor-link history, and stored neighbor scopes +- Querying one zero-hop neighbor's scopes or scheduling an MQTT neighbors publication cycle - Running manual CAD checks and CAD calibration - Saving CAD settings - Reading advert, companion, and contact diagnostics Open **Developer tools → Actions** and search for `openHop Repeater` or `pymc_repeater` to see the actions and their current fields. +The raw radio-config action accepts the Repeater dev `radio_id` field for multi-radio targeting and `direct_advert_interval_hours` for the additional advert schedule. The raw MQTT-config action accepts custom `base_topic` values and neighbor-publisher settings supported by current Repeater dev builds. + ## Authentication and persistent storage The Home Assistant integration stores an API token, while the Repeater stores the matching token hash in its SQLite database. Both the Repeater JWT secret and SQLite database must persist across Repeater restarts. diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index 6294be2..2fa91ed 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -28,6 +28,10 @@ ] SERVICE_PING_NEIGHBOR = "ping_neighbor" +SERVICE_SEND_ADVERT = "send_advert" +SERVICE_PUBLISH_NEIGHBORS = "publish_neighbors" +SERVICE_GET_NEIGHBOR_SCOPES = "get_neighbor_scopes" +SERVICE_QUERY_NEIGHBOR_SCOPES = "query_neighbor_scopes" SERVICE_ROOM_POST_MESSAGE = "room_post_message" SERVICE_ROOM_MESSAGES_CLEAR = "room_messages_clear" SERVICE_CAD_CALIBRATION_START = "cad_calibration_start" @@ -195,6 +199,62 @@ async def _with_api_response( ), ) + hass.services.async_register( + DOMAIN, + SERVICE_SEND_ADVERT, + lambda call: _with_api( + call, + lambda api, _: api.async_send_advert(call.data.get("mode", "flood")), + refresh=False, + ), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("mode", default="flood"): vol.In(["flood", "direct"]), + } + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_PUBLISH_NEIGHBORS, + lambda call: _with_api( + call, + lambda api, _: api.async_publish_neighbors(), + refresh=True, + ), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str}), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_NEIGHBOR_SCOPES, + lambda call: _with_api_response( + call, + lambda api, _: api.async_get_neighbor_scopes(), + always_return=True, + ), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str}), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_QUERY_NEIGHBOR_SCOPES, + lambda call: _with_api_response( + call, + lambda api, _: api.async_query_neighbor_scopes(call.data["pubkey"]), + always_return=True, + ), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Required("pubkey"): vol.Match(r"(?i)^[0-9a-f]{64}$"), + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_ROOM_POST_MESSAGE, diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index 19f047c..3fdb472 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -15,6 +15,30 @@ from .const import CLIENT_ID_PREFIX, DEFAULT_PACKET_WINDOW_HOURS REQUEST_TIMEOUT = 10 +NEIGHBOR_SCOPE_QUERY_TIMEOUT = 50 +SENSITIVE_RESPONSE_KEYS = { + "identity_key", + "private_key", + "admin_password", + "guest_password", + "password", + "token", + "transport_key", + "jwt_secret", +} + + +def _drop_sensitive_fields(value: Any) -> Any: + """Recursively remove private configuration fields from API payloads.""" + if isinstance(value, dict): + return { + key: _drop_sensitive_fields(item) + for key, item in value.items() + if key not in SENSITIVE_RESPONSE_KEYS + } + if isinstance(value, list): + return [_drop_sensitive_fields(item) for item in value] + return value class PyMCRepeaterError(Exception): @@ -283,16 +307,30 @@ async def async_get_acl_info(self) -> dict[str, Any]: return await self._async_request_wrapped("GET", "/api/acl_info") async def async_get_identities(self) -> dict[str, Any]: - """Return identity stats.""" - return await self._async_request_wrapped("GET", "/api/identities") + """Return identity stats without private configuration fields.""" + payload = await self._async_request_wrapped("GET", "/api/identities") + if not isinstance(payload, dict): + raise PyMCRepeaterApiError("Invalid identities response") + return _drop_sensitive_fields(payload) async def async_get_db_stats(self) -> dict[str, Any]: """Return database stats.""" return await self._async_request_wrapped("GET", "/api/db_stats") async def async_get_transport_keys(self) -> list[dict[str, Any]]: - """Return transport keys.""" - return await self._async_request_wrapped("GET", "/api/transport_keys") + """Return transport-key metadata without retaining key material.""" + items = await self._async_request_wrapped("GET", "/api/transport_keys") + if not isinstance(items, list): + raise PyMCRepeaterApiError("Invalid transport keys response") + + safe_items: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + safe_item = dict(item) + safe_item.pop("transport_key", None) + safe_items.append(safe_item) + return safe_items async def async_get_default_region(self) -> dict[str, Any]: """Return mesh default region configuration.""" @@ -536,9 +574,42 @@ async def async_delete_room_message( params["room_hash"] = room_hash return await self._async_request_wrapped("DELETE", "/api/room_message", params=params) - async def async_send_advert(self) -> Any: - """Trigger a repeater advert send.""" - return await self._async_request_wrapped("POST", "/api/send_advert", json_body={}) + async def async_get_neighbor_scopes(self) -> dict[str, Any]: + """Return stored neighbor scopes plus this Repeater's served scopes.""" + payload = await self._async_request_json( + "GET", "/api/neighbor_scopes", auth="api_token" + ) + if payload.get("success") is False: + raise PyMCRepeaterApiError( + payload.get("error", "Failed to fetch neighbor scopes") + ) + scopes = payload.get("data") or {} + return { + "scopes": scopes, + "count": payload.get("count", len(scopes) if isinstance(scopes, dict) else 0), + "served": payload.get("served"), + } + + async def async_query_neighbor_scopes(self, pubkey: str) -> dict[str, Any]: + """Query one zero-hop neighbor for its served region scopes.""" + return await self._async_request_wrapped( + "POST", + "/api/query_neighbor_scopes", + json_body={"pubkey": pubkey}, + timeout_seconds=NEIGHBOR_SCOPE_QUERY_TIMEOUT, + ) + + async def async_publish_neighbors(self) -> Any: + """Schedule a neighbor discovery and MQTT publication cycle.""" + return await self._async_request_wrapped( + "POST", "/api/publish_neighbors", json_body={} + ) + + async def async_send_advert(self, mode: str = "flood") -> Any: + """Trigger a flood or direct repeater advert send.""" + return await self._async_request_wrapped( + "POST", "/api/send_advert", json_body={"mode": mode} + ) async def async_restart_service(self) -> dict[str, Any]: """Restart the repeater service.""" @@ -925,9 +996,15 @@ async def _async_request_wrapped( *, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, + timeout_seconds: float = REQUEST_TIMEOUT, ) -> Any: payload = await self._async_request_json( - method, path, params=params, json_body=json_body, auth="api_token" + method, + path, + params=params, + json_body=json_body, + auth="api_token", + timeout_seconds=timeout_seconds, ) if payload.get("success") is False: raise PyMCRepeaterApiError(payload.get("error", f"Request failed for {path}")) @@ -977,6 +1054,7 @@ async def _async_request_json( json_body: dict[str, Any] | None = None, auth: str = "api_token", bearer_token: str | None = None, + timeout_seconds: float = REQUEST_TIMEOUT, ) -> dict[str, Any]: headers = {"Accept": "application/json"} @@ -992,7 +1070,7 @@ async def _async_request_json( url = f"{self.base_url}{path}" try: - async with asyncio.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(timeout_seconds): async with self._session.request( method, url, diff --git a/custom_components/pymc_repeater/button.py b/custom_components/pymc_repeater/button.py index 64e80a4..6d72501 100644 --- a/custom_components/pymc_repeater/button.py +++ b/custom_components/pymc_repeater/button.py @@ -29,7 +29,23 @@ class PyMCButtonDescription(ButtonEntityDescription): name="Send advert", icon="mdi:radio-tower", entity_category=EntityCategory.CONFIG, - press_fn=lambda api: api.async_send_advert(), + press_fn=lambda api: api.async_send_advert("flood"), + ), + PyMCButtonDescription( + key="send_direct_advert", + translation_key="send_direct_advert", + name="Send direct advert", + icon="mdi:radio-tower", + entity_category=EntityCategory.CONFIG, + press_fn=lambda api: api.async_send_advert("direct"), + ), + PyMCButtonDescription( + key="publish_neighbors", + translation_key="publish_neighbors", + name="Publish MQTT neighbors", + icon="mdi:access-point-network", + entity_category=EntityCategory.CONFIG, + press_fn=lambda api: api.async_publish_neighbors(), ), PyMCButtonDescription( key="restart_service", diff --git a/custom_components/pymc_repeater/diagnostics.py b/custom_components/pymc_repeater/diagnostics.py index 17676ce..a3be6b2 100644 --- a/custom_components/pymc_repeater/diagnostics.py +++ b/custom_components/pymc_repeater/diagnostics.py @@ -8,7 +8,33 @@ from .const import CONF_API_TOKEN, DOMAIN -TO_REDACT = {CONF_API_TOKEN} +TO_REDACT = { + CONF_API_TOKEN, + "token", + "password", + "admin_password", + "guest_password", + "jwt_secret", + "identity_key", + "private_key", + "transport_key", + "latitude", + "longitude", + "lat", + "lon", + "gps_position", + "manual_position", + "network_current_ip", + "ip_address", + "client_address", + "host", + "url", + "public_key", + "pubkey", + "identity_hash", + "companion_hash", + "room_hash", +} async def async_get_config_entry_diagnostics( @@ -18,6 +44,6 @@ async def async_get_config_entry_diagnostics( coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] return { "config_entry": async_redact_data(dict(entry.data), TO_REDACT), - "data": coordinator.data, + "data": async_redact_data(coordinator.data, TO_REDACT), } diff --git a/custom_components/pymc_repeater/number.py b/custom_components/pymc_repeater/number.py index 2b0bbe3..b0c372a 100644 --- a/custom_components/pymc_repeater/number.py +++ b/custom_components/pymc_repeater/number.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,6 +17,18 @@ from .sensor import PyMCBaseEntity, _nested +async def _async_set_flood_advert_interval(api: Any, value: float) -> object: + """Set the flood advert interval using the Repeater's disjoint bounds.""" + hours = int(value) + if hours != 0 and not 3 <= hours <= 168: + raise HomeAssistantError( + "Flood advert interval must be 0 (off) or 3-168 hours" + ) + return await api.async_update_radio_config( + {"flood_advert_interval_hours": hours} + ) + + @dataclass(frozen=True, kw_only=True) class PyMCNumberDescription: """Description for an openHop number.""" @@ -30,9 +43,42 @@ class PyMCNumberDescription: mode: NumberMode value_fn: Callable[[dict[str, Any]], float | int | None] set_fn: Callable[[object, float], Awaitable[object]] + translation_key: str | None = None NUMBERS: tuple[PyMCNumberDescription, ...] = ( + PyMCNumberDescription( + key="flood_advert_interval_hours", + translation_key="flood_advert_interval_hours", + name="Flood advert interval", + icon="mdi:timer-sync-outline", + min_value=0, + max_value=168, + step=1, + unit=UnitOfTime.HOURS, + mode=NumberMode.BOX, + value_fn=lambda data: _nested( + data, "stats", "config", "repeater", "send_advert_interval_hours" + ), + set_fn=_async_set_flood_advert_interval, + ), + PyMCNumberDescription( + key="direct_advert_interval_hours", + translation_key="direct_advert_interval_hours", + name="Direct advert interval", + icon="mdi:timer-outline", + min_value=0, + max_value=168, + step=1, + unit=UnitOfTime.HOURS, + mode=NumberMode.BOX, + value_fn=lambda data: _nested( + data, "stats", "config", "repeater", "direct_advert_interval_hours" + ), + set_fn=lambda api, value: api.async_update_radio_config( + {"direct_advert_interval_hours": int(value)} + ), + ), PyMCNumberDescription( key="max_airtime_percent", name="Max airtime percent", @@ -298,7 +344,10 @@ def __init__(self, entry: ConfigEntry, coordinator, api, description: PyMCNumber super().__init__(entry, coordinator) self._api = api self.description = description - self._attr_name = description.name + if description.translation_key: + self._attr_translation_key = description.translation_key + else: + self._attr_name = description.name self._attr_icon = description.icon self._attr_native_min_value = description.min_value self._attr_native_max_value = description.max_value diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index dbc1c49..53861e3 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -66,6 +66,21 @@ def _mqtt_connected_count(data: dict[str, Any]) -> int: return sum(1 for broker in brokers if _nested(broker, "status", "connected")) +def _mqtt_neighbors_status(data: dict[str, Any]) -> dict[str, Any]: + status = _nested(data, "mqtt_status", "neighbors") or {} + return status if isinstance(status, dict) else {} + + +def _radio_stack(data: dict[str, Any]) -> dict[str, Any]: + stack = _nested(data, "stats", "radio_stack") or {} + return stack if isinstance(stack, dict) else {} + + +def _configured_radio_ids(data: dict[str, Any]) -> list[str]: + radio_ids = _radio_stack(data).get("radio_ids") or [] + return [str(radio_id) for radio_id in radio_ids] if isinstance(radio_ids, list) else [] + + def _update_channel_options(data: dict[str, Any]) -> list[str]: current = _nested(data, "update_status", "channel") or _nested( data, "update_channels", "current_channel" @@ -169,23 +184,49 @@ def _external_sensor_readings(data: dict[str, Any]) -> list[dict[str, Any]]: def _external_sensor_identity(reading: dict[str, Any]) -> str: name = str(reading.get("name") or "sensor") sensor_type = str(reading.get("type") or "sensor") + # Preserve existing Home Assistant unique IDs across the Repeater plug-in rename. + if sensor_type == "openhop_modem": + sensor_type = "pymc_modem" return slugify(f"{name}_{sensor_type}") or "sensor" +def _external_sensor_payload(reading: dict[str, Any]) -> dict[str, Any]: + """Return a normalized copy while preserving original sensor fields.""" + raw_payload = reading.get("data") or {} + if not isinstance(raw_payload, dict): + return {} + payload = dict(raw_payload) + if "battery_percent" not in payload and "battery_percentage" in payload: + payload["battery_percent"] = payload["battery_percentage"] + return payload + + def _external_sensor_data_items(data: dict[str, Any]) -> list[tuple[dict[str, Any], str, Any]]: items: list[tuple[dict[str, Any], str, Any]] = [] for reading in _external_sensor_readings(data): if not isinstance(reading, dict): continue - payload = reading.get("data") or {} - if not isinstance(payload, dict): - continue + payload = _external_sensor_payload(reading) for key, value in payload.items(): if isinstance(value, (str, int, float)) and not isinstance(value, bool): items.append((reading, str(key), value)) return items +def _normalize_external_sensor_value(value: Any, *, has_unit: bool) -> Any: + """Coerce measurement strings and reject invalid measurement states.""" + if not has_unit: + return value + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + def _external_sensor_ok_count(data: dict[str, Any]) -> int: return sum(1 for reading in _external_sensor_readings(data) if reading.get("ok")) @@ -256,6 +297,29 @@ class PyMCSensorDescription(SensorEntityDescription): "node_name": get_repeater_name_from_stats(_nested(data, "stats") or {}), }, ), + PyMCSensorDescription( + key="radio_stack_mode", + translation_key="radio_stack_mode", + name="Radio stack mode", + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: _radio_stack(data).get("mode"), + attrs_fn=lambda data: { + "radio_ids": _configured_radio_ids(data), + "default_radio": _radio_stack(data).get("default_radio"), + "tx_mode": _radio_stack(data).get("tx_mode"), + "fabric": _radio_stack(data).get("fabric"), + }, + ), + PyMCSensorDescription( + key="configured_radio_count", + translation_key="configured_radio_count", + name="Configured radio count", + icon="mdi:counter", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: len(_configured_radio_ids(data)), + ), PyMCSensorDescription( key="update_latest_version", name="Update latest version", @@ -678,6 +742,20 @@ class PyMCSensorDescription(SensorEntityDescription): "brokers": _nested(data, "mqtt_status", "brokers"), }, ), + PyMCSensorDescription( + key="mqtt_neighbors_phase", + translation_key="mqtt_neighbors_phase", + name="MQTT neighbors phase", + icon="mdi:access-point-network", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: _mqtt_neighbors_status(data).get("phase"), + attrs_fn=lambda data: { + "seconds_until_next": _mqtt_neighbors_status(data).get("secs_until_next"), + "last_result": _mqtt_neighbors_status(data).get("last_result"), + "last_publish_at": _mqtt_neighbors_status(data).get("last_publish_at"), + "interval_hours": _mqtt_neighbors_status(data).get("interval_hours"), + }, + ), PyMCSensorDescription( key="running_companion_bridges", name="Running companion bridges", @@ -1445,7 +1523,9 @@ class PyMCExternalSensorMetricSensor(PyMCBaseEntity, SensorEntity): _UNIT_HINTS = { "battery_percent": PERCENTAGE, + "battery_percentage": PERCENTAGE, "humidity_pct": PERCENTAGE, + "battery_voltage_v": "V", "bus_voltage_v": "V", "shunt_voltage_v": "V", "battery_voltage_mv": "mV", @@ -1460,14 +1540,35 @@ class PyMCExternalSensorMetricSensor(PyMCBaseEntity, SensorEntity): "time_to_empty_min": UnitOfTime.MINUTES, "time_to_full_min": UnitOfTime.MINUTES, "temperature_c": "°C", + "die_temperature_c": "°C", "temperature_f": "°F", + "pressure_hpa": "hPa", + "last_rssi_dbm": "dBm", + "last_snr_db": "dB", + "noise_floor_dbm": "dBm", + "frequency_hz": "Hz", + "frequency_mhz": "MHz", + "bandwidth_hz": "Hz", + "bandwidth_khz": "kHz", + "tx_power_dbm": "dBm", + "altitude_m": "m", + "speed_kmh": "km/h", + "course_degrees": "°", + "solar_charge_rate_percent_per_hour": "%/h", } _ICON_HINTS = { "battery_percent": "mdi:battery", "charge_state": "mdi:battery-sync-outline", "temperature_c": "mdi:thermometer", + "die_temperature_c": "mdi:thermometer", "temperature_f": "mdi:thermometer", "humidity_pct": "mdi:water-percent", + "pressure_hpa": "mdi:gauge", + "current_ma": "mdi:current-dc", + "power_mw": "mdi:flash", + "last_rssi_dbm": "mdi:signal", + "last_snr_db": "mdi:signal", + "noise_floor_dbm": "mdi:waveform", } def __init__( @@ -1505,14 +1606,25 @@ def _get_reading(self) -> dict[str, Any] | None: @property def available(self) -> bool: reading = self._get_reading() - payload = reading.get("data") if isinstance(reading, dict) else None - return super().available and isinstance(payload, dict) and self._field in payload + payload = _external_sensor_payload(reading) if isinstance(reading, dict) else {} + if not super().available or self._field not in payload: + return False + return ( + _normalize_external_sensor_value( + payload.get(self._field), + has_unit=self._attr_native_unit_of_measurement is not None, + ) + is not None + ) @property def native_value(self) -> Any: reading = self._get_reading() or {} - payload = reading.get("data") or {} - return payload.get(self._field) if isinstance(payload, dict) else None + payload = _external_sensor_payload(reading) + return _normalize_external_sensor_value( + payload.get(self._field), + has_unit=self._attr_native_unit_of_measurement is not None, + ) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/custom_components/pymc_repeater/services.yaml b/custom_components/pymc_repeater/services.yaml index 022264d..8b66696 100644 --- a/custom_components/pymc_repeater/services.yaml +++ b/custom_components/pymc_repeater/services.yaml @@ -18,6 +18,53 @@ ping_neighbor: max: 60 mode: box +send_advert: + name: Send advert + description: Send a Repeater advert using the flood or direct mode supported by current Repeater dev builds. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + mode: + default: flood + selector: + select: + options: + - flood + - direct + +publish_neighbors: + name: Publish MQTT neighbors + description: Schedule a neighbor discovery, scope-query, and MQTT publication cycle. The Repeater must have neighbor publishing enabled and an opted-in broker. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + +get_neighbor_scopes: + name: Get neighbor scopes + description: Return the last stored region-scope result for each queried neighbor and the scopes served by this Repeater. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + +query_neighbor_scopes: + name: Query neighbor scopes + description: Query one zero-hop neighbor for its served region scopes. This can take up to 45 seconds and is rate-limited by the remote node. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + pubkey: + required: true + selector: + text: + room_post_message: name: Post room message description: Post a message to an openHop room server. @@ -220,7 +267,7 @@ db_purge: update_radio_config: name: Update radio config - description: Send a raw radio config payload to the repeater API. + description: Send a raw radio config payload to the Repeater API. Current dev builds accept radio_id for multi-radio targeting and direct_advert_interval_hours for the additional advert schedule. fields: config_entry_id: selector: @@ -233,7 +280,7 @@ update_radio_config: update_mqtt_config: name: Update MQTT config - description: Send a raw MQTT config payload to the repeater API. + description: Send a raw MQTT config payload to the Repeater API, including custom base_topic values and neighbor-publisher settings supported by current dev builds. fields: config_entry_id: selector: diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index 7aac20e..44c4183 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -47,6 +47,15 @@ "repeater_version": { "name": "Repeater version" }, + "radio_stack_mode": { + "name": "Radio stack mode" + }, + "configured_radio_count": { + "name": "Configured radio count" + }, + "mqtt_neighbors_phase": { + "name": "MQTT neighbors phase" + }, "packets_24h": { "name": "Packets (24h)" }, @@ -99,6 +108,22 @@ "name": "System uptime" } }, + "button": { + "send_direct_advert": { + "name": "Send direct advert" + }, + "publish_neighbors": { + "name": "Publish MQTT neighbors" + } + }, + "number": { + "flood_advert_interval_hours": { + "name": "Flood advert interval" + }, + "direct_advert_interval_hours": { + "name": "Direct advert interval" + } + }, "binary_sensor": { "mqtt_handler_active": { "name": "MQTT handler active" diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml index 5132707..e36ddae 100644 --- a/dashboards/openhop_repeater_dashboard.yaml +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -134,6 +134,12 @@ cards: - entity: sensor.REPEATER_SLUG_total_processes name: Total Processes icon: mdi:application-cog-outline + - entity: sensor.REPEATER_SLUG_radio_stack_mode + name: Radio Stack Mode + icon: mdi:radio-tower + - entity: sensor.REPEATER_SLUG_configured_radio_count + name: Configured Radio Count + icon: mdi:counter - type: divider - entity: sensor.REPEATER_SLUG_external_sensors_loaded name: External Sensors Loaded @@ -271,6 +277,12 @@ cards: - entity: sensor.REPEATER_SLUG_active_neighbor_links name: Active Neighbor Links icon: mdi:access-point-network-off + - entity: sensor.REPEATER_SLUG_mqtt_neighbors_phase + name: MQTT Neighbors Phase + icon: mdi:access-point-network + - entity: button.REPEATER_SLUG_publish_mqtt_neighbors + name: Publish MQTT Neighbors + icon: mdi:cloud-upload-outline - type: entities title: Repeater Controls @@ -293,8 +305,17 @@ cards: name: Max Airtime % icon: mdi:percent - entity: button.REPEATER_SLUG_send_advert - name: Send Advert + name: Send Flood Advert icon: mdi:bullhorn + - entity: button.REPEATER_SLUG_send_direct_advert + name: Send Direct Advert + icon: mdi:bullhorn-outline + - entity: number.REPEATER_SLUG_flood_advert_interval + name: Flood Advert Interval + icon: mdi:timer-sync-outline + - entity: number.REPEATER_SLUG_direct_advert_interval + name: Direct Advert Interval + icon: mdi:timer-outline - entity: button.REPEATER_SLUG_restart_repeater_service name: Restart Repeater Service icon: mdi:restart-alert @@ -539,6 +560,18 @@ cards: - entity: sensor.REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour name: Solar Charge Rate icon: mdi:solar-power + - entity: sensor.REPEATER_SLUG_sensor_modem_bus_voltage_v + name: Bus Voltage + icon: mdi:flash-outline + - entity: sensor.REPEATER_SLUG_sensor_modem_current_ma + name: Current + icon: mdi:current-dc + - entity: sensor.REPEATER_SLUG_sensor_modem_power_mw + name: Power + icon: mdi:flash + - entity: sensor.REPEATER_SLUG_sensor_modem_die_temperature_c + name: Modem Temperature + icon: mdi:thermometer - type: entities title: External Modem GPS diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py index 9f33baf..a11c063 100644 --- a/tests/test_dev_api_alignment.py +++ b/tests/test_dev_api_alignment.py @@ -9,6 +9,7 @@ import ast import json from pathlib import Path +from typing import Any import unittest ROOT = Path(__file__).resolve().parents[1] @@ -50,6 +51,34 @@ def test_neighbor_link_client_methods_and_polling_exist(self) -> None: self.assertIn("/api/neighbor_link_history", source) self.assertIn("'neighbor_links': self.async_get_neighbor_links()", source) + def test_neighbor_scope_and_direct_advert_contracts_exist(self) -> None: + module = _module("api.py") + client = next( + node for node in module.body if isinstance(node, ast.ClassDef) and node.name == "PyMCRepeaterApiClient" + ) + source = ast.unparse(client) + + for expected in ( + "async_get_neighbor_scopes", + "'/api/neighbor_scopes'", + "async_query_neighbor_scopes", + "'/api/query_neighbor_scopes'", + "async_publish_neighbors", + "'/api/publish_neighbors'", + "NEIGHBOR_SCOPE_QUERY_TIMEOUT", + "{'mode': mode}", + ): + self.assertIn(expected, source) + + buttons = (COMPONENT / "button.py").read_text(encoding="utf-8") + numbers = (COMPONENT / "number.py").read_text(encoding="utf-8") + sensor = (COMPONENT / "sensor.py").read_text(encoding="utf-8") + self.assertIn('key="send_direct_advert"', buttons) + self.assertIn('key="publish_neighbors"', buttons) + self.assertIn('key="flood_advert_interval_hours"', numbers) + self.assertIn('key="direct_advert_interval_hours"', numbers) + self.assertIn('key="mqtt_neighbors_phase"', sensor) + def test_cad_client_contract_includes_new_dev_fields(self) -> None: module = _module("api.py") start = ast.unparse( @@ -84,6 +113,10 @@ def test_home_assistant_services_cover_new_routes_and_bounds(self) -> None: "SERVICE_CAD_MANUAL_CHECK", "SERVICE_GET_NEIGHBOR_LINKS", "SERVICE_GET_NEIGHBOR_LINK_HISTORY", + "SERVICE_SEND_ADVERT", + "SERVICE_PUBLISH_NEIGHBORS", + "SERVICE_GET_NEIGHBOR_SCOPES", + "SERVICE_QUERY_NEIGHBOR_SCOPES", ): self.assertIn(service, setup) self.assertIn("vol.In([1, 2, 4, 8, 16])", setup) @@ -97,12 +130,212 @@ def test_new_diagnostics_are_exposed_as_sensors(self) -> None: self.assertIn('key="neighbor_link_count"', source) self.assertIn('key="active_neighbor_link_count"', source) self.assertIn('key="metrics_data_source"', source) + self.assertIn('key="radio_stack_mode"', source) + self.assertIn('key="configured_radio_count"', source) + + def test_current_external_sensor_payloads_have_units(self) -> None: + source = (COMPONENT / "sensor.py").read_text(encoding="utf-8") + + for field, unit in ( + ("battery_voltage_v", "V"), + ("bus_voltage_v", "V"), + ("current_ma", "mA"), + ("power_mw", "mW"), + ("pressure_hpa", "hPa"), + ("die_temperature_c", "°C"), + ("last_rssi_dbm", "dBm"), + ("last_snr_db", "dB"), + ("noise_floor_dbm", "dBm"), + ("frequency_hz", "Hz"), + ("bandwidth_hz", "Hz"), + ("tx_power_dbm", "dBm"), + ("solar_charge_rate_percent_per_hour", "%/h"), + ): + self.assertIn(f'"{field}": "{unit}"', source) + self.assertIn('if sensor_type == "openhop_modem"', source) + self.assertIn('sensor_type = "pymc_modem"', source) + + def test_external_sensor_battery_percentage_alias_is_additive(self) -> None: + module = _module("sensor.py") + helpers = [ + node + for node in module.body + if isinstance(node, ast.FunctionDef) + and node.name == "_external_sensor_payload" + ] + self.assertTrue(helpers, "Missing external sensor payload normalizer") + namespace: dict[str, Any] = {"Any": Any} + exec( + compile(ast.Module(body=[helpers[0]], type_ignores=[]), "", "exec"), + namespace, + ) + normalize = namespace["_external_sensor_payload"] + + payload = normalize({"data": {"battery_percentage": "74"}}) + self.assertEqual(payload["battery_percentage"], "74") + self.assertEqual(payload["battery_percent"], "74") + canonical = normalize( + {"data": {"battery_percentage": 74, "battery_percent": 73}} + ) + self.assertEqual(canonical["battery_percent"], 73) + + def test_external_sensor_measurements_normalize_numeric_strings(self) -> None: + module = _module("sensor.py") + source = (COMPONENT / "sensor.py").read_text(encoding="utf-8") + helpers = [ + node + for node in module.body + if isinstance(node, ast.FunctionDef) + and node.name == "_normalize_external_sensor_value" + ] + self.assertTrue(helpers, "Missing external sensor value normalizer") + helper = helpers[0] + namespace: dict[str, Any] = {"Any": Any} + exec( + compile(ast.Module(body=[helper], type_ignores=[]), "", "exec"), + namespace, + ) + normalize = namespace["_normalize_external_sensor_value"] + + self.assertEqual(normalize("12.5", has_unit=True), 12.5) + self.assertEqual(normalize(0, has_unit=True), 0) + self.assertIsNone(normalize("not-a-number", has_unit=True)) + self.assertEqual(normalize("active", has_unit=False), "active") + self.assertIn("_normalize_external_sensor_value", source) + self.assertIn("has_unit=self._attr_native_unit_of_measurement is not None", source) + + def test_flood_advert_number_enforces_repeater_bounds(self) -> None: + source = (COMPONENT / "number.py").read_text(encoding="utf-8") + + self.assertIn("async def _async_set_flood_advert_interval", source) + self.assertIn("hours != 0 and not 3 <= hours <= 168", source) + self.assertIn("Flood advert interval must be 0 (off) or 3-168 hours", source) + self.assertIn("set_fn=_async_set_flood_advert_interval", source) + + def test_identity_polling_drops_private_configuration(self) -> None: + module = _module("api.py") + method = ast.unparse( + _class_method(module, "PyMCRepeaterApiClient", "async_get_identities") + ) + source = (COMPONENT / "api.py").read_text(encoding="utf-8") + + self.assertIn("_drop_sensitive_fields", method) + for key in ( + "identity_key", + "private_key", + "admin_password", + "guest_password", + "password", + "token", + "transport_key", + "jwt_secret", + ): + self.assertIn(f'"{key}"', source) + + helper = next( + node + for node in module.body + if isinstance(node, ast.FunctionDef) and node.name == "_drop_sensitive_fields" + ) + namespace: dict[str, Any] = { + "Any": Any, + "SENSITIVE_RESPONSE_KEYS": { + "identity_key", + "private_key", + "admin_password", + "guest_password", + "password", + "token", + "transport_key", + "jwt_secret", + }, + } + exec( + compile(ast.Module(body=[helper], type_ignores=[]), "", "exec"), + namespace, + ) + sanitized = namespace["_drop_sensitive_fields"]( + { + "configured": [ + { + "name": "Example", + "identity_key": "secret", + "settings": {"admin_password": "secret", "port": 5000}, + } + ] + } + ) + self.assertEqual( + sanitized, + {"configured": [{"name": "Example", "settings": {"port": 5000}}]}, + ) + + def test_transport_key_polling_drops_key_material(self) -> None: + module = _module("api.py") + method = ast.unparse( + _class_method(module, "PyMCRepeaterApiClient", "async_get_transport_keys") + ) + + self.assertIn("transport_key", method) + self.assertIn("safe_item.pop", method) + self.assertNotIn( + 'return await self._async_request_wrapped("GET", "/api/transport_keys")', + method, + ) + + def test_diagnostics_redact_runtime_installation_data(self) -> None: + source = (COMPONENT / "diagnostics.py").read_text(encoding="utf-8") + + self.assertIn('"data": async_redact_data(coordinator.data, TO_REDACT)', source) + for key in ( + "token", + "password", + "jwt_secret", + "identity_key", + "private_key", + "transport_key", + "latitude", + "longitude", + "network_current_ip", + "public_key", + "pubkey", + ): + self.assertIn(f'"{key}"', source) + + def test_new_entities_have_translation_keys(self) -> None: + translations = json.loads( + (COMPONENT / "translations" / "en.json").read_text(encoding="utf-8") + )["entity"] + expected = { + "button": ("send_direct_advert", "publish_neighbors"), + "number": ( + "flood_advert_interval_hours", + "direct_advert_interval_hours", + ), + "sensor": ( + "mqtt_neighbors_phase", + "radio_stack_mode", + "configured_radio_count", + ), + } + + for platform, keys in expected.items(): + source = (COMPONENT / f"{platform}.py").read_text(encoding="utf-8") + for key in keys: + self.assertIn(key, translations.get(platform, {})) + self.assertIn(f'translation_key="{key}"', source) + number_source = (COMPONENT / "number.py").read_text(encoding="utf-8") + self.assertIn("self._attr_translation_key = description.translation_key", number_source) def test_service_descriptions_include_new_actions(self) -> None: source = (COMPONENT / "services.yaml").read_text(encoding="utf-8") self.assertIn("cad_manual_check:", source) self.assertIn("get_neighbor_links:", source) self.assertIn("get_neighbor_link_history:", source) + self.assertIn("send_advert:", source) + self.assertIn("publish_neighbors:", source) + self.assertIn("get_neighbor_scopes:", source) + self.assertIn("query_neighbor_scopes:", source) self.assertIn("cad_symbol_num:", source) self.assertIn("known_signal_present:", source) @@ -132,7 +365,7 @@ def test_release_metadata_and_changelog_are_v1_1_6(self) -> None: self.assertEqual(manifest["version"], "1.1.6") self.assertIn("## 1.1.6", changelog) - self.assertNotIn("## Unreleased", changelog) + self.assertIn("## Unreleased", changelog) for expected in ( "15 seconds", "GPS stream", @@ -154,6 +387,14 @@ def test_example_dashboard_is_anonymized_and_includes_v1_1_6_entities(self) -> N self.assertIn("sensor.REPEATER_SLUG_observed_neighbor_links", dashboard) self.assertIn("sensor.REPEATER_SLUG_active_neighbor_links", dashboard) self.assertIn("sensor.REPEATER_SLUG_metrics_data_source", dashboard) + self.assertIn("sensor.REPEATER_SLUG_mqtt_neighbors_phase", dashboard) + self.assertIn("sensor.REPEATER_SLUG_radio_stack_mode", dashboard) + self.assertIn("sensor.REPEATER_SLUG_configured_radio_count", dashboard) + self.assertIn("sensor.REPEATER_SLUG_sensor_modem_current_ma", dashboard) + self.assertIn("sensor.REPEATER_SLUG_sensor_modem_power_mw", dashboard) + self.assertIn("button.REPEATER_SLUG_send_direct_advert", dashboard) + self.assertIn("button.REPEATER_SLUG_publish_mqtt_neighbors", dashboard) + self.assertIn("number.REPEATER_SLUG_direct_advert_interval", dashboard) self.assertIn("External Modem Status", dashboard) self.assertIn("Example Broker 1", dashboard) self.assertIn("Example Companion", dashboard) From ccfdf7b851236a49c705be2b32a4cd6126ad139a Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:10:58 -0400 Subject: [PATCH 02/12] fix(dashboard): resolve dynamic modem percentages Use percentage-valued sensors for gauges and discover active modem battery and solar-rate entities despite Home Assistant area prefixes or numeric registry suffixes. --- CHANGELOG.md | 1 + README.md | 3 +- dashboards/openhop_repeater_dashboard.yaml | 43 +++++++++++++++------- tests/test_dev_api_alignment.py | 30 +++++++++++++++ 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 060d3b3..5df8dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - added radio-stack mode and configured-radio-count diagnostics for the Repeater's current multi-radio API payload - aligned the flood advert interval control with the Repeater's disjoint `0` or `3-168` hour validation instead of allowing invalid one- and two-hour writes - stopped retaining raw transport-key and private identity configuration in coordinator polling and redacted runtime location, identity, network, and credential fields from downloadable diagnostics +- fixed the dashboard percentage gauges and made modem battery/solar percentages tolerate Home Assistant area prefixes and numeric entity-ID suffixes ## 1.1.6 diff --git a/README.md b/README.md index b86c47b..b488f0b 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,8 @@ To use it: 4. Create or edit a dashboard view and paste the template into the view's YAML editor. 5. Optionally change the view `title` and `path`. 6. Replace the marked example MQTT broker and companion rows with entities from your installation. -7. If your external sensor is not named `modem`, replace `_sensor_modem_` with its actual sensor slug. +7. The modem percentage card automatically finds active battery and solar-rate entities even when Home Assistant adds an area prefix or numeric suffix. +8. For other dynamic rows, replace the complete example entity ID with the active entity ID shown in your instance. If the external sensor is not named `modem`, also update `_sensor_modem_` in the percentage card's two match strings. The dashboard uses only built-in Home Assistant cards. diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml index e36ddae..fc70605 100644 --- a/dashboards/openhop_repeater_dashboard.yaml +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -8,9 +8,12 @@ # 4. Optionally change the title and path below. # # Dynamic MQTT broker, companion, and external-sensor entities vary by install. -# Replace the clearly marked example broker/companion rows with entities that -# exist in your instance. If your external sensor is not named `modem`, replace -# `_sensor_modem_` in those rows with the actual sensor slug shown by HA. +# Home Assistant can give dynamic entities area prefixes or numeric suffixes, +# so they cannot always be derived from REPEATER_SLUG. Replace the clearly +# marked example rows with the complete active entity IDs from your instance. +# The modem percentage card below searches active entities and tolerates those +# area prefixes or numeric suffixes. If the sensor is not named `modem`, replace +# `_sensor_modem_` in its two template match strings with the actual sensor slug. # # This template uses only built-in Home Assistant cards. @@ -65,14 +68,14 @@ cards: - type: horizontal-stack cards: - type: gauge - entity: sensor.REPEATER_SLUG_current_airtime - name: Airtime + entity: sensor.REPEATER_SLUG_cpu_usage + name: CPU Use min: 0 max: 100 severity: green: 0 - yellow: 50 - red: 80 + yellow: 70 + red: 90 - type: gauge entity: sensor.REPEATER_SLUG_radio_utilization name: Radio Use @@ -530,6 +533,25 @@ cards: - entity: sensor.REPEATER_SLUG_gps_valid_checksums name: Valid Checksums + - type: markdown + title: External Modem Percentages + card_size: 2 + content: | + {% set ns = namespace(battery=none, battery_unit='%', solar=none, solar_unit='%/h') %} + {% for item in states.sensor %} + {% if 'REPEATER_SLUG_sensor_modem_battery_percent' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} + {% set ns.battery = item.state %} + {% set ns.battery_unit = item.attributes.get('unit_of_measurement', '%') %} + {% endif %} + {% if 'REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} + {% set ns.solar = item.state %} + {% set ns.solar_unit = item.attributes.get('unit_of_measurement', '%/h') %} + {% endif %} + {% endfor %} + **Battery:** {% if ns.battery is not none %}{{ ns.battery }} {{ ns.battery_unit }}{% else %}Not reported{% endif %} + + **Solar charge rate:** {% if ns.solar is not none %}{{ ns.solar }} {{ ns.solar_unit }}{% else %}Not reported{% endif %} + - type: entities title: External Modem Status state_color: true @@ -548,18 +570,13 @@ cards: name: Modem UTC Time icon: mdi:clock-outline - type: divider - - entity: sensor.REPEATER_SLUG_sensor_modem_battery_percent - name: Battery - icon: mdi:battery - entity: sensor.REPEATER_SLUG_sensor_modem_battery_voltage_v name: Battery Voltage icon: mdi:flash - entity: sensor.REPEATER_SLUG_sensor_modem_battery_voltage_mv name: Battery Voltage mV icon: mdi:flash-outline - - entity: sensor.REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour - name: Solar Charge Rate - icon: mdi:solar-power + - entity: sensor.REPEATER_SLUG_sensor_modem_bus_voltage_v name: Bus Voltage icon: mdi:flash-outline diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py index a11c063..fbfdaa8 100644 --- a/tests/test_dev_api_alignment.py +++ b/tests/test_dev_api_alignment.py @@ -378,6 +378,36 @@ def test_release_metadata_and_changelog_are_v1_1_6(self) -> None: self.assertIn(expected, changelog) self.assertIn("actions/setup-python@v7", workflow) + def test_dashboard_percentage_cards_use_percentage_states_and_dynamic_modem_lookup(self) -> None: + dashboard = (ROOT / "dashboards/openhop_repeater_dashboard.yaml").read_text( + encoding="utf-8" + ) + gauge_stack = dashboard.split(" - type: horizontal-stack", 1)[1].split( + " - type: history-graph", 1 + )[0] + + self.assertIn("sensor.REPEATER_SLUG_cpu_usage", gauge_stack) + self.assertIn("sensor.REPEATER_SLUG_radio_utilization", gauge_stack) + self.assertIn("sensor.REPEATER_SLUG_packet_drop_rate_24h", gauge_stack) + self.assertNotIn("sensor.REPEATER_SLUG_current_airtime", gauge_stack) + self.assertIn( + "'REPEATER_SLUG_sensor_modem_battery_percent' in item.entity_id", + dashboard, + ) + self.assertIn( + "'REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour' in item.entity_id", + dashboard, + ) + self.assertNotIn( + "entity: sensor.REPEATER_SLUG_sensor_modem_battery_percent\n", + dashboard, + ) + self.assertNotIn( + "entity: sensor.REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour\n", + dashboard, + ) + self.assertIn("area prefixes or numeric suffixes", dashboard) + def test_example_dashboard_is_anonymized_and_includes_v1_1_6_entities(self) -> None: dashboard = (ROOT / "dashboards/openhop_repeater_dashboard.yaml").read_text( encoding="utf-8" From 4717f2f423dabb7177d680f9fb9d0469003ef065 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:06:21 -0400 Subject: [PATCH 03/12] feat: expand repeater monitoring and Home Assistant operations --- CHANGELOG.md | 11 ++ README.md | 29 ++- .../openhop/broker_disconnected.yaml | 32 ++++ .../automation/openhop/high_temperature.yaml | 40 +++++ .../automation/openhop/low_battery.yaml | 40 +++++ .../automation/openhop/plugin_failure.yaml | 32 ++++ .../automation/openhop/stale_sensor.yaml | 32 ++++ .../automation/openhop/unavailable.yaml | 32 ++++ .../automation/openhop/update_available.yaml | 32 ++++ custom_components/pymc_repeater/__init__.py | 5 + custom_components/pymc_repeater/api.py | 45 ++++- .../pymc_repeater/binary_sensor.py | 2 + .../pymc_repeater/coordinator.py | 7 +- custom_components/pymc_repeater/monitoring.py | 136 +++++++++++++++ .../pymc_repeater/monitoring_entities.py | 153 ++++++++++++++++ custom_components/pymc_repeater/sensor.py | 54 +++++- custom_components/pymc_repeater/services.yaml | 10 +- .../pymc_repeater/translations/en.json | 4 + custom_components/pymc_repeater/update.py | 66 +++++++ dashboards/openhop_operations.yaml | 36 ++++ dashboards/openhop_repeater_dashboard.yaml | 10 ++ docs/installed-api-alignment.md | 50 ++++++ docs/operational-monitoring.md | 108 ++++++++++++ tests/test_dev_api_alignment.py | 4 +- tests/test_installed_api_behavior.py | 165 ++++++++++++++++++ tests/test_monitoring.py | 98 +++++++++++ tests/test_runtime_monitoring.py | 139 +++++++++++++++ 27 files changed, 1349 insertions(+), 23 deletions(-) create mode 100644 blueprints/automation/openhop/broker_disconnected.yaml create mode 100644 blueprints/automation/openhop/high_temperature.yaml create mode 100644 blueprints/automation/openhop/low_battery.yaml create mode 100644 blueprints/automation/openhop/plugin_failure.yaml create mode 100644 blueprints/automation/openhop/stale_sensor.yaml create mode 100644 blueprints/automation/openhop/unavailable.yaml create mode 100644 blueprints/automation/openhop/update_available.yaml create mode 100644 custom_components/pymc_repeater/monitoring.py create mode 100644 custom_components/pymc_repeater/monitoring_entities.py create mode 100644 custom_components/pymc_repeater/update.py create mode 100644 dashboards/openhop_operations.yaml create mode 100644 docs/installed-api-alignment.md create mode 100644 docs/operational-monitoring.md create mode 100644 tests/test_installed_api_behavior.py create mode 100644 tests/test_monitoring.py create mode 100644 tests/test_runtime_monitoring.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df8dbb..2fcdeb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- added stable radio child inventory and supported read-only radio settings without allocating aggregate RF metrics; the installed API does not establish genuine per-radio telemetry +- derive charging/discharging/neutral battery trend only from fresh finite signed solar charge rate; zero does not claim a full battery +- added shared-poll component diagnostics, last successful poll, source age/stale sensors and conservative measurement availability +- added a native cached-status update entity with explicit installation only, preserving all existing update controls +- added battery/electrical/temperature device classes, generic voltage units and rejection of nonfinite measurements +- added seven opt-in alert blueprints with user-selected actions and a separate compact operations dashboard + +- added aggregate installed, enabled, running, and failed application-plugin diagnostics and dashboard rows, plus allowlisted per-ID version/state/enabled/runtime/problem entities, without retaining plugin paths, settings, or repository metadata +- added optional `bucket_seconds` to the existing neighbor-history action; raw `rows` remain the default and bucketed requests return `buckets` +- checked these additions against installed Repeater 1.1.2.dev325 source and added executable stdlib client/sensor behavior tests (not a live Home Assistant runtime test) + - aligned advert controls with current Repeater dev support for explicit flood/direct sends and independent flood/direct schedules - added MQTT neighbors publication status, a manual publication control, stored neighbor-scope retrieval, and one-neighbor scope queries - allowed the neighbor-scope query action to wait through the Repeater's 45-second response window instead of hitting the integration's normal 10-second API timeout diff --git a/README.md b/README.md index b488f0b..e5f0da8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The integration uses coordinated local polling instead of making a separate API - Hardware, process, network, database, and metrics diagnostics - GPS position, fix, satellite, time-sync, and location-update data - External sensor-manager entities, including supported modem and UPS readings +- Application-plugin health counts (installed, enabled, running, and failed) - Neighbor-link counts, neighbor-scope queries, and on-demand neighbor history - Default-region, duty-cycle, advert-rate, advert-schedule, and Repeater-mode controls - Update status, update-channel selection, and update actions @@ -111,7 +112,7 @@ A comprehensive native Lovelace view is included at: [`dashboards/openhop_repeater_dashboard.yaml`](dashboards/openhop_repeater_dashboard.yaml) -The template covers radio health and stack diagnostics, packet flow, LBT diagnostics, routing, neighbor links, controls, advert tuning, MQTT, companions, GPS, external modem power readings, updates, and database metrics. +The template covers radio health and stack diagnostics, packet flow, LBT diagnostics, routing, neighbor links, plugin health, controls, advert tuning, MQTT, companions, GPS, external modem power readings, updates, and database metrics. To use it: @@ -142,6 +143,28 @@ Open **Developer tools → Actions** and search for `openHop Repeater` or `pymc_ The raw radio-config action accepts the Repeater dev `radio_id` field for multi-radio targeting and `direct_advert_interval_hours` for the additional advert schedule. The raw MQTT-config action accepts custom `base_topic` values and neighbor-publisher settings supported by current Repeater dev builds. +### Plugin health and bucketed neighbor history + +Application-plugin counts use the installed plugin manager's lightweight `/api/plugins/` list on the shared polling schedule. These are separate from external sensor-manager readings. Counts and an allowlist of plugin ID, name, version, enabled, state, and has_runtime enter coordinator data. Paths, settings, logs, PID, descriptions and repository URLs are not retained. An empty inventory reports zero; unsupported/unavailable manager endpoints or malformed inventory leave the counts unknown rather than falsely reporting a healthy empty installation. Authentication and connection failures still fail the regular refresh. + +`Enabled plugins` is not the same as `Running plugins`: UI-only plugins can be enabled without a running process. `Failed plugins` counts only the manager's explicit `FAILED` state, not stopped/disabled plugins. + +The existing `pymc_repeater.get_neighbor_link_history` response-returning action accepts optional `bucket_seconds` (integer, minimum 60). Omit it to preserve the raw `rows` response. Supply it to receive `buckets`, `bucket_seconds`, and a bucket `count`; `limit` caps returned buckets rather than raw observations. This requires a Repeater build supporting bucketed history and is never polled automatically. For example: + +```yaml +action: pymc_repeater.get_neighbor_link_history +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID + peer_hash: AB + path_hash_size: 1 + hours: 24 + limit: 1000 + bucket_seconds: 300 +response_variable: neighbor_history +``` + +Replace the example entry ID and peer hash with your target. Omit `bucket_seconds` for older Repeater builds; there is no silent fallback from buckets to raw rows. See [installed API alignment notes](docs/installed-api-alignment.md) for the audited source contract and intentionally deferred capabilities. + ## Authentication and persistent storage The Home Assistant integration stores an API token, while the Repeater stores the matching token hash in its SQLite database. Both the Repeater JWT secret and SQLite database must persist across Repeater restarts. @@ -172,6 +195,10 @@ Do not publish your admin password, JWT secret, Home Assistant token, or Repeate - [Release notes](CHANGELOG.md) - [Issue tracker](https://github.com/openhop-dev/openHop-HA-Integration/issues) +## Operational monitoring + +See [operational monitoring](docs/operational-monitoring.md) for radio child devices, source freshness and component diagnostics, native update installation safety, plugin health, battery units, alert blueprints, and the separate compact operations view. + ## Development The repository includes HACS validation, Hassfest, Python compilation, and contract tests. To run the local checks: diff --git a/blueprints/automation/openhop/broker_disconnected.yaml b/blueprints/automation/openhop/broker_disconnected.yaml new file mode 100644 index 0000000..a8e1450 --- /dev/null +++ b/blueprints/automation/openhop/broker_disconnected.yaml @@ -0,0 +1,32 @@ +blueprint: + name: "openHop: Selected broker disconnected" + description: >- + Select one entity and your own actions. The condition must persist for the + selected duration. No notification service or corrective action is assumed. + Timers reset after Home Assistant restart or automation reload. Threshold + alerts trigger on crossing, not repeatedly while already beyond the limit. + domain: automation + input: + entity: + name: Entity to monitor + selector: + entity: + domain: binary_sensor + duration: + name: Sustained duration + default: + minutes: 5 + selector: + duration: {} + actions: + name: Actions to run + selector: + action: {} +trigger: + - platform: state + entity_id: !input entity + to: "off" + for: !input duration +condition: [] +action: !input actions +mode: single diff --git a/blueprints/automation/openhop/high_temperature.yaml b/blueprints/automation/openhop/high_temperature.yaml new file mode 100644 index 0000000..1e39565 --- /dev/null +++ b/blueprints/automation/openhop/high_temperature.yaml @@ -0,0 +1,40 @@ +blueprint: + name: "openHop: High temperature" + description: >- + Select one entity and your own actions. The condition must persist for the + selected duration. No notification service or corrective action is assumed. + Timers reset after Home Assistant restart or automation reload. Threshold + alerts trigger on crossing, not repeatedly while already beyond the limit. + domain: automation + input: + entity: + name: Entity to monitor + selector: + entity: + domain: sensor + duration: + name: Sustained duration + default: + minutes: 5 + selector: + duration: {} + threshold: + name: Threshold in the selected sensor unit + default: 70 + selector: + number: + min: -100 + max: 1000 + mode: box + actions: + name: Actions to run + selector: + action: {} +trigger: + - platform: numeric_state + entity_id: !input entity + above: !input threshold + for: !input duration +condition: [] +action: !input actions +mode: single diff --git a/blueprints/automation/openhop/low_battery.yaml b/blueprints/automation/openhop/low_battery.yaml new file mode 100644 index 0000000..4a01c78 --- /dev/null +++ b/blueprints/automation/openhop/low_battery.yaml @@ -0,0 +1,40 @@ +blueprint: + name: "openHop: Low battery" + description: >- + Select one entity and your own actions. The condition must persist for the + selected duration. No notification service or corrective action is assumed. + Timers reset after Home Assistant restart or automation reload. Threshold + alerts trigger on crossing, not repeatedly while already beyond the limit. + domain: automation + input: + entity: + name: Entity to monitor + selector: + entity: + domain: sensor + duration: + name: Sustained duration + default: + minutes: 5 + selector: + duration: {} + threshold: + name: Threshold in the selected sensor unit + default: 20 + selector: + number: + min: -100 + max: 1000 + mode: box + actions: + name: Actions to run + selector: + action: {} +trigger: + - platform: numeric_state + entity_id: !input entity + below: !input threshold + for: !input duration +condition: [] +action: !input actions +mode: single diff --git a/blueprints/automation/openhop/plugin_failure.yaml b/blueprints/automation/openhop/plugin_failure.yaml new file mode 100644 index 0000000..5d67d40 --- /dev/null +++ b/blueprints/automation/openhop/plugin_failure.yaml @@ -0,0 +1,32 @@ +blueprint: + name: "openHop: Plugin failure" + description: >- + Select one entity and your own actions. The condition must persist for the + selected duration. No notification service or corrective action is assumed. + Timers reset after Home Assistant restart or automation reload. Threshold + alerts trigger on crossing, not repeatedly while already beyond the limit. + domain: automation + input: + entity: + name: Entity to monitor + selector: + entity: + domain: binary_sensor + duration: + name: Sustained duration + default: + minutes: 5 + selector: + duration: {} + actions: + name: Actions to run + selector: + action: {} +trigger: + - platform: state + entity_id: !input entity + to: "on" + for: !input duration +condition: [] +action: !input actions +mode: single diff --git a/blueprints/automation/openhop/stale_sensor.yaml b/blueprints/automation/openhop/stale_sensor.yaml new file mode 100644 index 0000000..ca2ce7f --- /dev/null +++ b/blueprints/automation/openhop/stale_sensor.yaml @@ -0,0 +1,32 @@ +blueprint: + name: "openHop: Stale sensor reading" + description: >- + Select one entity and your own actions. The condition must persist for the + selected duration. No notification service or corrective action is assumed. + Timers reset after Home Assistant restart or automation reload. Threshold + alerts trigger on crossing, not repeatedly while already beyond the limit. + domain: automation + input: + entity: + name: Entity to monitor + selector: + entity: + domain: binary_sensor + duration: + name: Sustained duration + default: + minutes: 5 + selector: + duration: {} + actions: + name: Actions to run + selector: + action: {} +trigger: + - platform: state + entity_id: !input entity + to: "on" + for: !input duration +condition: [] +action: !input actions +mode: single diff --git a/blueprints/automation/openhop/unavailable.yaml b/blueprints/automation/openhop/unavailable.yaml new file mode 100644 index 0000000..a3d7fa1 --- /dev/null +++ b/blueprints/automation/openhop/unavailable.yaml @@ -0,0 +1,32 @@ +blueprint: + name: "openHop: Sustained unavailability" + description: >- + Select one entity and your own actions. The condition must persist for the + selected duration. No notification service or corrective action is assumed. + Timers reset after Home Assistant restart or automation reload. Threshold + alerts trigger on crossing, not repeatedly while already beyond the limit. + domain: automation + input: + entity: + name: Entity to monitor + selector: + entity: + domain: sensor + duration: + name: Sustained duration + default: + minutes: 5 + selector: + duration: {} + actions: + name: Actions to run + selector: + action: {} +trigger: + - platform: state + entity_id: !input entity + to: "unavailable" + for: !input duration +condition: [] +action: !input actions +mode: single diff --git a/blueprints/automation/openhop/update_available.yaml b/blueprints/automation/openhop/update_available.yaml new file mode 100644 index 0000000..03b6ced --- /dev/null +++ b/blueprints/automation/openhop/update_available.yaml @@ -0,0 +1,32 @@ +blueprint: + name: "openHop: Update available" + description: >- + Select one entity and your own actions. The condition must persist for the + selected duration. No notification service or corrective action is assumed. + Timers reset after Home Assistant restart or automation reload. Threshold + alerts trigger on crossing, not repeatedly while already beyond the limit. + domain: automation + input: + entity: + name: Entity to monitor + selector: + entity: + domain: update + duration: + name: Sustained duration + default: + minutes: 5 + selector: + duration: {} + actions: + name: Actions to run + selector: + action: {} +trigger: + - platform: state + entity_id: !input entity + to: "on" + for: !input duration +condition: [] +action: !input actions +mode: single diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index 2fa91ed..ad89d79 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -19,6 +19,7 @@ from .coordinator import PyMCRepeaterDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.UPDATE, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON, @@ -786,6 +787,7 @@ async def _with_api_response( path_hash_size=call.data["path_hash_size"], hours=call.data.get("hours", 24), limit=call.data.get("limit", 1000), + bucket_seconds=call.data.get("bucket_seconds"), ), always_return=True, ), @@ -793,6 +795,9 @@ async def _with_api_response( { vol.Optional(CONF_ENTRY_ID): str, vol.Required("peer_hash"): str, + vol.Optional("bucket_seconds"): vol.All( + vol.Coerce(int), vol.Range(min=60) + ), vol.Required("path_hash_size"): vol.All( vol.Coerce(int), vol.Range(min=1, max=3) ), diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index 3fdb472..db65f22 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -173,6 +173,7 @@ async def async_fetch_all(self) -> dict[str, Any]: "lbt_diagnostics": self.async_get_lbt_diagnostics(), "default_region": self.async_get_default_region(), "neighbor_links": self.async_get_neighbor_links(), + "plugin_summary": self.async_get_plugin_summary(), } results = await asyncio.gather(*endpoints.values(), return_exceptions=True) @@ -257,6 +258,30 @@ async def async_get_neighbor_links( }, ) + async def async_get_plugin_summary(self) -> dict[str, Any]: + """Return counts and allowlisted plugin health, never paths or configuration.""" + # Unlike most endpoints, this API returns a top-level plugins list. + payload = await self._async_request_wrapped("GET", "/api/plugins/") + plugins = payload.get("plugins") if isinstance(payload, dict) else None + if not isinstance(plugins, list) or any( + not isinstance(item, dict) + or not isinstance(item.get("enabled"), bool) + or not isinstance(item.get("state"), str) + for item in plugins + ): + raise PyMCRepeaterApiError("Invalid plugin inventory response") + return { + "plugins": [ + {key: item[key] for key in ("id", "name", "version", "enabled", "state", "has_runtime") + if key in item and isinstance(item[key], (str, bool))} + for item in plugins if isinstance(item.get("id"), str) and item["id"] + ], + "installed": len(plugins), + "enabled": sum(item["enabled"] for item in plugins), + "running": sum(item["state"] == "RUNNING" for item in plugins), + "failed": sum(item["state"] == "FAILED" for item in plugins), + } + async def async_get_neighbor_link_history( self, *, @@ -264,17 +289,19 @@ async def async_get_neighbor_link_history( path_hash_size: int, hours: int = DEFAULT_PACKET_WINDOW_HOURS, limit: int = 1000, + bucket_seconds: int | None = None, ) -> dict[str, Any]: - """Return stored observations for one upstream neighbor link.""" + """Return raw observations or optional time buckets for one neighbor.""" + params: dict[str, Any] = { + "peer_hash": peer_hash, + "path_hash_size": path_hash_size, + "hours": hours, + "limit": limit, + } + if bucket_seconds is not None: + params["bucket_seconds"] = bucket_seconds return await self._async_request_wrapped( - "GET", - "/api/neighbor_link_history", - params={ - "peer_hash": peer_hash, - "path_hash_size": path_hash_size, - "hours": hours, - "limit": limit, - }, + "GET", "/api/neighbor_link_history", params=params ) async def async_get_noise_floor_stats(self) -> dict[str, Any]: diff --git a/custom_components/pymc_repeater/binary_sensor.py b/custom_components/pymc_repeater/binary_sensor.py index 5332149..7b51cf6 100644 --- a/custom_components/pymc_repeater/binary_sensor.py +++ b/custom_components/pymc_repeater/binary_sensor.py @@ -196,6 +196,8 @@ def add_external_sensor_entities() -> None: if new_entities: async_add_entities(new_entities) + from .monitoring_entities import setup_monitoring_binary_sensors + setup_monitoring_binary_sensors(entry, coordinator, async_add_entities) async_add_entities(entities) add_external_sensor_entities() entry.async_on_unload(coordinator.async_add_listener(add_external_sensor_entities)) diff --git a/custom_components/pymc_repeater/coordinator.py b/custom_components/pymc_repeater/coordinator.py index c0bf9a7..f43f101 100644 --- a/custom_components/pymc_repeater/coordinator.py +++ b/custom_components/pymc_repeater/coordinator.py @@ -4,7 +4,7 @@ import asyncio import contextlib -from datetime import timedelta +from datetime import datetime, timedelta, timezone import json import logging @@ -54,6 +54,7 @@ def __init__( ) self.config_entry = entry self.api = api + self.last_successful_poll: datetime | None = None self._gps_stream_task: asyncio.Task | None = None async def async_start_runtime(self) -> None: @@ -71,7 +72,9 @@ async def async_stop_runtime(self) -> None: async def _async_update_data(self) -> dict: try: - return await self.api.async_fetch_all() + data = await self.api.async_fetch_all() + self.last_successful_poll = datetime.now(timezone.utc) + return data except PyMCRepeaterAuthenticationError as err: raise ConfigEntryAuthFailed(str(err)) from err except PyMCRepeaterCannotConnect as err: diff --git a/custom_components/pymc_repeater/monitoring.py b/custom_components/pymc_repeater/monitoring.py new file mode 100644 index 0000000..e4d5b36 --- /dev/null +++ b/custom_components/pymc_repeater/monitoring.py @@ -0,0 +1,136 @@ +"""Conservative monitoring normalization independent of Home Assistant.""" +from __future__ import annotations + +from datetime import datetime +from math import isfinite +from time import time +from typing import Any + + +def finite_number(value: Any) -> float | None: + """Reject booleans, nonnumeric values and nonfinite measurements.""" + if isinstance(value, bool): + return None + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return None + return number if isfinite(number) else None + + +def reading_age(reading: dict, *, now: float | None = None) -> float | None: + """Return source age; absent, naive or future timestamps are unknown.""" + value = reading.get("timestamp") + stamp = finite_number(value) + if stamp is None and isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + return None + stamp = parsed.timestamp() + except (ValueError, OverflowError): + return None + if stamp is None or stamp <= 0: + return None + age = (time() if now is None else now) - stamp + return round(age, 1) if age >= 0 else None + + +def reading_stale(reading: dict, interval: Any, *, now: float | None = None) -> bool | None: + """Allow three source polling intervals, with a 60-second minimum.""" + age = reading_age(reading, now=now) + cadence = finite_number(interval) + if age is None or cadence is None or cadence <= 0: + return None + return age > max(60, cadence * 3) + + +def reading_usable(reading: dict, interval: Any, *, now: float | None = None) -> bool: + """Never expose cached measurements as fresh without source evidence.""" + return reading.get("ok") is True and reading_stale(reading, interval, now=now) is False + + +RADIO_FIELDS = ("frequency", "tx_power", "bandwidth", "spreading_factor", "coding_rate", "preamble_length") + + +def charge_state(reading: dict, interval: Any, *, now: float | None = None) -> str | None: + """Describe battery trend, not charger hardware or a full-battery claim.""" + if not reading_usable(reading, interval, now=now): + return None + payload = reading.get("data") + rate = finite_number(payload.get("solar_charge_rate_percent_per_hour")) if isinstance(payload, dict) else None + if rate is None: + return None + return "charging" if rate > 0 else "discharging" if rate < 0 else "neutral" + + +def plugin_problem(plugin: dict) -> bool | None: + """UI-only/disabled plugins need no process; transition states are unknown.""" + if plugin.get("enabled") is False or plugin.get("has_runtime") is False: + return False + if plugin.get("enabled") is not True or plugin.get("has_runtime") is not True: + return None + state = plugin.get("state") + if state in ("FAILED", "STOPPED"): + return True + if state == "RUNNING": + return False + return None + + +def radio_inventory(data: dict) -> dict[str, dict]: + """Expose allowlisted radio configuration, not aggregate health or traffic. + + Installed /api/stats radios[] is configuration, NOT per-radio telemetry. + """ + stats = data.get("stats") + if not isinstance(stats, dict) or stats.get("error"): + return {} + stack = stats.get("radio_stack") + stack = stack if isinstance(stack, dict) else {} + ids = stack.get("radio_ids") + result = {rid: {} for rid in ids if isinstance(rid, str) and rid} if isinstance(ids, list) else {} + rows = stats.get("radios") + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + rid = row.get("id") or row.get("radio_id") + if not isinstance(rid, str) or not rid: + continue + result.setdefault(rid, {}) + if isinstance(row.get("type"), str): + result[rid]["type"] = row["type"] + settings = row.get("radio") + if isinstance(settings, dict): + for field in RADIO_FIELDS: + value = finite_number(settings.get(field)) + if value is not None: + result[rid][field] = value + # Legacy single-radio configuration is global, but belongs only to the + # explicitly named default. Never copy global configuration across a fabric. + default = stack.get("default_radio") + config = data.get("config") + if (stack.get("mode") == "single" and len(result) == 1 and isinstance(default, str) and default in result + and isinstance(config, dict) and not config.get("error")): + settings = config.get("radio") + if isinstance(settings, dict): + for field in RADIO_FIELDS: + value = finite_number(settings.get(field)) + if value is not None: + result[default].setdefault(field, value) + return result + + +def measurement_class(field: str) -> str | None: + """Only classify measured fields with known physical units.""" + if field in ("battery_percent", "battery_percentage"): + return "battery" + if field.endswith(("voltage_v", "voltage_mv")): + return "voltage" + if field.endswith(("current_ma", "current_a")): + return "current" + if field.endswith(("power_mw", "power_w")): + return "power" + if field.endswith(("temperature_c", "temperature_f")): + return "temperature" + return None diff --git a/custom_components/pymc_repeater/monitoring_entities.py b/custom_components/pymc_repeater/monitoring_entities.py new file mode 100644 index 0000000..059c192 --- /dev/null +++ b/custom_components/pymc_repeater/monitoring_entities.py @@ -0,0 +1,153 @@ +"""Read-only operational entities discovered on the shared coordinator.""" +from __future__ import annotations + +import json + +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from homeassistant.helpers.entity import DeviceInfo, EntityCategory + +from .const import DOMAIN, MANUFACTURER +from .monitoring import RADIO_FIELDS, charge_state, plugin_problem, radio_inventory, reading_age, reading_stale +from .sensor import PyMCBaseEntity, _external_sensor_identity, _external_sensor_readings, _nested + + +def _snapshot(coordinator, binary: bool) -> dict: + """Only scalar values and stable source identifiers enter the entity model.""" + data = coordinator.data or {} + values = {} + if binary: + values[("component", "api", "connected")] = coordinator.last_update_success + for key in ("stats", "mqtt_status", "hardware_stats", "gps", "plugin_summary", "update_status"): + payload = data.get(key) + values[("component", key, "problem")] = ( + (bool(payload.get("error")) or payload.get("success") is False) + if isinstance(payload, dict) and payload else None + ) + else: + values[("component", "api", "last_success")] = getattr(coordinator, "last_successful_poll", None) + for radio_id, radio in radio_inventory(data).items(): + values[("radio", radio_id, "inventory")] = radio.get("type", "configured") + for field in RADIO_FIELDS: + if field in radio: + values[("radio", radio_id, field)] = radio[field] + plugins = _nested(data, "plugin_summary", "plugins") + for plugin in plugins if isinstance(plugins, list) else []: + if not isinstance(plugin, dict) or not isinstance(plugin.get("id"), str): + continue + fields = {"enabled": plugin.get("enabled"), "running": ( + plugin.get("state") == "RUNNING" if plugin.get("state") in ("RUNNING", "STOPPED", "FAILED", "DISABLED") else None + ), "problem": plugin_problem(plugin)} if binary else { + "version": plugin.get("version"), "state": plugin.get("state")} + for field, value in fields.items(): + values[("plugin", plugin["id"], field)] = value + interval = _nested(data, "stats", "sensors", "poll_interval_seconds") + for reading in _external_sensor_readings(data): + if not isinstance(reading, dict) or not reading.get("name"): + continue + identity = _external_sensor_identity(reading) + if binary: + values[("reading", identity, "stale")] = reading_stale(reading, interval) + else: + values[("reading", identity, "age")] = reading_age(reading) + payload = reading.get("data") + if isinstance(payload, dict) and "solar_charge_rate_percent_per_hour" in payload: + values[("reading", identity, "charge_state")] = charge_state(reading, interval) + return values + + +def _setup(entry, coordinator, async_add_entities, binary: bool) -> None: + seen = set() + + def discover() -> None: + entities = [] + for key in _snapshot(coordinator, binary): + if key in seen: + continue + seen.add(key) + cls = MonitoringBinarySensor if binary else MonitoringSensor + entities.append(cls(entry, coordinator, key)) + if entities: + async_add_entities(entities) + + discover() + entry.async_on_unload(coordinator.async_add_listener(discover)) + + +def setup_monitoring_sensors(entry, coordinator, async_add_entities) -> None: + """Discover sensors initially and when optional sources appear.""" + _setup(entry, coordinator, async_add_entities, False) + + +def setup_monitoring_binary_sensors(entry, coordinator, async_add_entities) -> None: + """Discover diagnostics without a second polling schedule.""" + _setup(entry, coordinator, async_add_entities, True) + + +class MonitoringEntity(PyMCBaseEntity): + """Stable source-ID entity; missing sources are unavailable, not healthy.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _binary = False + + def __init__(self, entry, coordinator, key) -> None: + super().__init__(entry, coordinator) + self._key = key + self._attr_unique_id = f"{entry.unique_id or entry.entry_id}_monitor_{json.dumps(key, separators=(',', ':'))}" + self._attr_name = " ".join(key).replace("_", " ").capitalize() + + @property + def available(self) -> bool: + if self._key == ("component", "api", "connected"): + return True + return super().available and self._key in _snapshot(self.coordinator, self._binary) + + @property + def device_info(self) -> DeviceInfo: + kind, identity, _field = self._key + if kind != "radio": + return super().device_info + parent = self._entry.unique_id or self._entry.entry_id + return DeviceInfo( + identifiers={(DOMAIN, f"{parent}_radio_{identity}")}, + via_device=(DOMAIN, parent), name=f"Radio {identity}", + manufacturer=MANUFACTURER, model="Configured radio", + ) + + +class MonitoringSensor(MonitoringEntity, SensorEntity): + """Source age, inventory, plugin version and state.""" + + def __init__(self, entry, coordinator, key) -> None: + super().__init__(entry, coordinator, key) + if key[0] == "radio": + self._attr_native_unit_of_measurement = {"frequency": "Hz", "bandwidth": "Hz", "tx_power": "dBm"}.get(key[2]) + if key[2] == "charge_state": + self._attr_icon = "mdi:battery-sync-outline" + if key[2] == "age": + self._attr_native_unit_of_measurement = "s" + self._attr_device_class = SensorDeviceClass.DURATION + self._attr_state_class = SensorStateClass.MEASUREMENT + elif key[2] == "last_success": + self._attr_device_class = SensorDeviceClass.TIMESTAMP + + @property + def native_value(self): + return _snapshot(self.coordinator, False).get(self._key) + + +class MonitoringBinarySensor(MonitoringEntity, BinarySensorEntity): + """Explicit component/plugin errors and source staleness.""" + + _binary = True + + def __init__(self, entry, coordinator, key) -> None: + super().__init__(entry, coordinator, key) + if key[2] in ("problem", "stale"): + self._attr_device_class = BinarySensorDeviceClass.PROBLEM + elif key[2] == "connected": + self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + @property + def is_on(self) -> bool | None: + return _snapshot(self.coordinator, True).get(self._key) diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index 53861e3..cbb7535 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -21,6 +21,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify +from .monitoring import finite_number, measurement_class, reading_usable from .api import get_repeater_name_from_stats from .const import ( CONF_DATA_SIZE_UNIT, @@ -217,14 +218,7 @@ def _normalize_external_sensor_value(value: Any, *, has_unit: bool) -> Any: """Coerce measurement strings and reject invalid measurement states.""" if not has_unit: return value - if isinstance(value, (int, float)) and not isinstance(value, bool): - return value - if isinstance(value, str): - try: - return float(value) - except ValueError: - return None - return None + return finite_number(value) def _external_sensor_ok_count(data: dict[str, Any]) -> int: @@ -944,6 +938,42 @@ class PyMCSensorDescription(SensorEntityDescription): "rrd_available": _nested(data, "db_stats", "rrd_available"), }, ), + PyMCSensorDescription( + key="plugins_installed", + translation_key="plugins_installed", + name="Installed plugins", + icon="mdi:puzzle-outline", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "plugin_summary", "installed"), + ), + PyMCSensorDescription( + key="plugins_enabled", + translation_key="plugins_enabled", + name="Enabled plugins", + icon="mdi:puzzle-outline", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "plugin_summary", "enabled"), + ), + PyMCSensorDescription( + key="plugins_running", + translation_key="plugins_running", + name="Running plugins", + icon="mdi:puzzle-outline", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "plugin_summary", "running"), + ), + PyMCSensorDescription( + key="plugins_failed", + translation_key="plugins_failed", + name="Failed plugins", + icon="mdi:puzzle-outline", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "plugin_summary", "failed"), + ), PyMCSensorDescription( key="neighbor_link_count", name="Observed neighbor links", @@ -1289,6 +1319,8 @@ def add_external_sensor_entities() -> None: if new_entities: async_add_entities(new_entities) + from .monitoring_entities import setup_monitoring_sensors + setup_monitoring_sensors(entry, coordinator, async_add_entities) async_add_entities(entities) add_external_sensor_entities() entry.async_on_unload(coordinator.async_add_listener(add_external_sensor_entities)) @@ -1525,6 +1557,8 @@ class PyMCExternalSensorMetricSensor(PyMCBaseEntity, SensorEntity): "battery_percent": PERCENTAGE, "battery_percentage": PERCENTAGE, "humidity_pct": PERCENTAGE, + "voltage_v": "V", + "voltage_mv": "mV", "battery_voltage_v": "V", "bus_voltage_v": "V", "shunt_voltage_v": "V", @@ -1591,6 +1625,7 @@ def __init__( ) self._attr_icon = self._ICON_HINTS.get(field, "mdi:chip") self._attr_native_unit_of_measurement = self._UNIT_HINTS.get(field) + self._attr_device_class = measurement_class(field) if self._attr_native_unit_of_measurement else None self._attr_state_class = ( SensorStateClass.MEASUREMENT if self._attr_native_unit_of_measurement else None ) @@ -1607,7 +1642,8 @@ def _get_reading(self) -> dict[str, Any] | None: def available(self) -> bool: reading = self._get_reading() payload = _external_sensor_payload(reading) if isinstance(reading, dict) else {} - if not super().available or self._field not in payload: + if (not super().available or self._field not in payload + or not reading_usable(reading or {}, _nested(self.coordinator.data, "stats", "sensors", "poll_interval_seconds"))): return False return ( _normalize_external_sensor_value( diff --git a/custom_components/pymc_repeater/services.yaml b/custom_components/pymc_repeater/services.yaml index 8b66696..1432054 100644 --- a/custom_components/pymc_repeater/services.yaml +++ b/custom_components/pymc_repeater/services.yaml @@ -604,12 +604,20 @@ get_neighbor_links: get_neighbor_link_history: name: Get neighbor link history - description: Return stored packet observations for one upstream neighbor hash. + description: Return raw rows or optional time-bucketed observations for one upstream neighbor hash. fields: config_entry_id: selector: config_entry: integration: pymc_repeater + bucket_seconds: + description: Optional bucket width in seconds (minimum 60). Omit for raw rows; supplying it returns buckets instead. + selector: + number: + min: 60 + step: 1 + mode: box + unit_of_measurement: s peer_hash: required: true selector: diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index 44c4183..1d09bf3 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -44,6 +44,10 @@ }, "entity": { "sensor": { + "plugins_installed": {"name": "Installed plugins"}, + "plugins_enabled": {"name": "Enabled plugins"}, + "plugins_running": {"name": "Running plugins"}, + "plugins_failed": {"name": "Failed plugins"}, "repeater_version": { "name": "Repeater version" }, diff --git a/custom_components/pymc_repeater/update.py b/custom_components/pymc_repeater/update.py new file mode 100644 index 0000000..52933cc --- /dev/null +++ b/custom_components/pymc_repeater/update.py @@ -0,0 +1,66 @@ +"""Native firmware update entity using cached status and explicit installation.""" +from __future__ import annotations + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.exceptions import HomeAssistantError + +from .api import PyMCRepeaterApiError +from .const import DOMAIN +from .sensor import PyMCBaseEntity + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + """Add the update entity without triggering a remote release check.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + async_add_entities([PyMCUpdateEntity(entry, coordinator)]) + + +class PyMCUpdateEntity(PyMCBaseEntity, UpdateEntity): + """Install only the backend's currently selected channel; never switch it.""" + + _attr_name = "Repeater software" + _attr_supported_features = UpdateEntityFeature.INSTALL + + def __init__(self, entry, coordinator) -> None: + super().__init__(entry, coordinator) + self._attr_unique_id = f"{entry.unique_id or entry.entry_id}_software_update" + + @property + def _status(self) -> dict: + value = self.coordinator.data.get("update_status") + return value if isinstance(value, dict) else {} + + @property + def available(self) -> bool: + return (super().available and not self._status.get("error") + and self._status.get("success") is not False + and isinstance(self._status.get("has_update"), bool) + and bool(self.installed_version) and bool(self.latest_version)) + + @property + def installed_version(self) -> str | None: + value = self._status.get("current_version") + return value if isinstance(value, str) and value else None + + @property + def latest_version(self) -> str | None: + if self._status.get("has_update") is False: + return self.installed_version + value = self._status.get("latest_version") + return value if isinstance(value, str) and value else None + + @property + def extra_state_attributes(self) -> dict: + return {key: self._status.get(key) for key in ("channel", "state", "last_checked", "rate_limit_until")} + + async def async_install(self, version: str | None, backup: bool, **kwargs) -> None: + """The API cannot select versions, back up HA, or report percentages.""" + if version is not None or backup: + raise HomeAssistantError("Version selection and backups are not supported") + if not self.available or self._status.get("has_update") is not True: + raise HomeAssistantError("No confirmed update is available") + try: + await self.coordinator.api.async_update_install(force=False) + except PyMCRepeaterApiError as err: + raise HomeAssistantError("Repeater update request failed") from err + # Installation may restart the server. Let the existing shared poll confirm it. diff --git a/dashboards/openhop_operations.yaml b/dashboards/openhop_operations.yaml new file mode 100644 index 0000000..9d924b7 --- /dev/null +++ b/dashboards/openhop_operations.yaml @@ -0,0 +1,36 @@ +# Single Lovelace view, not a full dashboard. Replace REPEATER_SLUG with +# your device prefix. Replace dynamic rows with exact entity IDs from HA. +# Built-in cards only. Keep the comprehensive dashboard for diagnostics. +title: openHop operations +path: openhop-operations +icon: mdi:radio-tower +cards: + - type: entities + title: Availability and updates + show_header_toggle: false + entities: + - binary_sensor.REPEATER_SLUG_component_api_connected + - sensor.REPEATER_SLUG_component_api_last_success + - update.REPEATER_SLUG_repeater_software + - sensor.REPEATER_SLUG_repeater_version + - type: entities + title: Component health + show_header_toggle: false + entities: + - binary_sensor.REPEATER_SLUG_component_stats_problem + - binary_sensor.REPEATER_SLUG_component_mqtt_status_problem + - binary_sensor.REPEATER_SLUG_component_plugin_summary_problem + - sensor.REPEATER_SLUG_failed_plugins + - type: markdown + title: Site-specific monitoring + content: >- + Add your exact radio inventory/settings, selected MQTT broker, battery percentage, + voltage, charge-state trend, temperature, sensor age/stale, and individual plugin problem + entities here using the card editor. Dynamic names and registry suffixes + vary by installation. Missing or stale readings are not healthy zeroes. + - type: history-graph + title: Resource trends + hours_to_show: 12 + entities: + - sensor.REPEATER_SLUG_cpu_usage + - sensor.REPEATER_SLUG_memory_usage diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml index fc70605..ec424e5 100644 --- a/dashboards/openhop_repeater_dashboard.yaml +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -269,6 +269,16 @@ cards: name: Allow Unscoped Flood icon: mdi:flood + # Plugin-manager counts are unknown on older builds or if the manager is down. + - type: entities + title: Plugin Health + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_installed_plugins + - entity: sensor.REPEATER_SLUG_enabled_plugins + - entity: sensor.REPEATER_SLUG_running_plugins + - entity: sensor.REPEATER_SLUG_failed_plugins + - type: entities title: Neighbor Links state_color: true diff --git a/docs/installed-api-alignment.md b/docs/installed-api-alignment.md new file mode 100644 index 0000000..dc0637d --- /dev/null +++ b/docs/installed-api-alignment.md @@ -0,0 +1,50 @@ +# Installed Repeater API alignment + +This focused compatibility update was checked against installed **openHop Repeater 1.1.2.dev325** Python source and its bundled OpenAPI, not inferred from a newer Git branch. It does not claim complete endpoint parity or live Home Assistant validation. + +## Verified contracts + +| Contract | Installed implementation | Integration behavior | +| --- | --- | --- | +| `GET /api/plugins/` | `web/plugin_endpoints.py:index`, `_ok`; top-level `success` and `plugins`, not a `data`-wrapped list | One request on the shared refresh; reduce the list to installed/enabled/running/failed counts and allowlisted per-ID health metadata before retaining it | +| Plugin states | `plugins/manager.py:list_plugins` delegates to `plugins/runtime.py:status_dict`; boolean `enabled`, uppercase states | Count exact `RUNNING` and `FAILED`; do not infer failure from enabled-but-stopped UI-only plugins | +| Plugin authentication | `web/http_server.py` protects `/api`; `web/auth/cherrypy_tool.py` accepts `X-API-Key` | Existing API-token authentication, no additional credentials | +| Missing manager | Plugin API returns HTTP 503; older versions may return 404 | Optional endpoint error leaves plugin counts unknown; unrelated telemetry remains usable. Authentication/connection errors still fail refresh | +| `GET /api/neighbor_link_history` | `web/api_endpoints.py` adds optional `bucket_seconds`, minimum 60 | Existing action gains optional field without changing its name or legacy defaults | +| History response | `rows` without buckets; `buckets` plus `bucket_seconds` when supplied; `count` is returned rows/buckets | Preserve the response unchanged; do not silently substitute raw data for requested buckets | +| History aggregation | `data_acquisition/sqlite_handler.py` groups stored observations in SQL; newest capped results returned chronologically | No history polling or entity attributes; explicit action only, existing 1–5000 result limit retained | + +Application plugins are distinct from sensor-manager plug-ins exposed under `stats.sensors`. Only ID, name, version, enabled, state and has_runtime are retained with counts. Filesystem paths, repository URLs, logs, settings and runtime documents are not stored in the coordinator summary. Malformed inventory is an error, not a zero count. There are no new write actions. + +## Source fingerprints + +SHA-256 of the selected installed files (paths relative to the installed `repeater` package): + +```text +web/api_endpoints.py +f8cd9b2029239d4896d449141b2af81309dda9572a8854ef79bab9db91c9eee9 +web/plugin_endpoints.py +96766f7d762b9a3180ce877735c23c3399cfb1700fd2cc98a25defc814f8fbe0 +web/openapi.yaml +cc8982b6194f4748fcaf5dc392055e89fda6dfb2a20d34ef4e79ec1c7499184f +plugins/runtime.py +424f410448918413e60be596e4827ac134c5ee345387c4bf815bfe9ac66ab77e +data_acquisition/sqlite_handler.py +98612f1677c9ccdec6d53c6a5cd3654347f79cf99de16bb64b9e30d1606940c4 +``` + +Package version is provenance, not proof of equivalence to an upstream Git SHA. No live configuration, token database, keys, packets, locations, or plugin runtime data were used as fixtures. + +## Deliberately not covered + +- Plugin install/update/uninstall, lifecycle controls, catalogue/update checks, settings, logs, runtime documents and installation-progress SSE. These are not needed for read-only health and may be disruptive, sensitive or expensive. +- Packet-order summaries from newer development work: the inspected installed `get_packet_stats` implementation does not provide them. No guessed sensors or fields are added. +- Bulk packet datasets, additional filtered histories, and other endpoints outside this focused audit. Existing raw radio/MQTT action payloads are not a fully validated generated API client. +- Older builds without bucket support must omit `bucket_seconds`. No version negotiation or automatic retry to raw rows is implemented. +- The existing per-endpoint timeout and fatal transport/auth behavior are unchanged. Plugin 404/503 is isolated, but a transport timeout still fails the refresh. + +## Validation boundaries + +Run `python3 -m unittest discover -s tests -v`. The existing suite checks source contracts; `test_installed_api_behavior.py` also executes the real client methods and sensor value lambdas extracted from trusted checked-in source with a fake HTTP boundary. It exercises top-level plugin responses, empty/malformed inventories, exact counts, privacy minimization, optional/fatal polling failures, and raw/bucketed history contracts without Home Assistant dependencies or a network connection. + +Compilation and JSON/YAML parsing complement these tests; they do not validate real HA entity registration, Voluptuous execution, recorder behavior, authenticated HTTP responses, or coordinator timing in a running HA instance. HACS/Hassfest remain CI checks, not locally reproduced gates. diff --git a/docs/operational-monitoring.md b/docs/operational-monitoring.md new file mode 100644 index 0000000..d3e06ab --- /dev/null +++ b/docs/operational-monitoring.md @@ -0,0 +1,108 @@ +# Operational monitoring + +## Radio support and limitations + +Radio child devices use the exact stable backend radio ID, linked to the parent +Repeater device. Inventory is discovered on each shared poll and disappears as +unavailable when removed. The installed API's `stats.radios` is **configuration**, +not live per-radio counters. Therefore the inventory sensor reports a configured +type when supplied, otherwise `configured`. `radio_stack.radio_ids` also works +when `radios` is empty. No aggregate packet, noise, CRC, RSSI or connectivity value +is attributed to a child radio. Read-only configuration sensors expose frequency +(Hz), bandwidth (Hz), TX power (dBm), spreading factor, coding rate and preamble +length only when supplied in that radio's `radio` configuration. In explicit +single mode, global `config.radio` may fill missing fields for the sole named +default radio, never for multiple radios. Settings are configuration, not proof +of measured RF behavior or successful hardware application. +Genuine per-radio traffic/health sensors remain +unsupported until a verified per-radio telemetry contract exists. Quiet RF is +not a failure signal. + +## Freshness and component health + +One full polling schedule remains in use, alongside the existing independent GPS +SSE stream. `Component api connected` reflects coordinator refresh success; +`Component api last success` records a successful full refresh, not an SSE event. +Optional endpoint errors do not erase unrelated data. Component problem sensors +report explicit endpoint errors for stats, MQTT, hardware, GPS, plugins and update +status. These indicate API payload health, not a claim that every component's +physical subsystem works. + +Each sensor-manager reading gains age (seconds) and stale diagnostics. Stale means +source age exceeds three backend `poll_interval_seconds`, with a 60-second minimum. +Missing, invalid, naive or future timestamps, or missing/invalid source cadence, +are unknown, not fresh. Missing sources become unavailable. Source measurements +are unavailable when the read failed, stale, or freshness cannot be established. +Freshness is evaluated on existing coordinator notifications, with shared-poll +resolution; no high-frequency timer or per-sensor polling task is added. + +## Plugins + +Existing aggregate counts are preserved. Stable plugin IDs additionally identify +version, runtime state, enabled, running and problem entities. Only ID, name, +version, enabled, state and has_runtime are retained from inventory. Paths, +settings, descriptions, process IDs, logs and repository metadata are excluded. +Disabled and UI-only plugins do not require a running process. Enabled runtime +plugins in FAILED or STOPPED indicate a problem; STARTING and unrecognized states +remain unknown. Missing inventory cannot become a healthy zero. These are all +read-only; no plugin lifecycle, install or settings controls are introduced. + +## Battery and electrical readings + +Battery percentages have battery device class; measured volts/millivolts, +current, power and temperatures have their appropriate classes when units are +known. Generic `voltage_v` and `voltage_mv` retain their supplied units. Numeric +strings are accepted; booleans, NaN and infinity are not numeric measurements. +The signed `solar_charge_rate_percent_per_hour` stays **%/h**, including negative +rates. It is not electric current or power. A separate charge-state trend sensor +uses this signed rate only with a successful, fresh, finite-timestamp reading: +positive means `charging`, negative `discharging`, zero `neutral`. Missing or +nonfinite rates and failed/stale/unknown-age readings yield unknown. This is a +battery trend inference, not a charger-status measurement; zero never means full. +No amps, watts or energy is synthesized. +Existing external-sensor IDs, including the modem compatibility alias, remain. + +## Native update entity + +`Repeater software` uses the already-polled cached `/api/update/status` data. +It never automatically checks external release services, switches channel, or +installs anything. Explicit Home Assistant `update.install` requests call the +existing install API with `force=false`. Version selection and backup requests +are rejected; only a confirmed available update without a status error is +installable. No progress percentage is invented. The existing update actions, +button, channel selector and diagnostic entities remain available. Installation +can restart the Repeater; subsequent ordinary polls confirm the reported version. + +## Alerts and compact view + +Copy the YAML files from `blueprints/automation/openhop/` into Home Assistant's +`config/blueprints/automation/openhop/`, then create automations from them. Choose +the actual entity, sustained duration, thresholds where applicable, and **your own +actions**. No notification destination or corrective action is assumed. + +- `unavailable.yaml`: select a sensor that becomes unavailable with the Repeater. +- `broker_disconnected.yaml`: select the individual broker connectivity entity, + not the aggregate any-broker entity. Disabled brokers may be off too; only + select one you intend to keep connected. +- `low_battery.yaml` / `high_temperature.yaml`: thresholds use the entity's unit. +- `stale_sensor.yaml`: select the new source stale problem sensor. +- `plugin_failure.yaml`: select the per-ID plugin problem sensor. +- `update_available.yaml`: select the native Repeater software update entity. + +State/threshold triggers fire after sustained transitions, not repeatedly for an +existing condition. Home Assistant restart or automation reload resets `for` +timers. Unknown/unavailable values do not satisfy numeric thresholds. + +The separate [compact operations view](../dashboards/openhop_operations.yaml) +uses built-in cards and does not replace the comprehensive dashboard. Paste into +a single view YAML editor, replace `REPEATER_SLUG`, then select exact dynamic +entities for your installation. Radio and plugin names, external sensor slugs and +entity registry suffixes cannot be predicted by replacing the parent prefix. + +## Verification boundary + +Stdlib tests execute normalization and extracted entity/client methods with fake +HA/HTTP boundaries and synthetic schemas. They are not Home Assistant integration +tests. A real HA instance is still required to verify entity registration, update +UI/action execution, blueprint selectors/triggers and rendered Lovelace cards. +No live installation, API write, restart or RF test was performed for this batch. diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py index fbfdaa8..9a71de4 100644 --- a/tests/test_dev_api_alignment.py +++ b/tests/test_dev_api_alignment.py @@ -190,7 +190,9 @@ def test_external_sensor_measurements_normalize_numeric_strings(self) -> None: ] self.assertTrue(helpers, "Missing external sensor value normalizer") helper = helpers[0] - namespace: dict[str, Any] = {"Any": Any} + import runpy + monitoring = runpy.run_path(str(COMPONENT / "monitoring.py")) + namespace: dict[str, Any] = {"Any": Any, "finite_number": monitoring["finite_number"]} exec( compile(ast.Module(body=[helper], type_ignores=[]), "", "exec"), namespace, diff --git a/tests/test_installed_api_behavior.py b/tests/test_installed_api_behavior.py new file mode 100644 index 0000000..35bc79d --- /dev/null +++ b/tests/test_installed_api_behavior.py @@ -0,0 +1,165 @@ +"""Execute actual client methods with stdlib-only fake HTTP boundaries. + +No Home Assistant imports, network, installed source or private fixtures required. +AST extraction removes dependency imports, not the implementation under test. +""" +from __future__ import annotations + +import ast +import asyncio +from pathlib import Path +from typing import Any +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COMPONENT = ROOT / "custom_components/pymc_repeater" + + +def client_namespace(): + tree = ast.parse((COMPONENT / "api.py").read_text()) + classes = [node for node in tree.body if isinstance(node, ast.ClassDef) + and (node.name.startswith("PyMCRepeater"))] + module = ast.Module(body=[ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0), *classes], type_ignores=[]) + namespace = {"Any": Any, "asyncio": asyncio, "DEFAULT_PACKET_WINDOW_HOURS": 24, + "REQUEST_TIMEOUT": 10, "NEIGHBOR_SCOPE_QUERY_TIMEOUT": 50} + exec(compile(ast.fix_missing_locations(module), "api.py", "exec"), namespace) + return namespace + + +class InstalledApiBehaviorTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.ns = client_namespace() + self.client = object.__new__(self.ns["PyMCRepeaterApiClient"]) + self.calls = [] + self.response = {} + + async def request(method, path, **kwargs): + self.calls.append((method, path, kwargs)) + return self.response + + self.client._async_request_json = request + + async def test_plugin_list_is_reduced_to_counts_without_private_metadata(self): + self.assertTrue(hasattr(self.client, "async_get_plugin_summary")) + self.response = {"success": True, "plugins": [ + {"id": "example", "enabled": True, "state": "RUNNING", "data_dir": "PRIVATE", "repository": "PRIVATE"}, + {"enabled": True, "state": "FAILED"}, + {"enabled": False, "state": "DISABLED"}, + {"enabled": True, "state": "STOPPED", "has_runtime": False}, + ]} + result = await self.client.async_get_plugin_summary() + self.assertEqual({key: result[key] for key in ("installed", "enabled", "running", "failed")}, + {"installed": 4, "enabled": 3, "running": 1, "failed": 1}) + self.assertEqual(result["plugins"], [{"id": "example", "enabled": True, "state": "RUNNING"}]) + self.assertNotIn("PRIVATE", repr(result)) + self.assertEqual(self.calls[0][:2], ("GET", "/api/plugins/")) + self.assertEqual(self.calls[0][2]["auth"], "api_token") + + async def test_empty_plugin_list_means_zero_not_unavailable(self): + self.assertTrue(hasattr(self.client, "async_get_plugin_summary")) + self.response = {"success": True, "plugins": []} + self.assertEqual(await self.client.async_get_plugin_summary(), + dict(installed=0, enabled=0, running=0, failed=0, plugins=[])) + + async def test_malformed_plugin_list_is_not_reported_as_zero(self): + self.assertTrue(hasattr(self.client, "async_get_plugin_summary")) + for payload in ({}, {"plugins": None}, {"plugins": {}}, {"plugins": [None]}, + {"plugins": [{"enabled": "false", "state": "RUNNING"}]}): + self.response = payload + with self.assertRaises(self.ns["PyMCRepeaterApiError"]): + await self.client.async_get_plugin_summary() + + async def test_plugin_api_failure_is_not_a_successful_empty_inventory(self): + self.assertTrue(hasattr(self.client, "async_get_plugin_summary")) + self.response = {"success": False, "error": "Plugin manager unavailable"} + with self.assertRaises(self.ns["PyMCRepeaterApiError"]): + await self.client.async_get_plugin_summary() + + async def test_bucket_history_preserves_bucket_response_and_query(self): + import inspect + self.assertIn("bucket_seconds", inspect.signature(self.client.async_get_neighbor_link_history).parameters) + self.response = {"success": True, "data": {"count": 1, "bucket_seconds": 300, "buckets": [{"count": 2}]}} + result = await self.client.async_get_neighbor_link_history(peer_hash="AB", path_hash_size=1, bucket_seconds=300) + self.assertEqual(result, self.response["data"]) + self.assertEqual(self.calls[0][2]["params"], dict(peer_hash="AB", path_hash_size=1, hours=24, limit=1000, bucket_seconds=300)) + + async def test_legacy_history_omits_bucket_parameter(self): + self.response = {"success": True, "data": {"count": 0, "rows": []}} + self.assertEqual(await self.client.async_get_neighbor_link_history(peer_hash="AB", path_hash_size=1), self.response["data"]) + self.assertNotIn("bucket_seconds", self.calls[0][2]["params"]) + + async def test_polling_is_optional_for_plugin_api_errors_but_not_auth(self): + self.assertTrue(hasattr(self.client, "async_get_plugin_summary")) + async def empty(*args, **kwargs): + return {} + for name in dir(self.client): + if name.startswith("async_get_"): + setattr(self.client, name, empty) + for error_name in ("PyMCRepeaterApiError", "PyMCRepeaterAuthenticationError", "PyMCRepeaterCannotConnect"): + async def fail(): + raise self.ns[error_name]("test failure") + self.client.async_get_plugin_summary = fail + if error_name == "PyMCRepeaterApiError": + result = await self.client.async_fetch_all() + self.assertEqual(result["plugin_summary"], {"error": "test failure"}) + self.assertEqual(result["stats"], {}) + else: + with self.assertRaises(self.ns[error_name]): + await self.client.async_fetch_all() + + def test_new_sensor_values_and_missing_data(self): + tree = ast.parse((COMPONENT / "sensor.py").read_text()) + expected = {f"plugins_{key}": key for key in ("installed", "enabled", "running", "failed")} + found = {} + nested = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "_nested") + ns = {"Any": Any} + exec(compile(ast.Module(body=[nested], type_ignores=[]), "sensor.py", "exec"), ns) + for call in ast.walk(tree): + if not isinstance(call, ast.Call): + continue + fields = {k.arg: k.value for k in call.keywords} + key = fields.get("key") + if isinstance(key, ast.Constant) and key.value in expected: + # Execute only a lambda from our checked-in trusted source, never API data. + fn = eval(compile(ast.Expression(fields["value_fn"]), "sensor.py", "eval"), ns) + found[key.value] = fn + self.assertEqual(set(found), set(expected)) + for name, fn in found.items(): + self.assertEqual(fn({"plugin_summary": {expected[name]: 2}}), 2) + self.assertIsNone(fn({"plugin_summary": {"error": "unavailable"}})) + self.assertIsNone(fn({})) + + def test_metadata_matches_new_entities_and_history_bounds(self): + import json + translations = json.loads((COMPONENT / "translations/en.json").read_text())["entity"]["sensor"] + dashboard = (ROOT / "dashboards/openhop_repeater_dashboard.yaml").read_text() + for key, label in (("installed", "Installed"), ("enabled", "Enabled"), + ("running", "Running"), ("failed", "Failed")): + self.assertEqual(translations[f"plugins_{key}"]["name"], f"{label} plugins") + self.assertIn(f"sensor.REPEATER_SLUG_{key}_plugins", dashboard) + tree = ast.parse((COMPONENT / "__init__.py").read_text()) + bucket_schema = None + for node in ast.walk(tree): + if isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values): + if isinstance(key, ast.Call) and key.args and isinstance(key.args[0], ast.Constant) and key.args[0].value == "bucket_seconds": + bucket_schema = ast.unparse(value) + self.assertFalse(key.keywords, "Bucket mode must stay opt-in without a default") + self.assertEqual(bucket_schema, "vol.All(vol.Coerce(int), vol.Range(min=60))") + services = (COMPONENT / "services.yaml").read_text() + history = services.split("get_neighbor_link_history:", 1)[1].split("get_adverts_by_contact_type:", 1)[0] + self.assertIn("bucket_seconds:", history) + self.assertIn("min: 60", history) + + def test_history_service_bucket_wiring_and_no_history_polling(self): + source = (COMPONENT / "__init__.py").read_text() + self.assertIn('bucket_seconds=call.data.get("bucket_seconds")', source) + self.assertIn('vol.Optional("bucket_seconds")', source) + client = ast.parse((COMPONENT / "api.py").read_text()) + fetch = next(n for n in ast.walk(client) if isinstance(n, ast.AsyncFunctionDef) and n.name == "async_fetch_all") + self.assertNotIn("async_get_neighbor_link_history", ast.unparse(fetch)) + self.assertNotIn("progress", ast.unparse(fetch)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_monitoring.py b/tests/test_monitoring.py new file mode 100644 index 0000000..c1de40d --- /dev/null +++ b/tests/test_monitoring.py @@ -0,0 +1,98 @@ +"""Executable pure monitoring contracts; no Home Assistant or network.""" +import importlib.util +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COMPONENT = ROOT / 'custom_components/pymc_repeater' + + +class MonitoringTests(unittest.TestCase): + def setUp(self): + path = COMPONENT / 'monitoring.py' + self.assertTrue(path.exists(), 'monitoring normalization is not implemented') + spec = importlib.util.spec_from_file_location('monitoring', path) + self.m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.m) + + def test_freshness_unknown_stale_future_and_failure(self): + for timestamp in (None, 'invalid', True, 1100): + self.assertIsNone(self.m.reading_age({'timestamp': timestamp}, now=1000)) + self.assertEqual(self.m.reading_age({'timestamp': 900}, now=1000), 100) + self.assertTrue(self.m.reading_stale({'timestamp': 900}, 15, now=1000)) + self.assertFalse(self.m.reading_stale({'timestamp': 990}, 15, now=1000)) + self.assertIsNone(self.m.reading_stale({}, 15, now=1000)) + self.assertFalse(self.m.reading_usable({'ok': False, 'timestamp': 990}, 15, now=1000)) + self.assertFalse(self.m.reading_usable({'ok': True}, 15, now=1000)) + + def test_radio_inventory_does_not_allocate_aggregate_metrics(self): + data = {'stats': {'radio_stack': {'radio_ids': ['a', 'b']}, 'radio_status': 'ok', + 'packets_received': 88, 'radios': [{'id': 'a', 'type': 'modem_tcp', 'radio': {'frequency': 915}}]}} + radios = self.m.radio_inventory(data) + self.assertEqual(set(radios), {'a', 'b'}) + self.assertNotIn('packets_received', radios['a']) + self.assertNotIn('status', radios['b']) + self.assertEqual(radios['a']['type'], 'modem_tcp') + self.assertEqual(self.m.radio_inventory({'stats': {'radios': []}}), {}) + + def test_radio_fields_and_single_default_fallback(self): + fields = {'frequency': 915000000, 'bandwidth': 125000, 'tx_power': 20, + 'spreading_factor': 7, 'coding_rate': 5, 'preamble_length': 8} + data = {'stats': {'radio_stack': {'mode': 'single', 'radio_ids': ['a'], 'default_radio': 'a'}, 'radios': []}, + 'config': {'radio': dict(fields, secret='excluded')}} + radio = self.m.radio_inventory(data)['a'] + for key, value in fields.items(): + self.assertEqual(radio[key], value) + self.assertNotIn('secret', radio) + data['stats']['radio_stack']['mode'] = 'multi' + self.assertNotIn('frequency', self.m.radio_inventory(data)['a']) + data['stats']['radios'] = [{'id': 'a', 'radio': fields}] + self.assertEqual(self.m.radio_inventory(data)['a']['frequency'], 915000000) + + def test_charge_state_requires_fresh_finite_signed_rate(self): + reading = {'ok': True, 'timestamp': 990, 'data': {}} + for rate, expected in [(1, 'charging'), (-0.416, 'discharging'), (0, 'neutral'), ('nan', None), (True, None)]: + reading['data']['solar_charge_rate_percent_per_hour'] = rate + self.assertEqual(self.m.charge_state(reading, 15, now=1000), expected) + reading['data']['solar_charge_rate_percent_per_hour'] = 1 + for stamp in (1, None, 'invalid', 'inf', 1100): + reading['timestamp'] = stamp + self.assertIsNone(self.m.charge_state(reading, 15, now=1000)) + reading.update(timestamp=990, ok=False) + self.assertIsNone(self.m.charge_state(reading, 15, now=1000)) + + def test_plugin_problem_requires_runtime_and_explicit_evidence(self): + self.assertFalse(self.m.plugin_problem({'enabled': True, 'has_runtime': False, 'state': 'STOPPED'})) + self.assertTrue(self.m.plugin_problem({'enabled': True, 'has_runtime': True, 'state': 'FAILED'})) + self.assertFalse(self.m.plugin_problem({'enabled': False, 'state': 'FAILED'})) + self.assertIsNone(self.m.plugin_problem({})) + self.assertIsNone(self.m.plugin_problem({'enabled': True, 'has_runtime': True, 'state': 'STARTING'})) + + def test_battery_semantics_and_finite_numeric_values(self): + self.assertEqual(self.m.measurement_class('battery_percent'), 'battery') + self.assertEqual(self.m.measurement_class('voltage_v'), 'voltage') + self.assertEqual(self.m.measurement_class('die_temperature_c'), 'temperature') + self.assertIsNone(self.m.measurement_class('solar_charge_rate_percent_per_hour')) + self.assertEqual(self.m.finite_number('-0.416'), -0.416) + for value in ('nan', 'inf', True, None): + self.assertIsNone(self.m.finite_number(value)) + + def test_update_platform_is_explicit_install_only(self): + path = COMPONENT / 'update.py' + self.assertTrue(path.exists(), 'native update platform missing') + source = path.read_text() + self.assertIn('async_update_install(force=False)', source) + self.assertNotIn('async_update_check(', source) + self.assertNotIn('async_update_set_channel(', source) + self.assertNotIn('PROGRESS', source) + self.assertIn('Platform.UPDATE', (COMPONENT / '__init__.py').read_text()) + + def test_blueprints_and_compact_view(self): + for name in ('unavailable', 'broker_disconnected', 'low_battery', 'high_temperature', 'stale_sensor', 'plugin_failure', 'update_available'): + path = ROOT / 'blueprints/automation/openhop' / (name + '.yaml') + self.assertTrue(path.exists(), name) + source = path.read_text() + self.assertIn('!input actions', source) + self.assertIn('for:', source) + self.assertNotIn('notify.', source) + self.assertTrue((ROOT / 'dashboards/openhop_operations.yaml').exists()) diff --git a/tests/test_runtime_monitoring.py b/tests/test_runtime_monitoring.py new file mode 100644 index 0000000..6296db3 --- /dev/null +++ b/tests/test_runtime_monitoring.py @@ -0,0 +1,139 @@ +"""Execute checked-in entity code with minimal HA boundaries, not a HA harness.""" +import ast +import asyncio +from pathlib import Path +from types import SimpleNamespace +import unittest +import runpy + +ROOT = Path(__file__).resolve().parents[1] +COMPONENT = ROOT / 'custom_components/pymc_repeater' + + +def extracted(filename, namespace): + tree = ast.parse((COMPONENT / filename).read_text()) + nodes = [n for n in tree.body if not isinstance(n, (ast.Import, ast.ImportFrom))] + exec(compile(ast.fix_missing_locations(ast.Module(body=[ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0), *nodes], type_ignores=[])), filename, 'exec'), namespace) + return namespace + + +class Base: + def __init__(self, entry, coordinator): + self._entry, self.coordinator = entry, coordinator + + @property + def available(self): + return self.coordinator.last_update_success + + @property + def device_info(self): + return {'identifiers': {('pymc_repeater', 'entry')}} + + +class RuntimeMonitoringTests(unittest.TestCase): + def test_update_readback_versions_and_install_guards(self): + ns = extracted('update.py', dict(PyMCBaseEntity=Base, UpdateEntity=type('Update', (), {}), + UpdateEntityFeature=SimpleNamespace(INSTALL=1), HomeAssistantError=RuntimeError, + PyMCRepeaterApiError=ValueError, DOMAIN='pymc_repeater')) + calls = [] + async def install(**kwargs): + calls.append(kwargs) + coordinator = SimpleNamespace(last_update_success=True, data={'update_status': { + 'current_version': '1.0', 'latest_version': '1.1', 'has_update': True, + 'channel': 'dev', 'error': None}}, api=SimpleNamespace(async_update_install=install)) + entity = ns['PyMCUpdateEntity'](SimpleNamespace(unique_id='entry', entry_id='fallback'), coordinator) + self.assertTrue(entity.available) + self.assertEqual(entity.latest_version, '1.1') + self.assertEqual(calls, []) + asyncio.run(entity.async_install(None, False)) + self.assertEqual(calls, [{'force': False}]) + for version, backup in [('1.2', False), (None, True)]: + with self.assertRaises(RuntimeError): + asyncio.run(entity.async_install(version, backup)) + coordinator.data['update_status']['error'] = 'check failed' + self.assertFalse(entity.available, 'errored cached release must not enable install') + coordinator.data['update_status'] = {'current_version': '1.0', 'has_update': False} + self.assertEqual(entity.latest_version, '1.0') + with self.assertRaises(RuntimeError): + asyncio.run(entity.async_install(None, False)) + coordinator.data = {} + self.assertFalse(entity.available) + + def test_existing_measurement_becomes_unavailable_when_stale(self): + helpers = runpy.run_path(str(COMPONENT / 'monitoring.py')) + tree = ast.parse((COMPONENT / 'sensor.py').read_text()) + wanted = {'PyMCExternalSensorMetricSensor', '_external_sensor_readings', + '_external_sensor_identity', '_external_sensor_payload', + '_normalize_external_sensor_value', '_nested'} + nodes = [node for node in tree.body if getattr(node, 'name', None) in wanted] + ns = dict(helpers, PyMCBaseEntity=Base, SensorEntity=type('Sensor', (), {}), + EntityCategory=SimpleNamespace(DIAGNOSTIC='diagnostic'), + SensorStateClass=SimpleNamespace(MEASUREMENT='measurement'), + PERCENTAGE='%', UnitOfTime=SimpleNamespace(MINUTES='min'), + slugify=lambda text: text.lower().replace(' ', '_')) + nodes.insert(0, ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)) + exec(compile(ast.fix_missing_locations(ast.Module(body=nodes, type_ignores=[])), 'sensor.py', 'exec'), ns) + reading = {'name': 'modem', 'type': 'openhop_modem', 'ok': True, + 'timestamp': helpers['time'](), 'data': {'battery_percent': 90}} + coordinator = SimpleNamespace(last_update_success=True, data={ + 'stats': {'sensors': {'readings': [reading], 'poll_interval_seconds': 15}}}) + entry = SimpleNamespace(unique_id='entry', entry_id='fallback') + entity = ns['PyMCExternalSensorMetricSensor'](entry, coordinator, reading, 'battery_percent') + self.assertTrue(entity.available) + self.assertEqual(entity.native_value, 90) + self.assertEqual(entity._attr_device_class, 'battery') + self.assertIn('pymc_modem', entity._attr_unique_id) + reading['timestamp'] = 1 + self.assertFalse(entity.available) + reading['timestamp'] = helpers['time']() + reading['ok'] = False + self.assertFalse(entity.available) + reading['ok'] = True + reading['data']['battery_percent'] = float('nan') + self.assertFalse(entity.available) + + def test_snapshot_discovery_late_sources_removal_and_stable_ids(self): + import json + helpers = runpy.run_path(str(COMPONENT / 'monitoring.py')) + def nested(data, *keys): + for key in keys: + data = data.get(key) if isinstance(data, dict) else None + return data + ns = dict(helpers, json=json, PyMCBaseEntity=Base, SensorEntity=type('Sensor', (), {}), + BinarySensorEntity=type('Binary', (), {}), EntityCategory=SimpleNamespace(DIAGNOSTIC='diagnostic'), + SensorDeviceClass=SimpleNamespace(DURATION='duration', TIMESTAMP='timestamp'), + SensorStateClass=SimpleNamespace(MEASUREMENT='measurement'), + BinarySensorDeviceClass=SimpleNamespace(PROBLEM='problem', CONNECTIVITY='connectivity'), + DeviceInfo=dict, DOMAIN='pymc_repeater', MANUFACTURER='openHop', _nested=nested, + _external_sensor_readings=lambda data: nested(data, 'stats', 'sensors', 'readings') or [], + _external_sensor_identity=lambda reading: reading['name']) + ns = extracted('monitoring_entities.py', ns) + listeners, unload, entities = [], [], [] + coordinator = SimpleNamespace(data={}, last_update_success=True, async_add_listener=lambda cb: listeners.append(cb)) + entry = SimpleNamespace(unique_id='entry', entry_id='fallback', async_on_unload=unload.append) + ns['setup_monitoring_sensors'](entry, coordinator, entities.extend) + coordinator.data = {'stats': {'radio_stack': {'radio_ids': ['a', 'a_b']}}} + listeners[0]() + count = len(entities) + listeners[0]() + self.assertEqual(len(entities), count) + radios = [e for e in entities if e._key[0] == 'radio'] + self.assertEqual(len(radios), 2) + self.assertNotEqual(radios[0]._attr_unique_id, radios[1]._attr_unique_id) + self.assertEqual(radios[0].device_info['via_device'], ('pymc_repeater', 'entry')) + coordinator.data['stats'].update(sensors={'poll_interval_seconds': 15, 'readings': [ + {'name': 'modem', 'ok': True, 'timestamp': helpers['time'](), + 'data': {'solar_charge_rate_percent_per_hour': -0.416}}]}) + listeners[0]() + charge = next(e for e in entities if e._key[2] == 'charge_state') + self.assertEqual(charge.native_value, 'discharging') + coordinator.data['stats']['sensors']['readings'][0]['timestamp'] = 1 + self.assertIsNone(charge.native_value) + coordinator.data = {} + self.assertFalse(charge.available) + self.assertFalse(radios[0].available) + self.assertEqual(len(unload), 1) + coordinator.last_update_success = False + binary = ns['_snapshot'](coordinator, True) + self.assertFalse(binary[('component', 'api', 'connected')]) + self.assertIsNone(binary[('component', 'gps', 'problem')]) From 4e684f4600a04fb7200b66ba7600eda723d59d27 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:34:25 -0400 Subject: [PATCH 04/12] fix: discover single-radio settings from stats configuration --- CHANGELOG.md | 2 ++ custom_components/pymc_repeater/monitoring.py | 2 +- tests/test_monitoring.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcdeb3..66a932a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- fixed single-radio settings discovery to read the actual nested stats configuration, restoring frequency, bandwidth, TX power, spreading factor, coding rate, and preamble entities + - added stable radio child inventory and supported read-only radio settings without allocating aggregate RF metrics; the installed API does not establish genuine per-radio telemetry - derive charging/discharging/neutral battery trend only from fresh finite signed solar charge rate; zero does not claim a full battery - added shared-poll component diagnostics, last successful poll, source age/stale sensors and conservative measurement availability diff --git a/custom_components/pymc_repeater/monitoring.py b/custom_components/pymc_repeater/monitoring.py index e4d5b36..72ce719 100644 --- a/custom_components/pymc_repeater/monitoring.py +++ b/custom_components/pymc_repeater/monitoring.py @@ -109,7 +109,7 @@ def radio_inventory(data: dict) -> dict[str, dict]: # Legacy single-radio configuration is global, but belongs only to the # explicitly named default. Never copy global configuration across a fabric. default = stack.get("default_radio") - config = data.get("config") + config = stats.get("config") if (stack.get("mode") == "single" and len(result) == 1 and isinstance(default, str) and default in result and isinstance(config, dict) and not config.get("error")): settings = config.get("radio") diff --git a/tests/test_monitoring.py b/tests/test_monitoring.py index c1de40d..697e03f 100644 --- a/tests/test_monitoring.py +++ b/tests/test_monitoring.py @@ -38,8 +38,8 @@ def test_radio_inventory_does_not_allocate_aggregate_metrics(self): def test_radio_fields_and_single_default_fallback(self): fields = {'frequency': 915000000, 'bandwidth': 125000, 'tx_power': 20, 'spreading_factor': 7, 'coding_rate': 5, 'preamble_length': 8} - data = {'stats': {'radio_stack': {'mode': 'single', 'radio_ids': ['a'], 'default_radio': 'a'}, 'radios': []}, - 'config': {'radio': dict(fields, secret='excluded')}} + data = {'stats': {'radio_stack': {'mode': 'single', 'radio_ids': ['a'], 'default_radio': 'a'}, 'radios': [], + 'config': {'radio': dict(fields, secret='excluded')}}} radio = self.m.radio_inventory(data)['a'] for key, value in fields.items(): self.assertEqual(radio[key], value) From 062cca731dd38ed7d8b84d45a83e270d46d864b7 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:36:57 -0400 Subject: [PATCH 05/12] feat(dashboard): prioritize overview and compact repeater diagnostics --- CHANGELOG.md | 6 + README.md | 2 +- dashboards/openhop_repeater_dashboard.yaml | 1354 +++++++++++++------- tests/test_comprehensive_dashboard.py | 49 + tests/test_dev_api_alignment.py | 13 +- 5 files changed, 929 insertions(+), 495 deletions(-) create mode 100644 tests/test_comprehensive_dashboard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a932a..ba915fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- corrected comprehensive dashboard density with natural-height vertical-stack columns, compact update management, six primary overview metrics plus optional battery/temperature, named badges, and shorter radio/source/plugin labels; retained entity references and ordered diagnostic bands + +- reorganized the comprehensive view into responsive ordered Sections bands: live metrics and short single-unit trends first, operations next, complete grouped diagnostics and notes last; shortened display-only labels without renaming entities or removing controls + +- refreshed the comprehensive built-in dashboard with component problems, source freshness, radio child settings, individual plugin rows, battery charge trend and native update more-info, with explicit same-Repeater dynamic entity replacement guidance + - fixed single-radio settings discovery to read the actual nested stats configuration, restoring frequency, bandwidth, TX power, spreading factor, coding rate, and preamble entities - added stable radio child inventory and supported read-only radio settings without allocating aggregate RF metrics; the installed API does not establish genuine per-radio telemetry diff --git a/README.md b/README.md index e5f0da8..6c07583 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ To use it: 7. The modem percentage card automatically finds active battery and solar-rate entities even when Home Assistant adds an area prefix or numeric suffix. 8. For other dynamic rows, replace the complete example entity ID with the active entity ID shown in your instance. If the external sensor is not named `modem`, also update `_sensor_modem_` in the percentage card's two match strings. -The dashboard uses only built-in Home Assistant cards. +The dashboard uses only built-in Home Assistant cards and the current Sections frontend (configuration saved and read back on HA 2026.9; rendered layout still needs browser verification). Full-width ordered bands put Live overview first, then short single-unit Recent trends, Operations, grouped Diagnostics, and Reference notes last. Three desktop columns collapse with the native responsive grid; dense placement is disabled so diagnostic cards cannot jump above the essentials. Each detail column is a native vertical stack in its own single-column section: short cards keep their natural height instead of stretching to the tallest neighboring card. Smaller cards share columns to reduce empty rows. The overview retains six key metrics plus optional battery/temperature; secondary metrics remain in diagnostics. Badges explicitly show their names, GPS stays in diagnostics, and the software update tile shares a compact column with update management. Numeric precision is left to Home Assistant; no global entity-registry settings are changed. Compact metric tiles replace duplicate gauges; all detailed readings and controls remain below. Alert banners cover explicit API/statistics/hardware failures, not disabled GPS. This view layout requires a newer frontend than the integration's minimum HA version. The comprehensive view also includes component endpoint problems, last successful poll, source age/stale, radio child inventory/settings, per-plugin status, fresh battery charge trend, and a native update tile (opens more-info; it does not install automatically). Replace the new dynamic example rows with exact entity IDs from the same Repeater config entry, including its radio child devices; repeat rows for multiple sources and remove unsupported rows/cards. Radio device prefixes are independent of `REPEATER_SLUG`. Battery trend is charging/discharging/neutral from fresh signed %/h, never an inferred full-battery indication. When replacing an existing view, retain its title, path, icon and other view metadata, and back up the complete dashboard first. ## Actions diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml index ec424e5..bee972a 100644 --- a/dashboards/openhop_repeater_dashboard.yaml +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -16,106 +16,368 @@ # `_sensor_modem_` in its two template match strings with the actual sensor slug. # # This template uses only built-in Home Assistant cards. - +# Replace ALL dynamic example rows below with exact entity IDs from this +# Repeater's device/child devices. Radio children have independent prefixes. +# Remove unsupported rows/cards; never borrow another Repeater's readings. +# Plugin-manager counts are unknown on older builds or if the manager is down. +# Replace these example rows with broker entities from your installation. +# Replace these example rows with companion entities from your installation. +# Ordered full-width bands require a current Home Assistant Sections frontend. title: Example openHop Repeater path: example-openhop-repeater icon: mdi:radio-tower -type: masonry +type: sections badges: - - entity: binary_sensor.REPEATER_SLUG_mqtt_broker_connected - name: MQTT - - entity: binary_sensor.REPEATER_SLUG_mqtt_handler_active - name: MQTT Handler - - entity: binary_sensor.REPEATER_SLUG_gps_fix_valid - name: GPS Fix - - entity: binary_sensor.REPEATER_SLUG_update_available - name: Update - - entity: sensor.REPEATER_SLUG_repeater_version - name: Version - - entity: select.REPEATER_SLUG_repeater_mode - name: Mode -cards: - - type: grid - title: Repeater Overview - columns: 3 - square: false +- entity: binary_sensor.REPEATER_SLUG_mqtt_broker_connected + name: MQTT + show_name: true +- type: entity + entity: binary_sensor.REPEATER_SLUG_component_api_connected + name: API + show_name: true +- entity: binary_sensor.REPEATER_SLUG_mqtt_handler_active + name: MQTT handler + show_name: true +- entity: binary_sensor.REPEATER_SLUG_update_available + name: Update + show_name: true +- entity: sensor.REPEATER_SLUG_repeater_version + name: Firmware + show_name: true +- entity: select.REPEATER_SLUG_repeater_mode + name: Mode + show_name: true +show_icon_and_title: true +max_columns: 3 +dense_section_placement: false +sections: +- type: grid + column_span: 3 + cards: + - type: heading + heading: Live overview + heading_style: title + grid_options: + columns: full + rows: 1 + - type: conditional + conditions: + - condition: state + entity: binary_sensor.REPEATER_SLUG_component_api_connected + state: 'off' + card: + type: markdown + content: '**Attention:** Repeater API disconnected. See diagnostics below.' + grid_options: + columns: full + rows: auto + - type: conditional + conditions: + - condition: state + entity: binary_sensor.REPEATER_SLUG_component_stats_problem + state: 'on' + card: + type: markdown + content: '**Attention:** Packet statistics unavailable. See diagnostics below.' + grid_options: + columns: full + rows: auto + - type: conditional + conditions: + - condition: state + entity: binary_sensor.REPEATER_SLUG_component_hardware_stats_problem + state: 'on' + card: + type: markdown + content: '**Attention:** Hardware statistics unavailable. See diagnostics below.' + grid_options: + columns: full + rows: auto + - type: tile + entity: sensor.REPEATER_SLUG_packets_received_per_hour + name: Received / h + tap_action: + action: more-info + grid_options: + columns: 6 + rows: 1 + - type: tile + entity: sensor.REPEATER_SLUG_packets_forwarded_per_hour + name: Forwarded / h + tap_action: + action: more-info + grid_options: + columns: 6 + rows: 1 + - type: tile + entity: sensor.REPEATER_SLUG_packet_drop_rate_24h + name: Drops · 24 h + tap_action: + action: more-info + grid_options: + columns: 6 + rows: 1 + - type: tile + entity: sensor.REPEATER_SLUG_current_noise_floor + name: Noise floor + tap_action: + action: more-info + grid_options: + columns: 6 + rows: 1 + - type: tile + entity: sensor.REPEATER_SLUG_radio_utilization + name: Radio use + tap_action: + action: more-info + grid_options: + columns: 6 + rows: 1 + - type: tile + entity: sensor.REPEATER_SLUG_cpu_usage + name: CPU use + tap_action: + action: more-info + grid_options: + columns: 6 + rows: 1 + - type: tile + entity: sensor.REPEATER_SLUG_sensor_modem_die_temperature_c + name: Modem temperature + tap_action: + action: more-info + grid_options: + columns: 6 + rows: 1 + - type: vertical-stack cards: - - type: entity - entity: sensor.REPEATER_SLUG_current_airtime - name: Current Airtime - icon: mdi:radio-tower - - type: entity - entity: sensor.REPEATER_SLUG_radio_utilization - name: Radio Utilization - icon: mdi:access-point-network - - type: entity - entity: sensor.REPEATER_SLUG_current_noise_floor - name: Noise Floor - icon: mdi:waves - - type: entity - entity: sensor.REPEATER_SLUG_packets_24h - name: Packets 24h - icon: mdi:package-variant - - type: entity - entity: sensor.REPEATER_SLUG_packet_drop_rate_24h - name: Drop Rate 24h - icon: mdi:packet-filter - - type: entity - entity: sensor.REPEATER_SLUG_average_score_24h - name: Avg Score 24h - icon: mdi:signal + - type: markdown + title: External Modem Percentages + card_size: 2 + content: | + {% set ns = namespace(battery=none, battery_unit='%', solar=none, solar_unit='%/h') %} + {% for item in states.sensor %} + {% if 'REPEATER_SLUG_sensor_modem_battery_percent' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} + {% set ns.battery = item.state %} + {% set ns.battery_unit = item.attributes.get('unit_of_measurement', '%') %} + {% endif %} + {% if 'REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} + {% set ns.solar = item.state %} + {% set ns.solar_unit = item.attributes.get('unit_of_measurement', '%/h') %} + {% endif %} + {% endfor %} + **Battery:** {% if ns.battery is not none %}{{ ns.battery }} {{ ns.battery_unit }}{% else %}Not reported{% endif %} - - type: horizontal-stack + **Solar charge rate:** {% if ns.solar is not none %}{{ ns.solar }} {{ ns.solar_unit }}{% else %}Not reported{% endif %} + grid_options: + columns: 12 + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Recent trends + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack cards: - - type: gauge - entity: sensor.REPEATER_SLUG_cpu_usage - name: CPU Use - min: 0 - max: 100 - severity: - green: 0 - yellow: 70 - red: 90 - - type: gauge - entity: sensor.REPEATER_SLUG_radio_utilization - name: Radio Use - min: 0 - max: 100 - severity: - green: 0 - yellow: 60 - red: 85 - - type: gauge - entity: sensor.REPEATER_SLUG_packet_drop_rate_24h - name: Drops - min: 0 - max: 100 - severity: - green: 0 - yellow: 5 - red: 15 - - - type: history-graph - title: Radio Health - hours_to_show: 24 - refresh_interval: 60 - entities: - - entity: sensor.REPEATER_SLUG_current_airtime - name: Airtime - - entity: sensor.REPEATER_SLUG_radio_utilization - name: Utilization - - entity: sensor.REPEATER_SLUG_current_noise_floor - name: Noise Floor - - entity: sensor.REPEATER_SLUG_average_rssi_24h - name: Avg RSSI - - entity: sensor.REPEATER_SLUG_average_snr_24h - name: Avg SNR - - - type: entities - title: System Health - state_color: true - show_header_toggle: false - entities: + - type: sensor + entity: sensor.REPEATER_SLUG_packets_received_per_hour + name: Received / h · 6 h + graph: line + hours_to_show: 6 + detail: 1 + height: 100 + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: sensor + entity: sensor.REPEATER_SLUG_packets_forwarded_per_hour + name: Forwarded / h · 6 h + graph: line + hours_to_show: 6 + detail: 1 + height: 100 + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: sensor + entity: sensor.REPEATER_SLUG_current_noise_floor + name: Noise floor · 6 h + graph: line + hours_to_show: 6 + detail: 1 + height: 100 + grid_options: + columns: full + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Operations + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: tile + entity: update.REPEATER_SLUG_repeater_software + name: Software update + tap_action: + action: more-info + - type: entities + title: Update Management + state_color: true + show_header_toggle: false + entities: + - entity: binary_sensor.REPEATER_SLUG_update_available + name: Update Available + icon: mdi:update + - entity: sensor.REPEATER_SLUG_repeater_version + name: Installed Version + icon: mdi:tag + - entity: sensor.REPEATER_SLUG_update_latest_version + name: Latest Version + icon: mdi:tag-plus + - entity: sensor.REPEATER_SLUG_update_state + name: Update State + icon: mdi:progress-download + - entity: sensor.REPEATER_SLUG_update_channel + name: Current Update Channel + icon: mdi:source-branch + - entity: select.REPEATER_SLUG_update_channel + name: Set Update Channel + icon: mdi:source-branch + - entity: button.REPEATER_SLUG_check_for_updates + name: Check For Updates + icon: mdi:cloud-search + - entity: button.REPEATER_SLUG_install_latest_update + name: Install Latest Update + icon: mdi:download-circle + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Repeater Controls + state_color: true + show_header_toggle: false + entities: + - entity: select.REPEATER_SLUG_repeater_mode + name: Repeater Mode + icon: mdi:tune + - entity: sensor.REPEATER_SLUG_default_region + name: Current Default Region + icon: mdi:earth + - entity: select.REPEATER_SLUG_default_region + name: Set Default Region + icon: mdi:earth-arrow-right + - entity: switch.REPEATER_SLUG_duty_cycle_enforcement + name: Duty Cycle Enforcement + icon: mdi:shield-check + - entity: number.REPEATER_SLUG_max_airtime_percent + name: Max Airtime % + icon: mdi:percent + - entity: button.REPEATER_SLUG_send_advert + name: Send Flood Advert + icon: mdi:bullhorn + - entity: button.REPEATER_SLUG_send_direct_advert + name: Send Direct Advert + icon: mdi:bullhorn-outline + - entity: number.REPEATER_SLUG_flood_advert_interval + name: Flood Advert Interval + icon: mdi:timer-sync-outline + - entity: number.REPEATER_SLUG_direct_advert_interval + name: Direct Advert Interval + icon: mdi:timer-outline + - entity: button.REPEATER_SLUG_restart_repeater_service + name: Restart Repeater Service + icon: mdi:restart-alert + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Routing + state_color: true + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_route_direct + name: Direct Routes + icon: mdi:routes + - entity: sensor.REPEATER_SLUG_route_flood + name: Flood Routes + icon: mdi:waves-arrow-right + - entity: switch.REPEATER_SLUG_allow_unscoped_flood + name: Allow Unscoped Flood + icon: mdi:flood + - type: entities + title: Neighbor Links + state_color: true + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_observed_neighbor_links + name: Observed Neighbor Links + icon: mdi:access-point-network + - entity: sensor.REPEATER_SLUG_active_neighbor_links + name: Active Neighbor Links + icon: mdi:access-point-network-off + - entity: sensor.REPEATER_SLUG_mqtt_neighbors_phase + name: MQTT Neighbors Phase + icon: mdi:access-point-network + - entity: button.REPEATER_SLUG_publish_mqtt_neighbors + name: Publish MQTT Neighbors + icon: mdi:cloud-upload-outline + grid_options: + columns: full + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Diagnostics · system & radio + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: System Health + state_color: true + show_header_toggle: false + entities: - entity: binary_sensor.REPEATER_SLUG_sensor_system_health_ok name: System Health OK icon: mdi:heart-pulse @@ -150,29 +412,94 @@ cards: - entity: sensor.REPEATER_SLUG_external_sensor_readings_ok name: External Sensor Readings OK icon: mdi:check-network-outline - - - type: entities - title: Network Traffic - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_network_bytes_received - name: Bytes Received - icon: mdi:download-network-outline - - entity: sensor.REPEATER_SLUG_network_bytes_sent - name: Bytes Sent - icon: mdi:upload-network-outline - - entity: sensor.REPEATER_SLUG_network_packets_received - name: Network Packets Received - icon: mdi:download-network - - entity: sensor.REPEATER_SLUG_network_packets_sent - name: Network Packets Sent - icon: mdi:upload-network - - - type: entities - title: Packet Flow - state_color: true - show_header_toggle: false - entities: + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Component Health + show_header_toggle: false + entities: + - entity: binary_sensor.REPEATER_SLUG_component_api_connected + name: API connected + - entity: sensor.REPEATER_SLUG_component_api_last_success + name: API last success + - entity: binary_sensor.REPEATER_SLUG_component_stats_problem + name: Stats problem + - entity: binary_sensor.REPEATER_SLUG_component_mqtt_status_problem + name: MQTT status problem + - entity: binary_sensor.REPEATER_SLUG_component_hardware_stats_problem + name: Hardware problem + - entity: binary_sensor.REPEATER_SLUG_component_gps_problem + name: GPS problem + - entity: binary_sensor.REPEATER_SLUG_component_plugin_summary_problem + name: Plugin summary problem + - entity: binary_sensor.REPEATER_SLUG_component_update_status_problem + name: Update status problem + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Radio Inventory and Settings + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_radio_example_inventory + name: Inventory + - entity: sensor.REPEATER_SLUG_radio_example_frequency + name: Frequency + - entity: sensor.REPEATER_SLUG_radio_example_tx_power + name: Tx power + - entity: sensor.REPEATER_SLUG_radio_example_bandwidth + name: Bandwidth + - entity: sensor.REPEATER_SLUG_radio_example_spreading_factor + name: Spreading factor + - entity: sensor.REPEATER_SLUG_radio_example_coding_rate + name: Coding rate + - entity: sensor.REPEATER_SLUG_radio_example_preamble_length + name: Preamble length + - type: entities + title: Source Freshness + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_reading_system_health_hardware_stats_age + name: Hardware age + - entity: binary_sensor.REPEATER_SLUG_reading_system_health_hardware_stats_stale + name: Hardware stale + - entity: sensor.REPEATER_SLUG_reading_modem_pymc_modem_age + name: Modem age + - entity: binary_sensor.REPEATER_SLUG_reading_modem_pymc_modem_stale + name: Modem stale + grid_options: + columns: full + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Diagnostics · traffic & routing + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Packet Flow + state_color: true + show_header_toggle: false + entities: - entity: sensor.REPEATER_SLUG_packets_24h name: Packets Received 24h icon: mdi:download-network @@ -194,249 +521,107 @@ cards: - entity: sensor.REPEATER_SLUG_packet_types_24h name: Packet Types 24h icon: mdi:format-list-bulleted-type - - - type: history-graph - title: Packet Trends - hours_to_show: 24 - refresh_interval: 60 - entities: - - entity: sensor.REPEATER_SLUG_packets_received_per_hour - name: RX / Hour - - entity: sensor.REPEATER_SLUG_packets_forwarded_per_hour - name: Forwarded / Hour - - entity: sensor.REPEATER_SLUG_dropped_packets_24h - name: Dropped 24h - - entity: sensor.REPEATER_SLUG_crc_errors_24h - name: CRC 24h - - - type: entities - title: Listen Before Talk Diagnostics - state_color: true - show_header_toggle: false - entities: - - entity: binary_sensor.REPEATER_SLUG_lbt_data_available - name: LBT Data Available - icon: mdi:database-check-outline - - entity: sensor.REPEATER_SLUG_lbt_transmissions_24h - name: LBT Transmissions 24h - icon: mdi:transmission-tower-export - - entity: sensor.REPEATER_SLUG_lbt_retry_rate_24h - name: LBT Retry Rate 24h - icon: mdi:reload-alert - - entity: sensor.REPEATER_SLUG_lbt_average_attempts_24h - name: Average Attempts 24h - icon: mdi:counter - - entity: sensor.REPEATER_SLUG_lbt_severe_contention_24h - name: Severe Contention 24h - icon: mdi:alert-octagon-outline - - - type: entities - title: Signal Quality - state_color: true - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_average_rssi_24h - name: Average RSSI 24h - icon: mdi:signal - - entity: sensor.REPEATER_SLUG_average_snr_24h - name: Average SNR 24h - icon: mdi:signal-cellular-outline - - entity: sensor.REPEATER_SLUG_average_noise_floor_24h - name: Average Noise Floor 24h - icon: mdi:waves + - entity: sensor.REPEATER_SLUG_current_airtime + name: Current Airtime - entity: sensor.REPEATER_SLUG_average_score_24h - name: Average Score 24h - icon: mdi:star-circle - - entity: sensor.REPEATER_SLUG_average_payload_length_24h - name: Average Payload Length 24h - icon: mdi:code-brackets - - entity: sensor.REPEATER_SLUG_average_tx_delay_24h - name: Average TX Delay 24h - icon: mdi:timer-outline - - - type: entities - title: Routing - state_color: true - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_route_direct - name: Direct Routes - icon: mdi:routes - - entity: sensor.REPEATER_SLUG_route_flood - name: Flood Routes - icon: mdi:waves-arrow-right - - entity: switch.REPEATER_SLUG_allow_unscoped_flood - name: Allow Unscoped Flood - icon: mdi:flood - - # Plugin-manager counts are unknown on older builds or if the manager is down. - - type: entities - title: Plugin Health - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_installed_plugins - - entity: sensor.REPEATER_SLUG_enabled_plugins - - entity: sensor.REPEATER_SLUG_running_plugins - - entity: sensor.REPEATER_SLUG_failed_plugins - - - type: entities - title: Neighbor Links - state_color: true - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_observed_neighbor_links - name: Observed Neighbor Links - icon: mdi:access-point-network - - entity: sensor.REPEATER_SLUG_active_neighbor_links - name: Active Neighbor Links - icon: mdi:access-point-network-off - - entity: sensor.REPEATER_SLUG_mqtt_neighbors_phase - name: MQTT Neighbors Phase - icon: mdi:access-point-network - - entity: button.REPEATER_SLUG_publish_mqtt_neighbors - name: Publish MQTT Neighbors - icon: mdi:cloud-upload-outline - - - type: entities - title: Repeater Controls - state_color: true - show_header_toggle: false - entities: - - entity: select.REPEATER_SLUG_repeater_mode - name: Repeater Mode - icon: mdi:tune - - entity: sensor.REPEATER_SLUG_default_region - name: Current Default Region - icon: mdi:earth - - entity: select.REPEATER_SLUG_default_region - name: Set Default Region - icon: mdi:earth-arrow-right - - entity: switch.REPEATER_SLUG_duty_cycle_enforcement - name: Duty Cycle Enforcement - icon: mdi:shield-check - - entity: number.REPEATER_SLUG_max_airtime_percent - name: Max Airtime % - icon: mdi:percent - - entity: button.REPEATER_SLUG_send_advert - name: Send Flood Advert - icon: mdi:bullhorn - - entity: button.REPEATER_SLUG_send_direct_advert - name: Send Direct Advert - icon: mdi:bullhorn-outline - - entity: number.REPEATER_SLUG_flood_advert_interval - name: Flood Advert Interval - icon: mdi:timer-sync-outline - - entity: number.REPEATER_SLUG_direct_advert_interval - name: Direct Advert Interval - icon: mdi:timer-outline - - entity: button.REPEATER_SLUG_restart_repeater_service - name: Restart Repeater Service - icon: mdi:restart-alert - - - type: entities - title: Advert System - state_color: true - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_advert_tier - name: Advert Tier - icon: mdi:stairs - - entity: sensor.REPEATER_SLUG_adverts_allowed - name: Adverts Allowed - icon: mdi:check-circle-outline - - entity: sensor.REPEATER_SLUG_adverts_dropped - name: Adverts Dropped - icon: mdi:close-circle-outline - - entity: sensor.REPEATER_SLUG_advert_drop_rate - name: Advert Drop Rate - icon: mdi:chart-bell-curve - - entity: sensor.REPEATER_SLUG_tracked_advert_hashes - name: Tracked Advert Hashes - icon: mdi:fingerprint - - entity: sensor.REPEATER_SLUG_tracked_advert_pubkeys - name: Tracked Advert Pubkeys - icon: mdi:key-chain - - - type: entities - title: Advert Rate Limiting - state_color: true - show_header_toggle: false - entities: - - entity: switch.REPEATER_SLUG_advert_rate_limit - name: Rate Limit Enabled - icon: mdi:speedometer-slow - - entity: binary_sensor.REPEATER_SLUG_advert_adaptive_enabled - name: Adaptive Enabled - icon: mdi:auto-fix - - entity: switch.REPEATER_SLUG_advert_adaptive_control - name: Adaptive Control - icon: mdi:tune-variant - - entity: binary_sensor.REPEATER_SLUG_advert_dedupe_enabled - name: Dedupe Enabled - icon: mdi:content-duplicate - - entity: switch.REPEATER_SLUG_advert_penalty_box - name: Penalty Box - icon: mdi:account-cancel - - entity: number.REPEATER_SLUG_advert_minimum_interval - name: Minimum Interval - - entity: number.REPEATER_SLUG_advert_quiet_max - name: Quiet Max - - entity: number.REPEATER_SLUG_advert_normal_max - name: Normal Max - - entity: number.REPEATER_SLUG_advert_busy_max - name: Busy Max - - - type: entities - title: Advert Penalty Tuning - show_header_toggle: false - entities: - - entity: number.REPEATER_SLUG_advert_base_penalty - name: Base Penalty - - entity: number.REPEATER_SLUG_advert_penalty_multiplier - name: Penalty Multiplier - - entity: number.REPEATER_SLUG_advert_max_penalty - name: Max Penalty - - entity: number.REPEATER_SLUG_advert_violation_threshold - name: Violation Threshold - - entity: number.REPEATER_SLUG_advert_violation_decay - name: Violation Decay - - entity: number.REPEATER_SLUG_advert_bucket_capacity - name: Bucket Capacity - - entity: number.REPEATER_SLUG_advert_refill_tokens - name: Refill Tokens - - entity: number.REPEATER_SLUG_advert_refill_interval - name: Refill Interval - - entity: number.REPEATER_SLUG_advert_ewma_alpha - name: EWMA Alpha - - entity: number.REPEATER_SLUG_advert_hysteresis - name: Hysteresis - - - type: entities - title: Clients & Identities - state_color: true - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_authenticated_clients - name: Authenticated Clients - icon: mdi:account-check - - entity: sensor.REPEATER_SLUG_acl_clients - name: ACL Clients - icon: mdi:account-lock - - entity: sensor.REPEATER_SLUG_registered_identities - name: Registered Identities - icon: mdi:card-account-details - - entity: sensor.REPEATER_SLUG_transport_keys - name: Transport Keys - icon: mdi:key - - entity: sensor.REPEATER_SLUG_room_servers - name: Room Servers - icon: mdi:server-network - - - type: entities - title: MQTT Brokers - state_color: true - show_header_toggle: false - entities: + name: Avg Score 24h + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Signal Quality + state_color: true + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_average_rssi_24h + name: Average RSSI 24h + icon: mdi:signal + - entity: sensor.REPEATER_SLUG_average_snr_24h + name: Average SNR 24h + icon: mdi:signal-cellular-outline + - entity: sensor.REPEATER_SLUG_average_noise_floor_24h + name: Average Noise Floor 24h + icon: mdi:waves + - entity: sensor.REPEATER_SLUG_average_score_24h + name: Average Score 24h + icon: mdi:star-circle + - entity: sensor.REPEATER_SLUG_average_payload_length_24h + name: Average Payload Length 24h + icon: mdi:code-brackets + - entity: sensor.REPEATER_SLUG_average_tx_delay_24h + name: Average TX Delay 24h + icon: mdi:timer-outline + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Listen Before Talk Diagnostics + state_color: true + show_header_toggle: false + entities: + - entity: binary_sensor.REPEATER_SLUG_lbt_data_available + name: LBT Data Available + icon: mdi:database-check-outline + - entity: sensor.REPEATER_SLUG_lbt_transmissions_24h + name: LBT Transmissions 24h + icon: mdi:transmission-tower-export + - entity: sensor.REPEATER_SLUG_lbt_retry_rate_24h + name: LBT Retry Rate 24h + icon: mdi:reload-alert + - entity: sensor.REPEATER_SLUG_lbt_average_attempts_24h + name: Average Attempts 24h + icon: mdi:counter + - entity: sensor.REPEATER_SLUG_lbt_severe_contention_24h + name: Severe Contention 24h + icon: mdi:alert-octagon-outline + - type: entities + title: Network Traffic + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_network_bytes_received + name: Bytes Received + icon: mdi:download-network-outline + - entity: sensor.REPEATER_SLUG_network_bytes_sent + name: Bytes Sent + icon: mdi:upload-network-outline + - entity: sensor.REPEATER_SLUG_network_packets_received + name: Network Packets Received + icon: mdi:download-network + - entity: sensor.REPEATER_SLUG_network_packets_sent + name: Network Packets Sent + icon: mdi:upload-network + grid_options: + columns: full + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Diagnostics · plugins & connections + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: MQTT Brokers + state_color: true + show_header_toggle: false + entities: - entity: sensor.REPEATER_SLUG_connected_mqtt_brokers name: Connected Brokers icon: mdi:cloud-check @@ -445,7 +630,6 @@ cards: - entity: binary_sensor.REPEATER_SLUG_mqtt_handler_active name: Handler Active - type: divider - # Replace these example rows with broker entities from your installation. - entity: binary_sensor.REPEATER_SLUG_mqtt_example_broker_1 name: Example Broker 1 - entity: binary_sensor.REPEATER_SLUG_mqtt_example_broker_2 @@ -454,12 +638,19 @@ cards: name: Example Broker 3 - entity: binary_sensor.REPEATER_SLUG_mqtt_example_broker_4 name: Example Broker 4 - - - type: entities - title: Companions - state_color: true - show_header_toggle: false - entities: + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Companions + state_color: true + show_header_toggle: false + entities: - entity: sensor.REPEATER_SLUG_configured_companions name: Configured Companions icon: mdi:account-multiple @@ -467,7 +658,6 @@ cards: name: Running Companion Bridges icon: mdi:bridge - type: divider - # Replace these example rows with companion entities from your installation. - entity: binary_sensor.REPEATER_SLUG_companion_example name: Example Companion - entity: binary_sensor.REPEATER_SLUG_companion_bridge_example @@ -476,12 +666,82 @@ cards: name: Example Channels - entity: sensor.REPEATER_SLUG_companion_example_contacts name: Example Contacts - - - type: entities - title: GPS - state_color: true - show_header_toggle: false - entities: + - type: entities + title: Plugin Health + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_installed_plugins + name: Installed plugins + - entity: sensor.REPEATER_SLUG_enabled_plugins + name: Enabled plugins + - entity: sensor.REPEATER_SLUG_running_plugins + name: Running plugins + - entity: sensor.REPEATER_SLUG_failed_plugins + name: Failed plugins + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Individual Plugins + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_plugin_example_version + name: example version + - entity: sensor.REPEATER_SLUG_plugin_example_state + name: example state + - entity: binary_sensor.REPEATER_SLUG_plugin_example_enabled + name: example enabled + - entity: binary_sensor.REPEATER_SLUG_plugin_example_running + name: example running + - entity: binary_sensor.REPEATER_SLUG_plugin_example_problem + name: example problem + - type: entities + title: Clients & Identities + state_color: true + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_authenticated_clients + name: Authenticated Clients + icon: mdi:account-check + - entity: sensor.REPEATER_SLUG_acl_clients + name: ACL Clients + icon: mdi:account-lock + - entity: sensor.REPEATER_SLUG_registered_identities + name: Registered Identities + icon: mdi:card-account-details + - entity: sensor.REPEATER_SLUG_transport_keys + name: Transport Keys + icon: mdi:key + - entity: sensor.REPEATER_SLUG_room_servers + name: Room Servers + icon: mdi:server-network + grid_options: + columns: full + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Diagnostics · GPS & external sensors + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: GPS + state_color: true + show_header_toggle: false + entities: - entity: binary_sensor.REPEATER_SLUG_gps_enabled name: GPS Enabled - entity: binary_sensor.REPEATER_SLUG_gps_running @@ -522,51 +782,84 @@ cards: name: PDOP - entity: sensor.REPEATER_SLUG_gps_vdop name: VDOP + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: External Modem GPS + state_color: true + show_header_toggle: false + entities: + - entity: binary_sensor.REPEATER_SLUG_sensor_modem_gps_enabled + name: GPS Enabled + icon: mdi:crosshairs-gps + - entity: binary_sensor.REPEATER_SLUG_sensor_modem_gps_seen + name: GPS Seen + icon: mdi:satellite-variant + - entity: binary_sensor.REPEATER_SLUG_sensor_modem_fix_valid + name: Fix Valid + icon: mdi:map-marker-check + - entity: sensor.REPEATER_SLUG_sensor_modem_fix_quality + name: Fix Quality + icon: mdi:signal + - type: divider + - entity: sensor.REPEATER_SLUG_sensor_modem_latitude + name: Latitude + - entity: sensor.REPEATER_SLUG_sensor_modem_longitude + name: Longitude + - entity: sensor.REPEATER_SLUG_sensor_modem_altitude_m + name: Altitude + - entity: sensor.REPEATER_SLUG_sensor_modem_speed_kmh + name: Speed + - entity: sensor.REPEATER_SLUG_sensor_modem_course_degrees + name: Course + - type: divider + - entity: sensor.REPEATER_SLUG_sensor_modem_satellites_used + name: Satellites Used + - entity: sensor.REPEATER_SLUG_sensor_modem_satellites_in_view + name: Satellites In View + - type: markdown + title: External Modem Percentages + card_size: 2 + content: | + {% set ns = namespace(battery=none, battery_unit='%', solar=none, solar_unit='%/h') %} + {% for item in states.sensor %} + {% if 'REPEATER_SLUG_sensor_modem_battery_percent' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} + {% set ns.battery = item.state %} + {% set ns.battery_unit = item.attributes.get('unit_of_measurement', '%') %} + {% endif %} + {% if 'REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} + {% set ns.solar = item.state %} + {% set ns.solar_unit = item.attributes.get('unit_of_measurement', '%/h') %} + {% endif %} + {% endfor %} + **Battery:** {% if ns.battery is not none %}{{ ns.battery }} {{ ns.battery_unit }}{% else %}Not reported{% endif %} - - type: entities - title: GPS Sync & Updates - state_color: true - show_header_toggle: false - entities: - - entity: binary_sensor.REPEATER_SLUG_gps_time_sync_enabled - name: Time Sync Enabled - - entity: sensor.REPEATER_SLUG_gps_time_sync_state - name: Time Sync State - - entity: sensor.REPEATER_SLUG_gps_utc_time - name: UTC Time - - entity: binary_sensor.REPEATER_SLUG_gps_location_updates_enabled - name: Location Updates Enabled - - entity: sensor.REPEATER_SLUG_gps_location_update_state - name: Location Update State - - entity: sensor.REPEATER_SLUG_gps_last_sentence_type - name: Last Sentence Type - - entity: sensor.REPEATER_SLUG_gps_valid_checksums - name: Valid Checksums - - - type: markdown - title: External Modem Percentages - card_size: 2 - content: | - {% set ns = namespace(battery=none, battery_unit='%', solar=none, solar_unit='%/h') %} - {% for item in states.sensor %} - {% if 'REPEATER_SLUG_sensor_modem_battery_percent' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} - {% set ns.battery = item.state %} - {% set ns.battery_unit = item.attributes.get('unit_of_measurement', '%') %} - {% endif %} - {% if 'REPEATER_SLUG_sensor_modem_solar_charge_rate_percent_per_hour' in item.entity_id and item.state not in ['unknown', 'unavailable'] %} - {% set ns.solar = item.state %} - {% set ns.solar_unit = item.attributes.get('unit_of_measurement', '%/h') %} - {% endif %} - {% endfor %} - **Battery:** {% if ns.battery is not none %}{{ ns.battery }} {{ ns.battery_unit }}{% else %}Not reported{% endif %} - - **Solar charge rate:** {% if ns.solar is not none %}{{ ns.solar }} {{ ns.solar_unit }}{% else %}Not reported{% endif %} - - - type: entities - title: External Modem Status - state_color: true - show_header_toggle: false - entities: + **Solar charge rate:** {% if ns.solar is not none %}{{ ns.solar }} {{ ns.solar_unit }}{% else %}Not reported{% endif %} + - type: entities + title: Battery Trend + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_reading_modem_pymc_modem_charge_state + name: Modem charge state + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: External Modem Status + state_color: true + show_header_toggle: false + entities: - entity: binary_sensor.REPEATER_SLUG_sensor_modem_ok name: Modem OK icon: mdi:check-circle-outline @@ -586,7 +879,6 @@ cards: - entity: sensor.REPEATER_SLUG_sensor_modem_battery_voltage_mv name: Battery Voltage mV icon: mdi:flash-outline - - entity: sensor.REPEATER_SLUG_sensor_modem_bus_voltage_v name: Bus Voltage icon: mdi:flash-outline @@ -599,75 +891,46 @@ cards: - entity: sensor.REPEATER_SLUG_sensor_modem_die_temperature_c name: Modem Temperature icon: mdi:thermometer - - - type: entities - title: External Modem GPS - state_color: true - show_header_toggle: false - entities: - - entity: binary_sensor.REPEATER_SLUG_sensor_modem_gps_enabled - name: GPS Enabled - icon: mdi:crosshairs-gps - - entity: binary_sensor.REPEATER_SLUG_sensor_modem_gps_seen - name: GPS Seen - icon: mdi:satellite-variant - - entity: binary_sensor.REPEATER_SLUG_sensor_modem_fix_valid - name: Fix Valid - icon: mdi:map-marker-check - - entity: sensor.REPEATER_SLUG_sensor_modem_fix_quality - name: Fix Quality - icon: mdi:signal - - type: divider - - entity: sensor.REPEATER_SLUG_sensor_modem_latitude - name: Latitude - - entity: sensor.REPEATER_SLUG_sensor_modem_longitude - name: Longitude - - entity: sensor.REPEATER_SLUG_sensor_modem_altitude_m - name: Altitude - - entity: sensor.REPEATER_SLUG_sensor_modem_speed_kmh - name: Speed - - entity: sensor.REPEATER_SLUG_sensor_modem_course_degrees - name: Course - - type: divider - - entity: sensor.REPEATER_SLUG_sensor_modem_satellites_used - name: Satellites Used - - entity: sensor.REPEATER_SLUG_sensor_modem_satellites_in_view - name: Satellites In View - - - type: entities - title: Update Management - state_color: true - show_header_toggle: false - entities: - - entity: binary_sensor.REPEATER_SLUG_update_available - name: Update Available - icon: mdi:update - - entity: sensor.REPEATER_SLUG_repeater_version - name: Installed Version - icon: mdi:tag - - entity: sensor.REPEATER_SLUG_update_latest_version - name: Latest Version - icon: mdi:tag-plus - - entity: sensor.REPEATER_SLUG_update_state - name: Update State - icon: mdi:progress-download - - entity: sensor.REPEATER_SLUG_update_channel - name: Current Update Channel - icon: mdi:source-branch - - entity: select.REPEATER_SLUG_update_channel - name: Set Update Channel - icon: mdi:source-branch - - entity: button.REPEATER_SLUG_check_for_updates - name: Check For Updates - icon: mdi:cloud-search - - entity: button.REPEATER_SLUG_install_latest_update - name: Install Latest Update - icon: mdi:download-circle - - - type: entities - title: Metrics Database - show_header_toggle: false - entities: + - type: entities + title: GPS Sync & Updates + state_color: true + show_header_toggle: false + entities: + - entity: binary_sensor.REPEATER_SLUG_gps_time_sync_enabled + name: Time Sync Enabled + - entity: sensor.REPEATER_SLUG_gps_time_sync_state + name: Time Sync State + - entity: sensor.REPEATER_SLUG_gps_utc_time + name: UTC Time + - entity: binary_sensor.REPEATER_SLUG_gps_location_updates_enabled + name: Location Updates Enabled + - entity: sensor.REPEATER_SLUG_gps_location_update_state + name: Location Update State + - entity: sensor.REPEATER_SLUG_gps_last_sentence_type + name: Last Sentence Type + - entity: sensor.REPEATER_SLUG_gps_valid_checksums + name: Valid Checksums + grid_options: + columns: full + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Diagnostics · tuning & storage + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Metrics Database + show_header_toggle: false + entities: - entity: sensor.REPEATER_SLUG_database_size name: Database Size icon: mdi:database @@ -706,5 +969,120 @@ cards: - entity: button.REPEATER_SLUG_vacuum_metrics_database name: Vacuum Metrics Database icon: mdi:database-refresh - -show_icon_and_title: true + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Advert Penalty Tuning + show_header_toggle: false + entities: + - entity: number.REPEATER_SLUG_advert_base_penalty + name: Base Penalty + - entity: number.REPEATER_SLUG_advert_penalty_multiplier + name: Penalty Multiplier + - entity: number.REPEATER_SLUG_advert_max_penalty + name: Max Penalty + - entity: number.REPEATER_SLUG_advert_violation_threshold + name: Violation Threshold + - entity: number.REPEATER_SLUG_advert_violation_decay + name: Violation Decay + - entity: number.REPEATER_SLUG_advert_bucket_capacity + name: Bucket Capacity + - entity: number.REPEATER_SLUG_advert_refill_tokens + name: Refill Tokens + - entity: number.REPEATER_SLUG_advert_refill_interval + name: Refill Interval + - entity: number.REPEATER_SLUG_advert_ewma_alpha + name: EWMA Alpha + - entity: number.REPEATER_SLUG_advert_hysteresis + name: Hysteresis + grid_options: + columns: full + rows: auto +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: entities + title: Advert Rate Limiting + state_color: true + show_header_toggle: false + entities: + - entity: switch.REPEATER_SLUG_advert_rate_limit + name: Rate Limit Enabled + icon: mdi:speedometer-slow + - entity: binary_sensor.REPEATER_SLUG_advert_adaptive_enabled + name: Adaptive Enabled + icon: mdi:auto-fix + - entity: switch.REPEATER_SLUG_advert_adaptive_control + name: Adaptive Control + icon: mdi:tune-variant + - entity: binary_sensor.REPEATER_SLUG_advert_dedupe_enabled + name: Dedupe Enabled + icon: mdi:content-duplicate + - entity: switch.REPEATER_SLUG_advert_penalty_box + name: Penalty Box + icon: mdi:account-cancel + - entity: number.REPEATER_SLUG_advert_minimum_interval + name: Minimum Interval + - entity: number.REPEATER_SLUG_advert_quiet_max + name: Quiet Max + - entity: number.REPEATER_SLUG_advert_normal_max + name: Normal Max + - entity: number.REPEATER_SLUG_advert_busy_max + name: Busy Max + - type: entities + title: Advert System + state_color: true + show_header_toggle: false + entities: + - entity: sensor.REPEATER_SLUG_advert_tier + name: Advert Tier + icon: mdi:stairs + - entity: sensor.REPEATER_SLUG_adverts_allowed + name: Adverts Allowed + icon: mdi:check-circle-outline + - entity: sensor.REPEATER_SLUG_adverts_dropped + name: Adverts Dropped + icon: mdi:close-circle-outline + - entity: sensor.REPEATER_SLUG_advert_drop_rate + name: Advert Drop Rate + icon: mdi:chart-bell-curve + - entity: sensor.REPEATER_SLUG_tracked_advert_hashes + name: Tracked Advert Hashes + icon: mdi:fingerprint + - entity: sensor.REPEATER_SLUG_tracked_advert_pubkeys + name: Tracked Advert Pubkeys + icon: mdi:key-chain + grid_options: + columns: full + rows: auto +- type: grid + column_span: 3 + cards: + - type: heading + heading: Reference notes + heading_style: title + grid_options: + columns: full + rows: 1 +- type: grid + column_span: 1 + cards: + - type: vertical-stack + cards: + - type: markdown + title: Monitoring Notes + content: Radio settings are read-only configuration, not per-radio traffic metrics. Battery charging/discharging/neutral + is derived only from fresh signed solar charge rate (%/h), not current or power. Zero does not mean full. + Unknown or unavailable measurements are not healthy zeroes. Remove unsupported dynamic example rows and + keep only this Repeater's sources. + grid_options: + columns: full + rows: auto diff --git a/tests/test_comprehensive_dashboard.py b/tests/test_comprehensive_dashboard.py new file mode 100644 index 0000000..a5c01a5 --- /dev/null +++ b/tests/test_comprehensive_dashboard.py @@ -0,0 +1,49 @@ +"""Portable comprehensive dashboard coverage without Home Assistant imports.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +class ComprehensiveDashboardTests(unittest.TestCase): + def test_monitoring_features_are_in_comprehensive_view(self): + text = (ROOT / 'dashboards/openhop_repeater_dashboard.yaml').read_text() + for token in ('Component Health', 'Radio Inventory and Settings', + 'Source Freshness', 'Individual Plugins', 'Battery Trend', + 'update.REPEATER_SLUG_repeater_software', + 'component_hardware_stats_problem', 'component_update_status_problem', + 'reading_modem_pymc_modem_charge_state'): + self.assertIn(token, text) + self.assertNotIn('custom:', text) + self.assertIn('REPEATER_SLUG', text) + self.assertIn('exact entity IDs', text) + + def test_density_uses_natural_height_columns_and_named_badges(self): + text = (ROOT / 'dashboards/openhop_repeater_dashboard.yaml').read_text() + self.assertIn('type: vertical-stack', text) + self.assertIn('column_span: 1', text) + self.assertIn('show_name: true', text) + overview = text.split('heading: Recent trends')[0] + self.assertNotIn('name: Current Airtime', overview) + self.assertNotIn('name: Avg Score 24h', overview) + self.assertNotIn('name: GPS Fix', overview) + + def test_ordered_native_sections_keep_diagnostics_below_essentials(self): + text = (ROOT / 'dashboards/openhop_repeater_dashboard.yaml').read_text() + self.assertIn('type: sections', text) + self.assertIn('max_columns: 3', text) + self.assertIn('dense_section_placement: false', text) + headings = ['Live overview', 'Recent trends', 'Operations', + 'Diagnostics · system & radio', 'Diagnostics · traffic & routing', + 'Diagnostics · plugins & connections', + 'Diagnostics · GPS & external sensors', + 'Diagnostics · tuning & storage', 'Reference notes'] + positions = [text.index('heading: ' + title) for title in headings] + self.assertEqual(positions, sorted(positions)) + self.assertEqual(text.count('column_span: 3'), len(headings)) + self.assertNotIn('type: history-graph', text) + overview = text.split('heading: Recent trends')[0] + self.assertNotIn('title: Component Health', overview) + self.assertNotIn('component_gps_problem', overview) + for suffix in ('packets_received_per_hour', 'packets_forwarded_per_hour', + 'packet_drop_rate_24h', 'current_noise_floor', 'radio_utilization'): + self.assertIn('sensor.REPEATER_SLUG_' + suffix, overview) diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py index 9a71de4..3a52643 100644 --- a/tests/test_dev_api_alignment.py +++ b/tests/test_dev_api_alignment.py @@ -384,14 +384,15 @@ def test_dashboard_percentage_cards_use_percentage_states_and_dynamic_modem_look dashboard = (ROOT / "dashboards/openhop_repeater_dashboard.yaml").read_text( encoding="utf-8" ) - gauge_stack = dashboard.split(" - type: horizontal-stack", 1)[1].split( - " - type: history-graph", 1 + overview = dashboard.split("heading: Live overview", 1)[1].split( + "heading: Recent trends", 1 )[0] - self.assertIn("sensor.REPEATER_SLUG_cpu_usage", gauge_stack) - self.assertIn("sensor.REPEATER_SLUG_radio_utilization", gauge_stack) - self.assertIn("sensor.REPEATER_SLUG_packet_drop_rate_24h", gauge_stack) - self.assertNotIn("sensor.REPEATER_SLUG_current_airtime", gauge_stack) + self.assertIn("sensor.REPEATER_SLUG_cpu_usage", overview) + self.assertIn("sensor.REPEATER_SLUG_radio_utilization", overview) + self.assertIn("sensor.REPEATER_SLUG_packet_drop_rate_24h", overview) + # Airtime has its own native-unit tile; it is never a percentage gauge. + self.assertNotIn("type: gauge", dashboard) self.assertIn( "'REPEATER_SLUG_sensor_modem_battery_percent' in item.entity_id", dashboard, From b922e515bdd651290e4a42af58c833ec23597abe Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:51:45 -0400 Subject: [PATCH 06/12] chore: prepare version 1.2.0 release notes --- CHANGELOG.md | 57 +++++++++---------- custom_components/pymc_repeater/manifest.json | 2 +- tests/test_dev_api_alignment.py | 5 +- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba915fe..a4f8197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,35 +2,34 @@ ## Unreleased -- corrected comprehensive dashboard density with natural-height vertical-stack columns, compact update management, six primary overview metrics plus optional battery/temperature, named badges, and shorter radio/source/plugin labels; retained entity references and ordered diagnostic bands - -- reorganized the comprehensive view into responsive ordered Sections bands: live metrics and short single-unit trends first, operations next, complete grouped diagnostics and notes last; shortened display-only labels without renaming entities or removing controls - -- refreshed the comprehensive built-in dashboard with component problems, source freshness, radio child settings, individual plugin rows, battery charge trend and native update more-info, with explicit same-Repeater dynamic entity replacement guidance - -- fixed single-radio settings discovery to read the actual nested stats configuration, restoring frequency, bandwidth, TX power, spreading factor, coding rate, and preamble entities - -- added stable radio child inventory and supported read-only radio settings without allocating aggregate RF metrics; the installed API does not establish genuine per-radio telemetry -- derive charging/discharging/neutral battery trend only from fresh finite signed solar charge rate; zero does not claim a full battery -- added shared-poll component diagnostics, last successful poll, source age/stale sensors and conservative measurement availability -- added a native cached-status update entity with explicit installation only, preserving all existing update controls -- added battery/electrical/temperature device classes, generic voltage units and rejection of nonfinite measurements -- added seven opt-in alert blueprints with user-selected actions and a separate compact operations dashboard - -- added aggregate installed, enabled, running, and failed application-plugin diagnostics and dashboard rows, plus allowlisted per-ID version/state/enabled/runtime/problem entities, without retaining plugin paths, settings, or repository metadata -- added optional `bucket_seconds` to the existing neighbor-history action; raw `rows` remain the default and bucketed requests return `buckets` -- checked these additions against installed Repeater 1.1.2.dev325 source and added executable stdlib client/sensor behavior tests (not a live Home Assistant runtime test) - -- aligned advert controls with current Repeater dev support for explicit flood/direct sends and independent flood/direct schedules -- added MQTT neighbors publication status, a manual publication control, stored neighbor-scope retrieval, and one-neighbor scope queries -- allowed the neighbor-scope query action to wait through the Repeater's 45-second response window instead of hitting the integration's normal 10-second API timeout -- documented multi-radio `radio_id`, MQTT custom base topics, and neighbor-publisher payload support on the existing raw configuration actions -- exposed current Repeater dev sensor-manager payloads generically, including BME280 pressure and normalized openHop Modem voltage/current/power readings with Home Assistant units, numeric-string coercion, and an additive battery-percentage alias, without changing compatibility identifiers -- added English translation keys for the new advert controls, MQTT-neighbor status, and radio-stack diagnostics -- added radio-stack mode and configured-radio-count diagnostics for the Repeater's current multi-radio API payload -- aligned the flood advert interval control with the Repeater's disjoint `0` or `3-168` hour validation instead of allowing invalid one- and two-hour writes -- stopped retaining raw transport-key and private identity configuration in coordinator polling and redacted runtime location, identity, network, and credential fields from downloadable diagnostics -- fixed the dashboard percentage gauges and made modem battery/solar percentages tolerate Home Assistant area prefixes and numeric entity-ID suffixes +## 1.2.0 + +### Added + +- Radio child devices with supported read-only frequency, bandwidth, TX power, spreading factor, coding rate, and preamble settings; preserved existing entity IDs and single-radio compatibility. +- Component-health diagnostics, last successful poll, source age and stale-reading indicators, with unavailable states for stale, failed, or invalid measurements. +- Native Home Assistant update entity with explicit installation on the selected channel, while preserving existing update sensors, buttons, and actions. +- Aggregate plugin counts and individual plugin version, state, enabled, running, and problem diagnostics without exposing plugin settings, filesystem paths, or repository metadata. +- Battery, voltage, current, power, and temperature device classes for supported readings, including numeric-string normalization, BME280 pressure, and an additive modem battery-percentage alias. +- Charging, discharging, and neutral battery trends derived from fresh signed charge-rate readings in `%/h`, without inferring electrical current or a full-battery state. +- Seven opt-in alert blueprints for availability, MQTT disconnection, low battery, high temperature, stale readings, plugin failure, and available updates. +- Optional `bucket_seconds` on the neighbor-history action, preserving raw rows when omitted. +- MQTT neighbors publication status, a manual publication control, stored neighbor-scope retrieval, and one-neighbor scope queries. +- Independent flood/direct advert controls and schedules, plus radio-stack mode and configured-radio-count diagnostics. + +### Changed + +- Redesigned the comprehensive built-in dashboard with a compact operational overview and trends first, controls next, and grouped diagnostics last. Responsive Sections and natural-height card stacks avoid stretched diagnostic cards; labels and badges are shortened and explicit. +- Added a separate compact operations dashboard and documented replacement of dynamic entity IDs using the correct Repeater and its radio child devices. +- Documented multi-radio `radio_id`, MQTT custom base topics, and neighbor-publisher fields on existing raw configuration actions. +- Expanded diagnostics redaction and stopped retaining raw transport-key and private identity configuration in coordinator polling. +- Expanded English translations and executable client, monitoring, update, and dashboard regression coverage. + +### Fixed + +- Allowed neighbor-scope queries to wait through the Repeater's 45-second response window instead of using the normal 10-second API timeout. +- Aligned flood advert interval validation with the Repeater's supported `0` or `3–168` hour values. +- Made modem battery/solar percentage displays tolerate Home Assistant area prefixes and numeric entity-ID suffixes. ## 1.1.6 diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index 89ef108..dae406f 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/openhop-dev/openHop-HA-Integration/issues", "requirements": [], - "version": "1.1.6" + "version": "1.2.0" } diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py index 3a52643..a906949 100644 --- a/tests/test_dev_api_alignment.py +++ b/tests/test_dev_api_alignment.py @@ -358,16 +358,17 @@ def test_integration_polling_is_configurable_and_defaults_to_15_seconds(self) -> self.assertIn("async_update_listeners()", coordinator) self.assertNotIn("async_set_updated_data", coordinator) - def test_release_metadata_and_changelog_are_v1_1_6(self) -> None: + def test_release_metadata_and_changelog_are_v1_2_0(self) -> None: manifest = json.loads((COMPONENT / "manifest.json").read_text(encoding="utf-8")) changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") workflow = (ROOT / ".github/workflows/python-smoke.yaml").read_text( encoding="utf-8" ) - self.assertEqual(manifest["version"], "1.1.6") + self.assertEqual(manifest["version"], "1.2.0") self.assertIn("## 1.1.6", changelog) self.assertIn("## Unreleased", changelog) + self.assertIn("## 1.2.0", changelog) for expected in ( "15 seconds", "GPS stream", From 48ca42152171e42d1bbabf1432f211afee615562 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:11:38 -0400 Subject: [PATCH 07/12] feat: add aggregate traffic counters and clarify update status --- CHANGELOG.md | 13 ++ README.md | 7 + custom_components/pymc_repeater/api.py | 3 +- custom_components/pymc_repeater/sensor.py | 61 +++++++ .../pymc_repeater/translations/en.json | 6 + custom_components/pymc_repeater/update.py | 32 +++- dashboards/openhop_repeater_dashboard.yaml | 45 +++++- docs/operational-monitoring.md | 15 ++ tests/test_runtime_monitoring.py | 62 +++++++- tests/test_traffic_counters.py | 150 ++++++++++++++++++ tests/test_update_channel_polling.py | 53 +++++++ 11 files changed, 430 insertions(+), 17 deletions(-) create mode 100644 tests/test_traffic_counters.py create mode 100644 tests/test_update_channel_polling.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f8197..fc93736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Added + +- Six repeater-wide flood/direct received, transmitted, and duplicate packet counters using existing stats polling, with restart-aware totals and comprehensive dashboard rows. + +### Changed + +- Show readable software-update dashboard status, including GitHub Limited, Not checked, Up to date, Update available, Checking, Installing, and Check failed, while preserving explicit check/install controls. + +### Fixed + +- Remove GitHub-backed update-channel discovery from automatic polling. Continue reading cached local update status and preserve explicit user-triggered update checks without slowing normal telemetry. +- Preserve unknown native update state before a successful update check instead of reporting the installed version as up to date. + ## 1.2.0 ### Added diff --git a/README.md b/README.md index 6c07583..f5eb4cb 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ This custom integration connects Home Assistant directly to the Repeater's local The integration uses coordinated local polling instead of making a separate API request for every entity. The polling interval is configurable in the integration options and defaults to 15 seconds. +Automatic polling reads the Repeater's cached update status; it does not discover GitHub branches or initiate update checks. Use the **Check for updates** button (or the explicit update-check action) to request a fresh check. The channel selector uses `main`, `dev`, and the current channel without querying GitHub. The Repeater's own update-check behavior is unchanged. + > [!NOTE] > The integration domain and folder remain `pymc_repeater` so existing installations and entity registry entries continue to work. The user-facing name is **openHop Repeater**. @@ -197,6 +199,11 @@ Do not publish your admin password, JWT secret, Home Assistant token, or Repeate ## Operational monitoring +Aggregate flood/direct received, transmitted, and duplicate packet counters are +exposed on the parent Repeater device using existing stats polling. These are +process-lifetime totals, not per-radio counters or hourly rates; missing values +remain unknown. + See [operational monitoring](docs/operational-monitoring.md) for radio child devices, source freshness and component diagnostics, native update installation safety, plugin health, battery units, alert blueprints, and the separate compact operations view. ## Development diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index db65f22..96202d2 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -166,7 +166,6 @@ async def async_fetch_all(self) -> dict[str, Any]: "transport_keys": self.async_get_transport_keys(), "room_stats": self.async_get_room_stats(), "update_status": self.async_get_update_status(), - "update_channels": self.async_get_update_channels(), "companions": self.async_get_companions(), "gps": self.async_get_gps(), "packet_type_stats": self.async_get_packet_type_stats(), @@ -380,7 +379,7 @@ async def async_get_update_status(self) -> dict[str, Any]: return await self._async_request_wrapped("GET", "/api/update/status") async def async_get_update_channels(self) -> dict[str, Any]: - """Return available repeater update channels.""" + """Return available repeater update channels on explicit request only.""" return await self._async_request_wrapped("GET", "/api/update/channels") async def async_get_companions(self) -> list[dict[str, Any]]: diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index cbb7535..f10febf 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -277,6 +277,67 @@ class PyMCSensorDescription(SensorEntityDescription): SENSORS: tuple[PyMCSensorDescription, ...] = ( + # Aggregate engine counters restart at zero with the Repeater process. + PyMCSensorDescription( + key="recv_flood_count", + translation_key="recv_flood_count", + name="Flood packets received", + icon="mdi:download-network", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="packets", + value_fn=lambda data: _nested(data, "stats", "recv_flood_count"), + ), + PyMCSensorDescription( + key="recv_direct_count", + translation_key="recv_direct_count", + name="Direct packets received", + icon="mdi:download-network", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="packets", + value_fn=lambda data: _nested(data, "stats", "recv_direct_count"), + ), + PyMCSensorDescription( + key="sent_flood_count", + translation_key="sent_flood_count", + name="Flood packets transmitted", + icon="mdi:upload-network", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="packets", + value_fn=lambda data: _nested(data, "stats", "sent_flood_count"), + ), + PyMCSensorDescription( + key="sent_direct_count", + translation_key="sent_direct_count", + name="Direct packets transmitted", + icon="mdi:upload-network", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="packets", + value_fn=lambda data: _nested(data, "stats", "sent_direct_count"), + ), + PyMCSensorDescription( + key="flood_dup_count", + translation_key="flood_dup_count", + name="Flood duplicates", + icon="mdi:content-duplicate", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="packets", + value_fn=lambda data: _nested(data, "stats", "flood_dup_count"), + ), + PyMCSensorDescription( + key="direct_dup_count", + translation_key="direct_dup_count", + name="Direct duplicates", + icon="mdi:content-duplicate", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="packets", + value_fn=lambda data: _nested(data, "stats", "direct_dup_count"), + ), PyMCSensorDescription( key="repeater_version", translation_key="repeater_version", diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index 1d09bf3..0f70a81 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -44,6 +44,12 @@ }, "entity": { "sensor": { + "recv_flood_count": {"name": "Flood packets received"}, + "recv_direct_count": {"name": "Direct packets received"}, + "sent_flood_count": {"name": "Flood packets transmitted"}, + "sent_direct_count": {"name": "Direct packets transmitted"}, + "flood_dup_count": {"name": "Flood duplicates"}, + "direct_dup_count": {"name": "Direct duplicates"}, "plugins_installed": {"name": "Installed plugins"}, "plugins_enabled": {"name": "Enabled plugins"}, "plugins_running": {"name": "Running plugins"}, diff --git a/custom_components/pymc_repeater/update.py b/custom_components/pymc_repeater/update.py index 52933cc..2e67083 100644 --- a/custom_components/pymc_repeater/update.py +++ b/custom_components/pymc_repeater/update.py @@ -1,6 +1,8 @@ """Native firmware update entity using cached status and explicit installation.""" from __future__ import annotations +from datetime import datetime + from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.exceptions import HomeAssistantError @@ -34,20 +36,35 @@ def _status(self) -> dict: def available(self) -> bool: return (super().available and not self._status.get("error") and self._status.get("success") is not False + and self._status.get("state") != "error" and isinstance(self._status.get("has_update"), bool) - and bool(self.installed_version) and bool(self.latest_version)) + and bool(self.installed_version)) @property def installed_version(self) -> str | None: value = self._status.get("current_version") - return value if isinstance(value, str) and value else None + return value if isinstance(value, str) and value.strip() and value != "unknown" else None @property def latest_version(self) -> str | None: - if self._status.get("has_update") is False: - return self.installed_version - value = self._status.get("latest_version") - return value if isinstance(value, str) and value else None + # Startup, channel changes and forced checks reset the cached version. + # A false has_update alone is therefore not evidence of a successful check. + status = self._status + value = status.get("latest_version") + checked = status.get("last_checked") + if (status.get("state") not in ("idle", "complete") + or status.get("error") or status.get("success") is False + or not isinstance(status.get("has_update"), bool) + or not self.installed_version + or not isinstance(value, str) or not value.strip() or value == "unknown" + or not isinstance(checked, str)): + return None + try: + datetime.fromisoformat(checked) + except ValueError: + return None + # Trust the backend comparison, including when installed is ahead of latest. + return self.installed_version if status["has_update"] is False else value @property def extra_state_attributes(self) -> dict: @@ -57,7 +74,8 @@ async def async_install(self, version: str | None, backup: bool, **kwargs) -> No """The API cannot select versions, back up HA, or report percentages.""" if version is not None or backup: raise HomeAssistantError("Version selection and backups are not supported") - if not self.available or self._status.get("has_update") is not True: + if (not self.available or self.latest_version is None + or self._status.get("has_update") is not True): raise HomeAssistantError("No confirmed update is available") try: await self.coordinator.api.async_update_install(force=False) diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml index bee972a..fb34808 100644 --- a/dashboards/openhop_repeater_dashboard.yaml +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -240,11 +240,32 @@ sections: cards: - type: vertical-stack cards: - - type: tile - entity: update.REPEATER_SLUG_repeater_software - name: Software update - tap_action: - action: more-info + - type: markdown + title: Software update + content: | + {% set status = states('sensor.REPEATER_SLUG_update_state') %} + {% set error = state_attr('sensor.REPEATER_SLUG_update_state', 'error') or '' %} + {% set checked = state_attr('sensor.REPEATER_SLUG_update_state', 'last_checked') %} + {% set update = states('update.REPEATER_SLUG_repeater_software') %} + {% if is_state('binary_sensor.REPEATER_SLUG_component_api_connected', 'off') %} + Repeater disconnected + {% elif status == 'installing' %} + Installing + {% elif status == 'checking' %} + Checking + {% elif 'github' in (error | lower) and 'rate limit' in (error | lower) %} + GitHub Limited + {% elif error or status == 'error' %} + Check failed + {% elif not checked %} + Not checked + {% elif update == 'on' %} + Update available + {% elif update == 'off' %} + Up to date + {% else %} + Status unavailable + {% endif %} - type: entities title: Update Management state_color: true @@ -525,6 +546,20 @@ sections: name: Current Airtime - entity: sensor.REPEATER_SLUG_average_score_24h name: Avg Score 24h + - type: section + label: Aggregate counters · since process start + - entity: sensor.REPEATER_SLUG_flood_packets_received + name: Flood packets received + - entity: sensor.REPEATER_SLUG_direct_packets_received + name: Direct packets received + - entity: sensor.REPEATER_SLUG_flood_packets_transmitted + name: Flood packets transmitted + - entity: sensor.REPEATER_SLUG_direct_packets_transmitted + name: Direct packets transmitted + - entity: sensor.REPEATER_SLUG_flood_duplicates + name: Flood duplicates + - entity: sensor.REPEATER_SLUG_direct_duplicates + name: Direct duplicates grid_options: columns: full rows: auto diff --git a/docs/operational-monitoring.md b/docs/operational-monitoring.md index d3e06ab..3115b32 100644 --- a/docs/operational-monitoring.md +++ b/docs/operational-monitoring.md @@ -18,6 +18,16 @@ Genuine per-radio traffic/health sensors remain unsupported until a verified per-radio telemetry contract exists. Quiet RF is not a failure signal. +## Aggregate traffic counters + +Six traffic sensors belong to the parent Repeater device: flood/direct packets +received, flood/direct packets transmitted, and flood/direct duplicates. They +read existing `/api/stats` counters without additional requests. These are +aggregate counters since the Repeater process started, not per-radio readings +or hourly rates. They use `total_increasing` state class so Home Assistant can +account for resets after a Repeater restart. Missing counters remain unknown, +not zero. The comprehensive dashboard includes them in its traffic diagnostics. + ## Freshness and component health One full polling schedule remains in use, alongside the existing independent GPS @@ -65,6 +75,11 @@ Existing external-sensor IDs, including the modem compatibility alias, remain. ## Native update entity `Repeater software` uses the already-polled cached `/api/update/status` data. +Before a successful check, including after a channel change, the latest version +remains unknown rather than claiming the installed version is up to date. A +confirmed result requires a completed check state, a valid check timestamp and +version data, and no reported error. An unconfirmed result cannot enable native +update installation. It never automatically checks external release services, switches channel, or installs anything. Explicit Home Assistant `update.install` requests call the existing install API with `force=false`. Version selection and backup requests diff --git a/tests/test_runtime_monitoring.py b/tests/test_runtime_monitoring.py index 6296db3..7b85232 100644 --- a/tests/test_runtime_monitoring.py +++ b/tests/test_runtime_monitoring.py @@ -1,6 +1,7 @@ """Execute checked-in entity code with minimal HA boundaries, not a HA harness.""" import ast import asyncio +from datetime import datetime from pathlib import Path from types import SimpleNamespace import unittest @@ -34,13 +35,14 @@ class RuntimeMonitoringTests(unittest.TestCase): def test_update_readback_versions_and_install_guards(self): ns = extracted('update.py', dict(PyMCBaseEntity=Base, UpdateEntity=type('Update', (), {}), UpdateEntityFeature=SimpleNamespace(INSTALL=1), HomeAssistantError=RuntimeError, - PyMCRepeaterApiError=ValueError, DOMAIN='pymc_repeater')) + PyMCRepeaterApiError=ValueError, DOMAIN='pymc_repeater', datetime=datetime)) calls = [] async def install(**kwargs): calls.append(kwargs) coordinator = SimpleNamespace(last_update_success=True, data={'update_status': { 'current_version': '1.0', 'latest_version': '1.1', 'has_update': True, - 'channel': 'dev', 'error': None}}, api=SimpleNamespace(async_update_install=install)) + 'channel': 'dev', 'error': None, 'state': 'idle', + 'last_checked': '2026-09-08T12:00:00+00:00'}}, api=SimpleNamespace(async_update_install=install)) entity = ns['PyMCUpdateEntity'](SimpleNamespace(unique_id='entry', entry_id='fallback'), coordinator) self.assertTrue(entity.available) self.assertEqual(entity.latest_version, '1.1') @@ -53,12 +55,66 @@ async def install(**kwargs): coordinator.data['update_status']['error'] = 'check failed' self.assertFalse(entity.available, 'errored cached release must not enable install') coordinator.data['update_status'] = {'current_version': '1.0', 'has_update': False} - self.assertEqual(entity.latest_version, '1.0') + self.assertIsNone(entity.latest_version) with self.assertRaises(RuntimeError): asyncio.run(entity.async_install(None, False)) coordinator.data = {} self.assertFalse(entity.available) + def test_update_requires_completed_valid_check(self): + ns = extracted('update.py', dict(PyMCBaseEntity=Base, UpdateEntity=type('Update', (), {}), + UpdateEntityFeature=SimpleNamespace(INSTALL=1), HomeAssistantError=RuntimeError, + PyMCRepeaterApiError=ValueError, DOMAIN='pymc_repeater', datetime=datetime)) + calls = [] + async def install(**kwargs): + calls.append(kwargs) + coordinator = SimpleNamespace(last_update_success=True, data={}, + api=SimpleNamespace(async_update_install=install)) + entity = ns['PyMCUpdateEntity'](SimpleNamespace(unique_id='entry', entry_id='fallback'), coordinator) + checked = {'current_version': '1.1', 'latest_version': '1.0', 'has_update': False, + 'channel': 'dev', 'state': 'idle', 'error': None, + 'last_checked': '2026-09-08T12:00:00+00:00'} + # Installed source resets these three fields on startup/channel change/forced check. + for channel in ('dev', 'main'): + coordinator.data['update_status'] = dict(checked, channel=channel, + latest_version=None, last_checked=None, has_update=False) + self.assertTrue(entity.available) + self.assertIsNone(entity.latest_version, 'unchecked is unknown, not up to date') + for state in ('idle', 'complete'): + coordinator.data['update_status'] = dict(checked, state=state) + self.assertTrue(entity.available) + self.assertEqual(entity.latest_version, '1.1', 'backend no-update decision avoids downgrade') + invalid = [ + {'state': 'checking'}, {'state': 'installing'}, {'state': 'error'}, + {'state': None}, {'error': 'GitHub rate limit'}, {'success': False}, + *({'last_checked': value} for value in (None, '', 'invalid', False, 123)), + *({'latest_version': value} for value in (None, '', ' ', 'unknown', False, 123, {})), + *({'current_version': value} for value in (None, '', ' ', 'unknown', False, 123)), + *({'has_update': value} for value in (None, 0, 1, 'false')), + ] + for has_update in (False, True): + for changes in invalid: + with self.subTest(has_update=has_update, changes=changes): + coordinator.data['update_status'] = dict(checked, has_update=has_update) + coordinator.data['update_status'].update(changes) + self.assertIsNone(entity.latest_version) + with self.assertRaises(RuntimeError): + asyncio.run(entity.async_install(None, False)) + for key in ('latest_version', 'last_checked', 'state'): + coordinator.data['update_status'] = dict(checked, has_update=True) + del coordinator.data['update_status'][key] + with self.subTest(missing=key): + self.assertIsNone(entity.latest_version) + with self.assertRaises(RuntimeError): + asyncio.run(entity.async_install(None, False)) + coordinator.data['update_status'] = dict(checked, latest_version='1.2', has_update=True) + self.assertEqual(entity.latest_version, '1.2') + coordinator.last_update_success = False + self.assertFalse(entity.available) + with self.assertRaises(RuntimeError): + asyncio.run(entity.async_install(None, False)) + self.assertEqual(calls, [], 'reading status and rejected installs never call backend') + def test_existing_measurement_becomes_unavailable_when_stale(self): helpers = runpy.run_path(str(COMPONENT / 'monitoring.py')) tree = ast.parse((COMPONENT / 'sensor.py').read_text()) diff --git a/tests/test_traffic_counters.py b/tests/test_traffic_counters.py new file mode 100644 index 0000000..754bed9 --- /dev/null +++ b/tests/test_traffic_counters.py @@ -0,0 +1,150 @@ +"""Execute trusted local sensor source without importing Home Assistant. + +This covers description/value/entity wiring, not HA registry or recorder runtime. +The installed engine's aggregate counters restart at zero; decreases must reach +HA unchanged so TOTAL_INCREASING can recognize a new counter cycle. +""" +from __future__ import annotations + +import ast +import json +from pathlib import Path +from types import SimpleNamespace +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COMPONENT = ROOT / "custom_components" / "pymc_repeater" +COUNTERS = { + "recv_flood_count": "Flood packets received", + "recv_direct_count": "Direct packets received", + "sent_flood_count": "Flood packets transmitted", + "sent_direct_count": "Direct packets transmitted", + "flood_dup_count": "Flood duplicates", + "direct_dup_count": "Direct duplicates", +} + + +class CoordinatorBoundary: + """Only the HA coordinator attachment boundary is replaced.""" + + def __init__(self, coordinator): + self.coordinator = coordinator + + +class SensorBoundary: + """No HA registry/recorder emulation.""" + + +class TrafficCounterTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = ast.parse((COMPONENT / "sensor.py").read_text()) + # Execute only trusted checked-in helper and entity class bodies. + names = {"_nested", "PyMCBaseEntity", "PyMCSensorEntity"} + nodes = [node for node in cls.module.body if getattr(node, "name", None) in names] + cls.scope = { + "CoordinatorEntity": CoordinatorBoundary, + "SensorEntity": SensorBoundary, + "DeviceInfo": dict, + "DOMAIN": "pymc_repeater", + "MANUFACTURER": "openHop", + "MODEL": "Repeater", + "CONF_HOST": "host", + "CONF_PORT": "port", + "DATA_SIZE_SENSOR_KEYS": set(), + "get_repeater_name_from_stats": lambda stats: None, + "PyMCSensorDescription": SimpleNamespace, + "SensorStateClass": SimpleNamespace(TOTAL_INCREASING="total_increasing", MEASUREMENT="measurement", TOTAL="total"), + "EntityCategory": SimpleNamespace(DIAGNOSTIC="diagnostic"), + } + exec(compile(ast.Module(body=nodes, type_ignores=[]), "sensor.py", "exec"), cls.scope) + table = next(node.value for node in cls.module.body if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "SENSORS") + assert isinstance(table, ast.Tuple) + cls.descriptions = {} + for node in table.elts: + assert isinstance(node, ast.Call) + fields = {kw.arg: kw.value for kw in node.keywords} + key = ast.literal_eval(fields["key"]) + if key in COUNTERS: + if key in cls.descriptions: + raise AssertionError(f"Duplicate counter description: {key}") + cls.descriptions[key] = eval(compile(ast.Expression(node), "sensor.py", "eval"), cls.scope) + + def description(self, key): + self.assertIn(key, self.descriptions, f"Missing aggregate counter {key}") + return self.descriptions[key] + + def test_description_metadata(self): + self.assertEqual(set(self.descriptions), set(COUNTERS)) + for key, name in COUNTERS.items(): + with self.subTest(key=key): + description = self.description(key) + self.assertEqual(description.key, key) + self.assertEqual(description.translation_key, key) + self.assertEqual(description.name, name) + self.assertEqual(description.state_class, "total_increasing") + self.assertEqual(description.native_unit_of_measurement, "packets") + self.assertEqual(description.entity_category, "diagnostic") + self.assertIsNone(getattr(description, "device_class", None)) + + def test_each_value_uses_aggregate_stats_not_radio_or_packet_history(self): + stats = {key: index for index, key in enumerate(COUNTERS, start=11)} + data = {"stats": {**stats, "radios": [{key: 999 for key in COUNTERS}]}, + "packet_stats": {key: 888 for key in COUNTERS}} + for key in COUNTERS: + with self.subTest(key=key): + self.assertEqual(self.description(key).value_fn(data), stats[key]) + + def test_explicit_zero_is_preserved(self): + for key in COUNTERS: + with self.subTest(key=key): + self.assertEqual(self.description(key).value_fn({"stats": {key: 0}}), 0) + + def test_missing_null_and_failed_stats_are_unknown(self): + for key in COUNTERS: + for data in ({}, {"stats": {}}, {"stats": None}, {"stats": []}, + {"stats": {"error": "offline"}}, {"stats": {key: None}}): + with self.subTest(key=key, data=data): + self.assertIsNone(self.description(key).value_fn(data)) + + def test_real_entity_preserves_resets_and_parent_identity_without_polling(self): + entity_type = self.scope["PyMCSensorEntity"] + for unique_id in ("example-repeater", None): + entry = SimpleNamespace(unique_id=unique_id, entry_id="example-entry", title="Example", + data={"host": "example.invalid", "port": 8000}, options={}) + coordinator = SimpleNamespace(data={"stats": {}}) # No API or refresh method. + for key in COUNTERS: + with self.subTest(key=key, unique_id=unique_id): + entity = entity_type(entry, coordinator, self.description(key)) + self.assertEqual(entity._attr_unique_id, f"{unique_id or entry.entry_id}_{key}") + self.assertEqual(entity.device_info["identifiers"], {("pymc_repeater", unique_id or entry.entry_id)}) + self.assertEqual(entity.native_unit_of_measurement, "packets") + observed = [] + for value in (100, 105, None, 0, 2, 1): + coordinator.data = {"stats": {key: value}} if value is not None else {"stats": {}} + observed.append(entity.native_value) + self.assertEqual(observed, [100, 105, None, 0, 2, 1]) + setup = next(node for node in self.module.body if getattr(node, "name", None) == "async_setup_entry") + self.assertIn("PyMCSensorEntity(entry, coordinator, description) for description in SENSORS", ast.unparse(setup)) + + def test_english_translations_match_exact_names(self): + translations = json.loads((COMPONENT / "translations/en.json").read_text())["entity"]["sensor"] + for key, name in COUNTERS.items(): + with self.subTest(key=key): + self.assertEqual(translations.get(key), {"name": name}) + + def test_dashboard_counters_are_only_in_existing_packet_flow_card(self): + dashboard = (ROOT / "dashboards/openhop_repeater_dashboard.yaml").read_text() + # Source boundaries keep this contract test standard-library-only. + packet_flow = dashboard.split(" title: Packet Flow\n", 1)[1].split(" grid_options:", 1)[0] + overview = dashboard.split(" heading: Recent trends", 1)[0] + for name in COUNTERS.values(): + entity_id = "sensor.REPEATER_SLUG_" + name.lower().replace(" ", "_") + with self.subTest(entity_id=entity_id): + self.assertEqual(dashboard.count(f"- entity: {entity_id}\n"), 1) + self.assertIn(f"- entity: {entity_id}\n name: {name}\n", packet_flow) + self.assertNotIn(entity_id, overview) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_update_channel_polling.py b/tests/test_update_channel_polling.py new file mode 100644 index 0000000..46f4bfb --- /dev/null +++ b/tests/test_update_channel_polling.py @@ -0,0 +1,53 @@ +"""Ensure automatic polling reads update state without initiating checks.""" +import ast +import unittest + +from test_installed_api_behavior import COMPONENT, client_namespace + + +class UpdatePollingTests(unittest.IsolatedAsyncioTestCase): + async def test_polling_never_discovers_branches_or_forces_update_checks(self): + ns = client_namespace() + client = object.__new__(ns["PyMCRepeaterApiClient"]) + calls = [] + + async def record(name): + calls.append(name) + return {} + + for name in dir(client): + if name.startswith("async_get_") or name == "async_update_check": + async def request(*args, _name=name, **kwargs): + return await record(_name) + setattr(client, name, request) + for _ in range(3): + await client.async_fetch_all() + self.assertNotIn("async_get_update_channels", calls) + self.assertNotIn("async_update_check", calls) + self.assertEqual(calls.count("async_get_update_status"), 3) + self.assertEqual(calls.count("async_get_stats"), 3) + + async def test_explicit_update_check_sends_force(self): + ns = client_namespace() + client = object.__new__(ns["PyMCRepeaterApiClient"]) + calls = [] + + async def request(*args, **kwargs): + calls.append((args, kwargs)) + return {} + + client._async_request_wrapped = request + await client.async_update_check(force=True) + self.assertEqual(calls, [(("POST", "/api/update/check"), {"json_body": {"force": True}})]) + + def test_check_button_keeps_explicit_force_action(self): + tree = ast.parse((COMPONENT / "button.py").read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fields = {kw.arg: kw.value for kw in node.keywords} + key = fields.get("key") + if isinstance(key, ast.Constant) and key.value == "update_check": + self.assertIn("async_update_check(force=True)", ast.unparse(fields["press_fn"])) + return + self.fail("Check for updates button missing") From 601669d60870c11265f4154dc2af7f6afc156959 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:13:39 -0400 Subject: [PATCH 08/12] docs: consolidate changes under 1.2.0 --- CHANGELOG.md | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc93736..cbb9f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,23 +2,11 @@ ## Unreleased -### Added - -- Six repeater-wide flood/direct received, transmitted, and duplicate packet counters using existing stats polling, with restart-aware totals and comprehensive dashboard rows. - -### Changed - -- Show readable software-update dashboard status, including GitHub Limited, Not checked, Up to date, Update available, Checking, Installing, and Check failed, while preserving explicit check/install controls. - -### Fixed - -- Remove GitHub-backed update-channel discovery from automatic polling. Continue reading cached local update status and preserve explicit user-triggered update checks without slowing normal telemetry. -- Preserve unknown native update state before a successful update check instead of reporting the installed version as up to date. - ## 1.2.0 ### Added +- Six repeater-wide flood/direct received, transmitted, and duplicate packet counters using existing stats polling, with restart-aware totals and comprehensive dashboard rows. - Radio child devices with supported read-only frequency, bandwidth, TX power, spreading factor, coding rate, and preamble settings; preserved existing entity IDs and single-radio compatibility. - Component-health diagnostics, last successful poll, source age and stale-reading indicators, with unavailable states for stale, failed, or invalid measurements. - Native Home Assistant update entity with explicit installation on the selected channel, while preserving existing update sensors, buttons, and actions. @@ -32,6 +20,7 @@ ### Changed +- Show readable software-update dashboard status, including GitHub Limited, Not checked, Up to date, Update available, Checking, Installing, and Check failed, while preserving explicit check/install controls. - Redesigned the comprehensive built-in dashboard with a compact operational overview and trends first, controls next, and grouped diagnostics last. Responsive Sections and natural-height card stacks avoid stretched diagnostic cards; labels and badges are shortened and explicit. - Added a separate compact operations dashboard and documented replacement of dynamic entity IDs using the correct Repeater and its radio child devices. - Documented multi-radio `radio_id`, MQTT custom base topics, and neighbor-publisher fields on existing raw configuration actions. @@ -40,6 +29,8 @@ ### Fixed +- Remove GitHub-backed update-channel discovery from automatic polling. Continue reading cached local update status and preserve explicit user-triggered update checks without slowing normal telemetry. +- Preserve unknown native update state before a successful update check instead of reporting the installed version as up to date. - Allowed neighbor-scope queries to wait through the Repeater's 45-second response window instead of using the normal 10-second API timeout. - Aligned flood advert interval validation with the Repeater's supported `0` or `3–168` hour values. - Made modem battery/solar percentage displays tolerate Home Assistant area prefixes and numeric entity-ID suffixes. From 36fc9f186407afcca8171a6c936a556927cef994 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:19:52 -0400 Subject: [PATCH 09/12] docs: match 1.2.0 changelog to existing release style --- CHANGELOG.md | 52 ++++++++++++++++++++++------------------------------ 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb9f10..9057596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,36 +4,28 @@ ## 1.2.0 -### Added - -- Six repeater-wide flood/direct received, transmitted, and duplicate packet counters using existing stats polling, with restart-aware totals and comprehensive dashboard rows. -- Radio child devices with supported read-only frequency, bandwidth, TX power, spreading factor, coding rate, and preamble settings; preserved existing entity IDs and single-radio compatibility. -- Component-health diagnostics, last successful poll, source age and stale-reading indicators, with unavailable states for stale, failed, or invalid measurements. -- Native Home Assistant update entity with explicit installation on the selected channel, while preserving existing update sensors, buttons, and actions. -- Aggregate plugin counts and individual plugin version, state, enabled, running, and problem diagnostics without exposing plugin settings, filesystem paths, or repository metadata. -- Battery, voltage, current, power, and temperature device classes for supported readings, including numeric-string normalization, BME280 pressure, and an additive modem battery-percentage alias. -- Charging, discharging, and neutral battery trends derived from fresh signed charge-rate readings in `%/h`, without inferring electrical current or a full-battery state. -- Seven opt-in alert blueprints for availability, MQTT disconnection, low battery, high temperature, stale readings, plugin failure, and available updates. -- Optional `bucket_seconds` on the neighbor-history action, preserving raw rows when omitted. -- MQTT neighbors publication status, a manual publication control, stored neighbor-scope retrieval, and one-neighbor scope queries. -- Independent flood/direct advert controls and schedules, plus radio-stack mode and configured-radio-count diagnostics. - -### Changed - -- Show readable software-update dashboard status, including GitHub Limited, Not checked, Up to date, Update available, Checking, Installing, and Check failed, while preserving explicit check/install controls. -- Redesigned the comprehensive built-in dashboard with a compact operational overview and trends first, controls next, and grouped diagnostics last. Responsive Sections and natural-height card stacks avoid stretched diagnostic cards; labels and badges are shortened and explicit. -- Added a separate compact operations dashboard and documented replacement of dynamic entity IDs using the correct Repeater and its radio child devices. -- Documented multi-radio `radio_id`, MQTT custom base topics, and neighbor-publisher fields on existing raw configuration actions. -- Expanded diagnostics redaction and stopped retaining raw transport-key and private identity configuration in coordinator polling. -- Expanded English translations and executable client, monitoring, update, and dashboard regression coverage. - -### Fixed - -- Remove GitHub-backed update-channel discovery from automatic polling. Continue reading cached local update status and preserve explicit user-triggered update checks without slowing normal telemetry. -- Preserve unknown native update state before a successful update check instead of reporting the installed version as up to date. -- Allowed neighbor-scope queries to wait through the Repeater's 45-second response window instead of using the normal 10-second API timeout. -- Aligned flood advert interval validation with the Repeater's supported `0` or `3–168` hour values. -- Made modem battery/solar percentage displays tolerate Home Assistant area prefixes and numeric entity-ID suffixes. +- added repeater-wide flood/direct received, transmitted, and duplicate packet counters using existing stats polling, with restart-aware totals and dashboard rows +- added radio child devices with read-only frequency, bandwidth, TX power, spreading factor, coding rate, and preamble settings while preserving existing entity IDs and single-radio support +- added component-health diagnostics, last successful poll, source age, and stale-reading indicators; stale, failed, or invalid measurements become unavailable +- added a native Home Assistant update entity with explicit installation on the selected channel while preserving existing update sensors, buttons, and actions +- added aggregate plugin counts and individual version, state, enabled, running, and problem diagnostics without exposing settings, paths, or repository metadata +- added battery, voltage, current, power, and temperature device classes, numeric-string normalization, BME280 pressure, and a modem battery-percentage alias +- added charging, discharging, and neutral battery trends from fresh signed charge-rate readings in `%/h`, without inferring electrical current or a full-battery state +- added seven opt-in alert blueprints for availability, MQTT disconnection, low battery, high temperature, stale readings, plugin failure, and available updates +- added optional `bucket_seconds` to neighbor-history actions while preserving raw rows when omitted +- added MQTT neighbor-publication status and controls, stored neighbor-scope retrieval, and one-neighbor scope queries +- added independent flood/direct advert controls and schedules, radio-stack mode, and configured-radio-count diagnostics +- added readable dashboard update status, including GitHub Limited, Not checked, Up to date, Update available, Checking, Installing, and Check failed, while retaining check/install controls +- reorganized the comprehensive dashboard with overview and trends first, controls next, and grouped diagnostics last; responsive sections and natural-height stacks avoid stretched cards +- added a compact operations dashboard and documented dynamic entity-ID replacement for repeaters and radio child devices +- documented multi-radio `radio_id`, MQTT custom base topics, and neighbor-publisher fields on raw configuration actions +- expanded diagnostics redaction and removed raw transport-key and private identity configuration from coordinator polling +- expanded English translations and client, monitoring, update, and dashboard regression tests +- removed GitHub-backed update-channel discovery from automatic polling while retaining cached local update status and manual checks +- fixed the native update entity reporting up to date before a successful check; unchecked results remain unknown +- increased neighbor-scope query timeout to cover the repeater's 45-second response window instead of the normal 10 seconds +- aligned flood advert interval validation with the repeater's supported `0` or `3–168` hour values +- fixed modem battery/solar percentage displays with Home Assistant area prefixes and numeric entity-ID suffixes ## 1.1.6 From 550b5c9db1e7b2fa0bd04e0257468887b9e9cfa2 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:56:53 -0400 Subject: [PATCH 10/12] feat: preserve radio identities and schedule hourly update checks --- CHANGELOG.md | 3 + README.md | 2 +- custom_components/pymc_repeater/__init__.py | 13 ++ .../pymc_repeater/config_flow.py | 26 ++- custom_components/pymc_repeater/const.py | 1 + .../pymc_repeater/coordinator.py | 46 +++- custom_components/pymc_repeater/monitoring.py | 97 +++++++- .../pymc_repeater/monitoring_entities.py | 7 +- .../pymc_repeater/radio_lifecycle.py | 167 ++++++++++++++ .../pymc_repeater/translations/en.json | 9 +- docs/operational-monitoring.md | 35 ++- tests/test_hourly_update_check.py | 191 ++++++++++++++++ tests/test_radio_aliases.py | 201 ++++++++++++++++ tests/test_radio_device_removal.py | 40 ++++ tests/test_radio_lifecycle.py | 215 ++++++++++++++++++ 15 files changed, 1040 insertions(+), 13 deletions(-) create mode 100644 custom_components/pymc_repeater/radio_lifecycle.py create mode 100644 tests/test_hourly_update_check.py create mode 100644 tests/test_radio_aliases.py create mode 100644 tests/test_radio_device_removal.py create mode 100644 tests/test_radio_lifecycle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9057596..6306adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## 1.2.0 +- added automatic retirement and restoration of removed radio devices while preserving custom names and registry IDs, with safe explicit removal of confirmed absent radios +- added explicit radio ID aliases to preserve existing Home Assistant radio devices, custom names, and entity IDs when a repeater's runtime radio ID changes +- added scheduled repeater update checks at one minute past each hour in Home Assistant's timezone, respecting active checks, installations, and GitHub rate-limit holds without fetching branch lists - added repeater-wide flood/direct received, transmitted, and duplicate packet counters using existing stats polling, with restart-aware totals and dashboard rows - added radio child devices with read-only frequency, bandwidth, TX power, spreading factor, coding rate, and preamble settings while preserving existing entity IDs and single-radio support - added component-health diagnostics, last successful poll, source age, and stale-reading indicators; stale, failed, or invalid measurements become unavailable diff --git a/README.md b/README.md index f5eb4cb..c5074c1 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This custom integration connects Home Assistant directly to the Repeater's local The integration uses coordinated local polling instead of making a separate API request for every entity. The polling interval is configurable in the integration options and defaults to 15 seconds. -Automatic polling reads the Repeater's cached update status; it does not discover GitHub branches or initiate update checks. Use the **Check for updates** button (or the explicit update-check action) to request a fresh check. The channel selector uses `main`, `dev`, and the current channel without querying GitHub. The Repeater's own update-check behavior is unchanged. +Normal polling reads the Repeater's cached update status without discovering GitHub branches. A separate scheduled check runs at one minute past each hour (`HH:01:00`) in Home Assistant's configured timezone, respecting the Repeater's cache and rate-limit hold. It does not check immediately on startup or install anything, and skips a check already in progress or an installation. The **Check for updates** button and explicit action still request a manual check. The channel selector uses `main`, `dev`, and the current channel without querying GitHub. > [!NOTE] > The integration domain and folder remain `pymc_repeater` so existing installations and entity registry entries continue to work. The user-facing name is **openHop Repeater**. diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index ad89d79..7cae2bb 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -13,10 +13,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntry from .api import PyMCRepeaterApiClient, get_repeater_name_from_stats from .const import CONF_API_TOKEN, DOMAIN from .coordinator import PyMCRepeaterDataUpdateCoordinator +from .radio_lifecycle import RadioLifecycle + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Allow operator removal only of a confirmed absent radio child.""" + lifecycle = hass.data.get(DOMAIN, {}).get(config_entry.entry_id, {}).get("radio_lifecycle") + return lifecycle is not None and lifecycle.can_remove(device_entry) PLATFORMS: list[Platform] = [ Platform.UPDATE, @@ -94,12 +104,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if repeater_name and repeater_name != entry.title: hass.config_entries.async_update_entry(entry, title=repeater_name) + lifecycle = RadioLifecycle(hass, entry, coordinator) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "api": api, "coordinator": coordinator, + "radio_lifecycle": lifecycle, "unsub_options_listener": entry.add_update_listener(_async_update_listener), } + lifecycle.async_start() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/pymc_repeater/config_flow.py b/custom_components/pymc_repeater/config_flow.py index 76b2340..618a017 100644 --- a/custom_components/pymc_repeater/config_flow.py +++ b/custom_components/pymc_repeater/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import json import logging from typing import Any @@ -26,6 +27,7 @@ from .const import ( CONF_API_TOKEN, CONF_DATA_SIZE_UNIT, + CONF_RADIO_ID_ALIASES, CONF_SCAN_INTERVAL, CONF_TOKEN_ID, CONF_TOKEN_NAME, @@ -40,6 +42,7 @@ MIN_SCAN_INTERVAL_SECONDS, UPTIME_UNITS, ) +from .monitoring import parse_radio_aliases, radio_source_ids _LOGGER = logging.getLogger(__name__) @@ -201,8 +204,22 @@ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the integration options.""" + errors: dict[str, str] = {} if user_input is not None: - return self.async_create_entry(title="", data=user_input) + entry_data = self.hass.data.get(DOMAIN, {}).get(self._config_entry.entry_id, {}) + coordinator = entry_data.get("coordinator") + radio_ids = radio_source_ids(getattr(coordinator, "data", None) or {}) + try: + aliases = parse_radio_aliases( + user_input.get(CONF_RADIO_ID_ALIASES, self._config_entry.options.get(CONF_RADIO_ID_ALIASES, {})), + radio_ids=radio_ids, + ) + except ValueError: + errors[CONF_RADIO_ID_ALIASES] = "invalid_radio_aliases" + else: + return self.async_create_entry(title="", data={ + **self._config_entry.options, **user_input, CONF_RADIO_ID_ALIASES: aliases, + }) current_unit = self._config_entry.options.get(CONF_UPTIME_UNIT, DEFAULT_UPTIME_UNIT) current_data_size_unit = self._config_entry.options.get( @@ -211,8 +228,13 @@ async def async_step_init( current_scan_interval = self._config_entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL_SECONDS ) + current_aliases = self._config_entry.options.get(CONF_RADIO_ID_ALIASES, {}) + alias_text = current_aliases if isinstance(current_aliases, str) else json.dumps(current_aliases) + if user_input is not None: + alias_text = user_input.get(CONF_RADIO_ID_ALIASES, alias_text) schema = vol.Schema( { + vol.Optional(CONF_RADIO_ID_ALIASES, default=alias_text): str, vol.Required(CONF_DATA_SIZE_UNIT, default=current_data_size_unit): vol.In( DATA_SIZE_UNITS ), @@ -229,4 +251,4 @@ async def async_step_init( ), } ) - return self.async_show_form(step_id="init", data_schema=schema) + return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/custom_components/pymc_repeater/const.py b/custom_components/pymc_repeater/const.py index 95b0160..42edfb0 100644 --- a/custom_components/pymc_repeater/const.py +++ b/custom_components/pymc_repeater/const.py @@ -8,6 +8,7 @@ CONF_TOKEN_NAME = "token_name" CONF_DATA_SIZE_UNIT = "data_size_unit" CONF_SCAN_INTERVAL = "scan_interval" +CONF_RADIO_ID_ALIASES = "radio_id_aliases" CONF_UPTIME_UNIT = "uptime_unit" DEFAULT_PORT = 8000 diff --git a/custom_components/pymc_repeater/coordinator.py b/custom_components/pymc_repeater/coordinator.py index f43f101..389510a 100644 --- a/custom_components/pymc_repeater/coordinator.py +++ b/custom_components/pymc_repeater/coordinator.py @@ -4,13 +4,15 @@ import asyncio import contextlib +from collections.abc import Callable from datetime import datetime, timedelta, timezone import json import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( @@ -56,20 +58,62 @@ def __init__( self.api = api self.last_successful_poll: datetime | None = None self._gps_stream_task: asyncio.Task | None = None + self._unsub_update_check: Callable[[], None] | None = None + self._update_check_running = False + entry.async_on_unload(self._async_cancel_update_check) async def async_start_runtime(self) -> None: """Start background runtime tasks.""" + if self._unsub_update_check is None: + # HA's local-time helper owns timezone/DST handling; omitted hour + # matches every hour. Registration does not check immediately. + self._unsub_update_check = async_track_time_change( + self.hass, self._async_hourly_update_check, minute=1, second=0 + ) 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.""" + self._async_cancel_update_check() 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 + @callback + def _async_cancel_update_check(self) -> None: + """Remove the hourly listener on stop, unload, or setup failure.""" + if self._unsub_update_check is not None: + self._unsub_update_check() + self._unsub_update_check = None + + async def _async_hourly_update_check(self, now: datetime) -> None: + """Request a cache-respecting version check, not branch discovery.""" + if self._unsub_update_check is None or self._update_check_running: + return + status = (self.data or {}).get("update_status") + if isinstance(status, dict) and status.get("state") in ("installing", "checking"): + return + + self._update_check_running = True + try: + try: + # The backend's 10-minute cache expires between hourly checks; + # force=False also preserves its GitHub rate-limit backoff. + await self.api.async_update_check(force=False) + except Exception as err: + _LOGGER.warning("Hourly update check failed: %s", err) + if self._unsub_update_check is not None: + try: + # Read cached status once; normal polling observes completion. + await self.async_request_refresh() + except Exception as err: + _LOGGER.warning("Refresh after hourly update check failed: %s", err) + finally: + self._update_check_running = False + async def _async_update_data(self) -> dict: try: data = await self.api.async_fetch_all() diff --git a/custom_components/pymc_repeater/monitoring.py b/custom_components/pymc_repeater/monitoring.py index 72ce719..6ef8597 100644 --- a/custom_components/pymc_repeater/monitoring.py +++ b/custom_components/pymc_repeater/monitoring.py @@ -1,7 +1,9 @@ """Conservative monitoring normalization independent of Home Assistant.""" from __future__ import annotations +from collections.abc import Iterable from datetime import datetime +import json from math import isfinite from time import time from typing import Any @@ -78,25 +80,95 @@ def plugin_problem(plugin: dict) -> bool | None: return None -def radio_inventory(data: dict) -> dict[str, dict]: +def parse_radio_aliases(value: Any, *, radio_ids: Iterable[str] = ()) -> dict[str, str]: + """Validate explicit runtime-ID -> HA-ID aliases without normalizing IDs. + + Chains (including self aliases/cycles), duplicate keys/targets, and alias + pairs whose source and target are both declared at runtime are rejected. + Empty objects disable aliasing; invalid persisted values must fail closed. + """ + def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result = {} + for key, target in pairs: + if key in result: + raise ValueError("Duplicate radio alias source") + result[key] = target + return result + + if isinstance(value, str): + try: + value = json.loads(value, object_pairs_hook=unique_object) + except (ValueError, RecursionError) as err: + raise ValueError("Invalid radio alias JSON") from err + if not isinstance(value, dict): + raise ValueError("Radio aliases must be an object") + for source, target in value.items(): + for identity in (source, target): + if (not isinstance(identity, str) or not identity or not identity.isprintable() + or any(char.isspace() for char in identity)): + raise ValueError("Radio IDs must be nonempty strings without whitespace") + targets = set(value.values()) + if len(targets) != len(value) or targets.intersection(value): + raise ValueError("Radio aliases must be one-to-one without chains or cycles") + occupied = set(radio_ids) + if any(source in occupied and target in occupied for source, target in value.items()): + raise ValueError("Radio alias source and target are both runtime IDs") + return dict(value) + + +_NO_RADIO_ALIASES = object() + + +def radio_source_ids(data: dict) -> set[str]: + """Return all declared runtime IDs, including ambiguous rows, for safety.""" + stats = data.get("stats") + if not isinstance(stats, dict): + return set() + stack = stats.get("radio_stack") + ids = stack.get("radio_ids") if isinstance(stack, dict) else None + result = {rid for rid in ids if isinstance(rid, str) and rid} if isinstance(ids, list) else set() + rows = stats.get("radios") + for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict): + result.update(value for key in ("id", "radio_id") + if isinstance(value := row.get(key), str) and value) + return result + + +def radio_inventory(data: dict, aliases: Any = _NO_RADIO_ALIASES) -> dict[str, dict]: """Expose allowlisted radio configuration, not aggregate health or traffic. Installed /api/stats radios[] is configuration, NOT per-radio telemetry. """ stats = data.get("stats") - if not isinstance(stats, dict) or stats.get("error"): + if not isinstance(stats, dict) or stats.get("error") or stats.get("success") is False: return {} stack = stats.get("radio_stack") stack = stack if isinstance(stack, dict) else {} ids = stack.get("radio_ids") - result = {rid: {} for rid in ids if isinstance(rid, str) and rid} if isinstance(ids, list) else {} + result: dict[str, dict] = {} + ambiguous = set() + for rid in ids if isinstance(ids, list) else []: + if not isinstance(rid, str) or not rid: + continue + if rid in result: + ambiguous.add(rid) + result[rid] = {} rows = stats.get("radios") + seen_rows = set() for row in rows if isinstance(rows, list) else []: if not isinstance(row, dict): continue + if "id" in row and "radio_id" in row and row["id"] != row["radio_id"]: + ambiguous.update(value for value in (row["id"], row["radio_id"]) if isinstance(value, str)) + continue rid = row.get("id") or row.get("radio_id") if not isinstance(rid, str) or not rid: continue + if rid in seen_rows: + ambiguous.add(rid) + continue + seen_rows.add(rid) result.setdefault(rid, {}) if isinstance(row.get("type"), str): result[rid]["type"] = row["type"] @@ -118,7 +190,24 @@ def radio_inventory(data: dict) -> dict[str, dict]: value = finite_number(settings.get(field)) if value is not None: result[default].setdefault(field, value) - return result + try: + mapping = parse_radio_aliases({} if aliases is _NO_RADIO_ALIASES else aliases) + except ValueError: + return {} + # An explicit alias joins two alternate names for the same identity. The + # canonical name remains valid when the alias source disappears (for example + # multi -> single mode). Only simultaneous declarations are a collision; + # include malformed/ambiguous rows so they cannot bypass that protection. + occupied = radio_source_ids(data) + conflicts = { + rid for source, target in mapping.items() + if source in occupied and target in occupied + for rid in (source, target) + } + return { + mapping.get(rid, rid): radio for rid, radio in result.items() + if rid not in ambiguous and rid not in conflicts + } def measurement_class(field: str) -> str | None: diff --git a/custom_components/pymc_repeater/monitoring_entities.py b/custom_components/pymc_repeater/monitoring_entities.py index 059c192..e19fb9e 100644 --- a/custom_components/pymc_repeater/monitoring_entities.py +++ b/custom_components/pymc_repeater/monitoring_entities.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass from homeassistant.helpers.entity import DeviceInfo, EntityCategory -from .const import DOMAIN, MANUFACTURER +from .const import CONF_RADIO_ID_ALIASES, DOMAIN, MANUFACTURER from .monitoring import RADIO_FIELDS, charge_state, plugin_problem, radio_inventory, reading_age, reading_stale from .sensor import PyMCBaseEntity, _external_sensor_identity, _external_sensor_readings, _nested @@ -26,7 +26,10 @@ def _snapshot(coordinator, binary: bool) -> dict: ) else: values[("component", "api", "last_success")] = getattr(coordinator, "last_successful_poll", None) - for radio_id, radio in radio_inventory(data).items(): + entry = getattr(coordinator, "config_entry", None) + aliases = entry.options.get(CONF_RADIO_ID_ALIASES, {}) if entry is not None else {} + # Invalid stored values fail closed, never create replacement identities. + for radio_id, radio in radio_inventory(data, aliases).items(): values[("radio", radio_id, "inventory")] = radio.get("type", "configured") for field in RADIO_FIELDS: if field in radio: diff --git a/custom_components/pymc_repeater/radio_lifecycle.py b/custom_components/pymc_repeater/radio_lifecycle.py new file mode 100644 index 0000000..2608a31 --- /dev/null +++ b/custom_components/pymc_repeater/radio_lifecycle.py @@ -0,0 +1,167 @@ +"""Retire missing radio children reversibly, never delete registry identities.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import CONF_RADIO_ID_ALIASES, DOMAIN +from .coordinator import PyMCRepeaterDataUpdateCoordinator +from .monitoring import parse_radio_aliases, radio_inventory + + +def _has_error(value: Any) -> bool: + """Any endpoint failure makes a full snapshot unsuitable for retirement.""" + if isinstance(value, dict): + return bool(value.get("error")) or value.get("success") is False or any( + _has_error(child) for child in value.values() + ) + if isinstance(value, list): + return any(_has_error(child) for child in value) + return False + + +def _valid_id(value: Any) -> bool: + return (isinstance(value, str) and bool(value) and value.isprintable() + and not any(char.isspace() for char in value)) + + +def authoritative_radio_ids( + coordinator: PyMCRepeaterDataUpdateCoordinator, entry: ConfigEntry +) -> set[str] | None: + """Return canonical IDs only when the complete inventory is unambiguous. + + Empty inventories are deliberately unknown: neither supported mode proves a + healthy zero-radio topology. Optional settings rows may be omitted, but any + supplied rows must agree with the authoritative stack IDs. Validate before + normalization, which intentionally drops ambiguous telemetry rows. + """ + if not coordinator.last_update_success or coordinator.last_successful_poll is None: + return None + data = coordinator.data + if not isinstance(data, dict): + return None + stats = data.get("stats") + if not isinstance(stats, dict) or stats.get("error") or stats.get("success") is False: + return None + try: + if _has_error({"stack": stats.get("radio_stack"), "rows": stats.get("radios")}): + return None + except RecursionError: + return None + stack = stats.get("radio_stack") + if not isinstance(stack, dict) or stack.get("mode") not in ("single", "multi"): + return None + ids = stack.get("radio_ids") + if not isinstance(ids, list) or not ids or not all(_valid_id(rid) for rid in ids): + return None + sources = set(ids) + if len(sources) != len(ids) or (stack["mode"] == "single" and len(ids) != 1): + return None + rows = stats.get("radios", []) + if not isinstance(rows, list): + return None + seen = set() + for row in rows: + if not isinstance(row, dict): + return None + if "id" in row and "radio_id" in row and row["id"] != row["radio_id"]: + return None + rid = row.get("id", row.get("radio_id")) + if not _valid_id(rid) or rid not in sources or rid in seen: + return None + if "radio" in row and not isinstance(row["radio"], dict): + return None + seen.add(rid) + try: + aliases = parse_radio_aliases(entry.options.get(CONF_RADIO_ID_ALIASES, {})) + except ValueError: + return None + # A target returning alone is the original canonical radio, not a collision. + # Both runtime identities present at once are ambiguous: do not retire ANY. + if any(source in sources and target in sources for source, target in aliases.items()): + return None + canonical = {aliases.get(rid, rid) for rid in sources} + if len(canonical) != len(sources) or set(radio_inventory(data, aliases)) != canonical: + return None + return canonical + + +class RadioLifecycle: + """Entry-owned full-poll listener with two-snapshot absence confirmation. + + Only disabled_by is written. HA's native device/entity registry propagation + disables enabled child entities with DEVICE and restores only those entities + when the device returns; the native config-entry listener schedules reload. + No entity names, unique IDs, entity IDs, histories, or alias options change. + """ + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, + coordinator: PyMCRepeaterDataUpdateCoordinator, + ) -> None: + self._hass = hass + self._entry = entry + self._coordinator = coordinator + self._last_poll: datetime | None = None + self._absences: dict[str, int] = {} + self._started = False + + def async_start(self) -> None: + """Register once after first refresh; entry unload owns the subscription.""" + if self._started: + return + self._started = True + self._entry.async_on_unload( + self._coordinator.async_add_listener(self.async_reconcile) + ) + self.async_reconcile() + + def _radio_id(self, device: dr.DeviceEntry) -> str | None: + """Manage only exclusively owned, exact single-identifier radio children.""" + if device.config_entries != {self._entry.entry_id} or len(device.identifiers) != 1: + return None + domain, identifier = next(iter(device.identifiers)) + prefix = f"{self._entry.unique_id or self._entry.entry_id}_radio_" + if domain != DOMAIN or not isinstance(identifier, str) or not identifier.startswith(prefix): + return None + rid = identifier[len(prefix):] + return rid if _valid_id(rid) else None + + def async_reconcile(self) -> None: + """Count only new successful full polls, never GPS listener notifications.""" + active = authoritative_radio_ids(self._coordinator, self._entry) + stamp = self._coordinator.last_successful_poll + if active is None or not isinstance(stamp, datetime) or stamp.tzinfo is None: + self._absences.clear() + return + if self._last_poll is not None and stamp <= self._last_poll: + return + self._last_poll = stamp + registry = dr.async_get(self._hass) + absent = {} + for device in dr.async_entries_for_config_entry(registry, self._entry.entry_id): + rid = self._radio_id(device) + if rid is None: + continue + if rid in active: + if device.disabled_by is dr.DeviceEntryDisabler.INTEGRATION: + registry.async_update_device(device.id, disabled_by=None) + continue + absent[device.id] = min(2, self._absences.get(device.id, 0) + 1) + if absent[device.id] >= 2 and device.disabled_by is None: + registry.async_update_device( + device.id, disabled_by=dr.DeviceEntryDisabler.INTEGRATION + ) + self._absences = absent + + def can_remove(self, device: dr.DeviceEntry) -> bool: + """Authorize explicit HA removal only after confirmed, still-valid absence.""" + active = authoritative_radio_ids(self._coordinator, self._entry) + rid = self._radio_id(device) + return (active is not None and rid is not None and rid not in active + and self._coordinator.last_successful_poll == self._last_poll + and self._absences.get(device.id, 0) >= 2) diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index 0f70a81..86993ad 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -37,9 +37,16 @@ "data": { "data_size_unit": "Data size display unit", "uptime_unit": "System uptime display unit", - "scan_interval": "Integration refresh interval (seconds)" + "scan_interval": "Integration refresh interval (seconds)", + "radio_id_aliases": "Radio ID aliases (JSON)" + }, + "data_description": { + "radio_id_aliases": "Explicitly map a renamed runtime radio ID to its previous Home Assistant radio ID, for example {\"local\": \"radio0\"}. Use only after confirming it is the same physical radio; IDs are case-sensitive, not device names. Use {} for no aliases. IDs must be nonempty strings without whitespace. Targets must be unique, must not be current runtime IDs, and must not also be alias sources (no chains or cycles). Missing sources become unavailable; runtime conflicts are suppressed. Saving reloads the integration; this does not delete existing duplicate devices or entities." } } + }, + "error": { + "invalid_radio_aliases": "Enter a JSON object mapping runtime IDs to previous Home Assistant IDs. Use unique nonempty strings without whitespace, no duplicate keys or targets, and no chains, cycles, or targets already used by runtime radios. Use {} to clear aliases." } }, "entity": { diff --git a/docs/operational-monitoring.md b/docs/operational-monitoring.md index 3115b32..a33fc8c 100644 --- a/docs/operational-monitoring.md +++ b/docs/operational-monitoring.md @@ -18,6 +18,34 @@ Genuine per-radio traffic/health sensors remain unsupported until a verified per-radio telemetry contract exists. Quiet RF is not a failure signal. +## Preserving radio names after a backend ID change + +Renaming a radio device in Home Assistant does not change its identity. If the +Repeater configuration changes the radio ID itself, use **Radio ID aliases (JSON)** +in the integration options to associate the new runtime ID with the existing HA +identity. For example, `{"local": "radio0"}` feeds runtime radio `local` into the +existing `radio0` device and entities, preserving their custom names and IDs. +Other radios remain separate. The same mapping also permits the original +`radio0` ID when `local` is absent, so returning to single-radio mode preserves +that device too. Use `{}` to clear aliases. Only map radios whose physical +identity you have confirmed; mappings are not inferred from frequency, list +position, or display names. Duplicate targets, chains, cycles, and simultaneous +source/target ID collisions are rejected. + +After two distinct successful full polls confirm a radio is absent, its device is +disabled by the integration rather than deleted. HA disables its associated +entities while retaining custom names, device/entity IDs, and history. When the +canonical radio returns, integration-disabled devices are restored; user-disabled +devices and entities are left alone. Removed radios can still be found by showing +disabled devices. Explicit permanent removal is allowed only for confirmed absent +radio children, never active radios or the parent Repeater. Permanent removal does +not preserve registry identity for later re-addition. + +Failed or malformed radio inventories, ambiguous aliases, and empty inventories +never retire devices. Unrelated optional endpoint errors, such as a GitHub +rate-limit error, do not invalidate a healthy radio inventory. GPS notifications +do not count as additional full polls. + ## Aggregate traffic counters Six traffic sensors belong to the parent Repeater device: flood/direct packets @@ -80,8 +108,11 @@ remains unknown rather than claiming the installed version is up to date. A confirmed result requires a completed check state, a valid check timestamp and version data, and no reported error. An unconfirmed result cannot enable native update installation. -It never automatically checks external release services, switches channel, or -installs anything. Explicit Home Assistant `update.install` requests call the +The entity itself never switches channel or installs automatically. The coordinator +requests a non-forced update check at `HH:01:00` in HA's configured timezone, +respecting backend rate-limit holds and skipping active checks/installations. +There is no immediate startup check or automatic branch-list discovery. +Explicit Home Assistant `update.install` requests call the existing install API with `force=false`. Version selection and backup requests are rejected; only a confirmed available update without a status error is installable. No progress percentage is invented. The existing update actions, diff --git a/tests/test_hourly_update_check.py b/tests/test_hourly_update_check.py new file mode 100644 index 0000000..5e71eaa --- /dev/null +++ b/tests/test_hourly_update_check.py @@ -0,0 +1,191 @@ +"""Execute the real coordinator with fake HA scheduling and HTTP boundaries. + +These stdlib tests verify registration and callback behavior, not HA's clock/DST +implementation. Only trusted checked-in source is executed; no live requests. +""" +from __future__ import annotations + +import ast +import asyncio +import contextlib +from datetime import datetime, timedelta, timezone +import json +import logging +from types import SimpleNamespace +import unittest + +from test_installed_api_behavior import COMPONENT, client_namespace + + +class FakeCoordinator: + def __class_getitem__(cls, item): + return cls + + def __init__(self, hass, **kwargs): + self.hass = hass + self.data = {} + self.update_interval = kwargs['update_interval'] + self.refreshes = 0 + self.refresh_error = None + + async def async_request_refresh(self): + self.refreshes += 1 + if self.refresh_error: + raise self.refresh_error + self.data = await self._async_update_data() + + +class HourlyUpdateCheckTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.registrations = [] + self.active = [] + self.unload_callbacks = [] + self.tasks = [] + self.calls = [] + self.check_error = None + self.check_gate = None + + def track(hass, action, **kwargs): + self.registrations.append((hass, action, kwargs)) + self.active.append(action) + def cancel(): + self.active.remove(action) + return cancel + + def create_task(coro): + task = asyncio.create_task(coro) + self.tasks.append(task) + return task + + ns = client_namespace() + api = object.__new__(ns['PyMCRepeaterApiClient']) + async def request(method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if self.check_gate: + await self.check_gate.wait() + if self.check_error: + raise self.check_error + return {'state': 'checking'} + api._async_request_wrapped = request + async def fetch_all(): + return {'update_status': {'state': 'checking'}} + api.async_fetch_all = fetch_all + self.hass = SimpleNamespace(async_create_task=create_task) + entry = SimpleNamespace(options={}, entry_id='test', async_on_unload=self.unload_callbacks.append) + ns.update(DataUpdateCoordinator=FakeCoordinator, asyncio=asyncio, + contextlib=contextlib, datetime=datetime, timedelta=timedelta, + timezone=timezone, json=json, _LOGGER=logging.getLogger('hourly_test'), + callback=lambda fn: fn, async_track_time_change=track, + CONF_SCAN_INTERVAL='scan_interval', DEFAULT_SCAN_INTERVAL_SECONDS=15, + MIN_SCAN_INTERVAL_SECONDS=5, MAX_SCAN_INTERVAL_SECONDS=300, + DOMAIN='pymc_repeater', GPS_STREAM_RETRY_SECONDS=15, + ConfigEntryAuthFailed=type('ConfigEntryAuthFailed', (Exception,), {}), + UpdateFailed=type('UpdateFailed', (Exception,), {})) + tree = ast.parse((COMPONENT / 'coordinator.py').read_text()) + body = [ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)] + body.extend(node for node in tree.body if isinstance(node, ast.ClassDef)) + exec(compile(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[])), 'coordinator.py', 'exec'), ns) + self.coordinator = ns['PyMCRepeaterDataUpdateCoordinator'](self.hass, entry, api) + # GPS transport is unrelated; retain real start/stop task ownership. + async def gps_wait(): + await asyncio.Event().wait() + self.coordinator._async_gps_stream_loop = gps_wait + + async def asyncTearDown(self): + await self.coordinator.async_stop_runtime() + for task in self.tasks: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + async def start(self): + await self.coordinator.async_start_runtime() + self.assertEqual(len(self.active), 1, 'hourly listener must be registered') + return self.active[0] + + async def test_local_hourly_registration_no_startup_check_or_duplicate(self): + await self.start() + await self.coordinator.async_start_runtime() + self.assertEqual(len(self.registrations), 1) + self.assertIs(self.registrations[0][0], self.hass) + self.assertEqual(self.registrations[0][2], {'minute': 1, 'second': 0}) + self.assertEqual(self.calls, []) + self.assertEqual(self.coordinator.refreshes, 0) + self.assertEqual(self.coordinator.update_interval, timedelta(seconds=15)) + self.assertEqual(len(self.tasks), 1) + imports = ast.parse((COMPONENT / 'coordinator.py').read_text()) + self.assertTrue(any(isinstance(n, ast.ImportFrom) and n.module == 'homeassistant.helpers.event' + and any(a.name == 'async_track_time_change' for a in n.names) for n in imports.body)) + + async def test_hourly_check_is_not_forced_then_refreshes_cached_status(self): + action = await self.start() + await action(datetime(2026, 9, 8, 12, 1, tzinfo=timezone.utc)) + self.assertEqual(self.calls, [('POST', '/api/update/check', {'json_body': {'force': False}})]) + self.assertEqual(self.coordinator.refreshes, 1) + self.assertEqual(self.coordinator.data['update_status']['state'], 'checking') + + async def test_busy_backend_skips_check_and_refresh(self): + action = await self.start() + for state in ('installing', 'checking'): + self.coordinator.data = {'update_status': {'state': state}} + await action(None) + self.assertEqual(self.calls, []) + self.assertEqual(self.coordinator.refreshes, 0) + + async def test_missing_or_malformed_optional_status_does_not_break_check(self): + action = await self.start() + for data in (None, {}, {'update_status': None}, {'update_status': []}): + self.coordinator.data = data + await action(None) + self.assertEqual(len(self.calls), 4) + + async def test_errors_are_contained_and_next_hour_can_retry(self): + action = await self.start() + self.check_error = RuntimeError('test check failure') + self.coordinator.refresh_error = RuntimeError('test refresh failure') + with self.assertLogs('hourly_test', level='WARNING'): + await action(None) + self.assertEqual(self.coordinator.refreshes, 1) + self.check_error = self.coordinator.refresh_error = None + await action(None) + self.assertEqual(len(self.calls), 2) + self.assertEqual(self.coordinator.refreshes, 2) + + async def test_overlapping_callbacks_do_not_duplicate_requests(self): + action = await self.start() + self.check_gate = asyncio.Event() + task = asyncio.create_task(action(None)) + await asyncio.sleep(0) + await action(None) + self.assertEqual(len(self.calls), 1) + self.check_gate.set() + await task + + async def test_stop_unregisters_and_queued_callback_is_harmless(self): + action = await self.start() + await self.coordinator.async_stop_runtime() + await self.coordinator.async_stop_runtime() + self.assertEqual(self.active, []) + await action(None) + self.assertEqual(self.calls, []) + await self.start() + self.assertEqual(len(self.registrations), 2) + + async def test_entry_unload_cleans_listener_even_after_failed_setup(self): + action = await self.start() + self.assertTrue(self.unload_callbacks, 'entry must own listener cleanup') + for cancel in self.unload_callbacks: + cancel() + self.assertEqual(self.active, []) + await action(None) + self.assertEqual(self.calls, []) + + async def test_stop_during_request_does_not_refresh_unloaded_entry(self): + action = await self.start() + self.check_gate = asyncio.Event() + task = asyncio.create_task(action(None)) + await asyncio.sleep(0) + await self.coordinator.async_stop_runtime() + self.check_gate.set() + await task + self.assertEqual(self.coordinator.refreshes, 0) diff --git a/tests/test_radio_aliases.py b/tests/test_radio_aliases.py new file mode 100644 index 0000000..4ac37bd --- /dev/null +++ b/tests/test_radio_aliases.py @@ -0,0 +1,201 @@ +"""Radio identity aliases: real source execution with fake HA boundaries.""" +import ast +import asyncio +import copy +import json +from pathlib import Path +import runpy +from types import SimpleNamespace +import unittest + +from test_runtime_monitoring import Base, extracted + +COMPONENT = Path(__file__).resolve().parents[1] / 'custom_components/pymc_repeater' + + +def fixture(*ids): + return {'stats': {'radio_stack': {'mode': 'multi', 'radio_ids': list(ids)}, + 'radios': [{'id': rid, 'type': 'serial', 'radio': { + 'frequency': 915000000, 'tx_power': 22, 'bandwidth': 125000, + 'spreading_factor': 7, 'coding_rate': 5, 'preamble_length': 8}} + for rid in ids]}} + + +def entity_namespace(): + ns = runpy.run_path(str(COMPONENT / 'monitoring.py')) + ns.update(runpy.run_path(str(COMPONENT / 'const.py'))) + ns.update(json=json, PyMCBaseEntity=Base, SensorEntity=type('Sensor', (), {}), + BinarySensorEntity=type('Binary', (), {}), DeviceInfo=dict, + EntityCategory=SimpleNamespace(DIAGNOSTIC='diagnostic'), + SensorDeviceClass=SimpleNamespace(DURATION='duration', TIMESTAMP='timestamp'), + SensorStateClass=SimpleNamespace(MEASUREMENT='measurement'), + BinarySensorDeviceClass=SimpleNamespace(PROBLEM='problem', CONNECTIVITY='connectivity'), + _nested=lambda *args: None, _external_sensor_readings=lambda data: []) + return extracted('monitoring_entities.py', ns) + + +class FakeOptionsFlow: + def async_create_entry(self, **kwargs): + return dict(type='create_entry', **kwargs) + + def async_show_form(self, **kwargs): + return dict(type='form', **kwargs) + + +class RadioAliasTests(unittest.TestCase): + def setUp(self): + self.helpers = runpy.run_path(str(COMPONENT / 'monitoring.py')) + + def parser(self): + self.assertIn('parse_radio_aliases', self.helpers, 'explicit alias validation is missing') + return self.helpers['parse_radio_aliases'] + + def test_alias_parser_accepts_exact_mapping_and_empty(self): + parse = self.parser() + for value in ('{"local":"radio0"}', {'local': 'radio0'}): + self.assertEqual(parse(value), {'local': 'radio0'}) + self.assertEqual(parse('{}'), {}) + self.assertEqual(parse({}), {}) + self.assertEqual(parse({'local': 'radio0'}, radio_ids=iter(['radio0', 'link'])), + {'local': 'radio0'}, 'canonical-only inventory is not a collision') + + def test_alias_parser_rejects_malformed_ambiguous_cycles_and_collisions(self): + parse = self.parser() + invalid = [None, '', 'null', '[]', '{', 1, True, [], {'': 'radio0'}, + {' local': 'radio0'}, {'local': 'radio0 '}, {'lo\ncal': 'radio0'}, + {'local': ''}, {'local': 1}, {1: 'radio0'}, {'local': ['radio0']}, + {'local': 'local'}, {'a': 'b', 'b': 'a'}, {'a': 'b', 'b': 'c'}, + {'local': 'radio0', 'link': 'radio0'}, + '{"local":"radio0","local":"radio1"}'] + for value in invalid: + with self.subTest(value=value), self.assertRaises(ValueError): + parse(value) + with self.assertRaises(ValueError): + parse({'local': 'radio0'}, radio_ids={'local', 'radio0', 'link'}) + + def test_legacy_identity_and_all_seven_entities_survive_rename(self): + ns = entity_namespace() + entry = SimpleNamespace(unique_id='example:8000', entry_id='entry', options={}, async_on_unload=lambda cb: None) + coordinator = SimpleNamespace(data=fixture('radio0'), config_entry=entry, + last_update_success=True, async_add_listener=lambda cb: None) + old = [] + ns['setup_monitoring_sensors'](entry, coordinator, old.extend) + old = {e._key[2]: e for e in old if e._key[0] == 'radio'} + self.assertEqual(len(old), 7) + entry.options = {'radio_id_aliases': {'local': 'radio0'}} + coordinator.data = fixture('local', 'link') + untouched = copy.deepcopy(coordinator.data) + created, listeners = [], [] + coordinator.async_add_listener = lambda cb: listeners.append(cb) + ns['setup_monitoring_sensors'](entry, coordinator, created.extend) + radios = [e for e in created if e._key[0] == 'radio'] + self.assertEqual({e._key[1] for e in radios}, {'radio0', 'link'}) + for entity in radios: + if entity._key[1] == 'radio0': + self.assertEqual(entity._attr_unique_id, old[entity._key[2]]._attr_unique_id) + self.assertEqual(entity.device_info, old[entity._key[2]].device_info) + self.assertTrue(entity.available) + self.assertEqual(entity.native_value, old[entity._key[2]].native_value) + self.assertEqual(coordinator.data, untouched) + count = len(created) + listeners[0]() + self.assertEqual(len(created), count) + coordinator.data = fixture('link') + listeners[0]() + self.assertTrue(all(not e.available for e in radios if e._key[1] == 'radio0')) + coordinator.data = fixture('local', 'link') + listeners[0]() + self.assertEqual(len(created), count) + self.assertTrue(all(e.available for e in radios)) + # Keep the alias installed across the return to legacy single mode. + coordinator.data = fixture('radio0') + coordinator.data['stats']['radio_stack'].update(mode='single', default_radio='radio0') + listeners[0]() + self.assertEqual(len(created), count) + for entity in radios: + if entity._key[1] == 'radio0': + self.assertTrue(entity.available) + self.assertEqual(entity._attr_unique_id, old[entity._key[2]]._attr_unique_id) + self.assertEqual(entity.device_info, old[entity._key[2]].device_info) + self.assertEqual(entity.native_value, old[entity._key[2]].native_value) + else: + self.assertFalse(entity.available) + + def test_runtime_collision_does_not_publish_wrong_radio_or_fallback_identity(self): + ns = entity_namespace() + entry = SimpleNamespace(options={'radio_id_aliases': {'local': 'radio0'}}) + coordinator = SimpleNamespace(data=fixture('local', 'radio0', 'link'), config_entry=entry) + radio_keys = lambda: {key[1] for key in ns['_snapshot'](coordinator, False) if key[0] == 'radio'} + self.assertEqual(radio_keys(), {'link'}) + coordinator.data = fixture('radio0', 'link') + self.assertEqual(radio_keys(), {'radio0', 'link'}, 'canonical ID survives when alias source is absent') + for source, target in (('local', 'radio0'), ('radio0', 'local')): + coordinator.data = fixture(source, 'link') + coordinator.data['stats']['radios'].append({'id': 123, 'radio_id': target}) + self.assertEqual(radio_keys(), {'link'}, 'ambiguous declarations still occupy both identities') + coordinator.data = fixture('radio0', 'link') + entry.options['radio_id_aliases'] = {'local': 'radio0', 'link': 'radio0'} + self.assertEqual(radio_keys(), set(), 'corrupt stored aliases must not silently revert identities') + + def test_invalid_stored_values_fail_closed_without_default_fallback(self): + inventory = self.helpers['radio_inventory'] + for invalid in (None, False, [], 'null', {'local': 'local'}): + with self.subTest(invalid=invalid): + self.assertEqual(inventory(fixture('local', 'link'), invalid), {}) + self.assertEqual(set(inventory(fixture('local', 'link'))), {'local', 'link'}) + + def test_duplicate_or_conflicting_source_rows_are_not_merged(self): + inventory = self.helpers['radio_inventory'] + data = fixture('local', 'link') + data['stats']['radios'].append({'id': 'local', 'radio': {'frequency': 123}}) + self.assertEqual(set(inventory(data)), {'link'}) + data = fixture('local', 'link') + data['stats']['radios'][0]['radio_id'] = 'other' + self.assertEqual(set(inventory(data)), {'link'}) + data = fixture('local', 'link') + data['stats']['radio_stack']['radio_ids'].append('local') + self.assertEqual(set(inventory(data)), {'link'}) + data = fixture('local', 'link') + data['stats']['radios'].append({'id': 123, 'radio_id': 'local'}) + self.assertEqual(set(inventory(data)), {'link'}) + data = fixture('local') + data['stats']['success'] = False + self.assertEqual(inventory(data), {}) + + def test_options_flow_persists_validated_mapping_and_rejects_invalid(self): + ns = dict(self.helpers) + ns.update(runpy.run_path(str(COMPONENT / 'const.py'))) + ns.update(json=json, config_entries=SimpleNamespace(OptionsFlow=FakeOptionsFlow)) + # Fake schema constructors only; execute the real options-flow methods. + ns['vol'] = SimpleNamespace(Schema=lambda value: value, + Required=lambda key, **kwargs: key, + Optional=lambda key, **kwargs: key, + In=lambda value: value, All=lambda *args: args, + Coerce=lambda value: value, Range=lambda **kwargs: kwargs) + tree = ast.parse((COMPONENT / 'config_flow.py').read_text()) + nodes = [ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)] + nodes += [node for node in tree.body if getattr(node, 'name', None) == 'PyMCRepeaterOptionsFlow'] + exec(compile(ast.fix_missing_locations(ast.Module(body=nodes, type_ignores=[])), 'config_flow.py', 'exec'), ns) + entry = SimpleNamespace(entry_id='entry', options={'unrelated': 'preserve'}) + flow = ns['PyMCRepeaterOptionsFlow'](entry) + coordinator = SimpleNamespace(data=fixture('local', 'link')) + flow.hass = SimpleNamespace(data={'pymc_repeater': {'entry': {'coordinator': coordinator}}}) + form = asyncio.run(flow.async_step_init()) + self.assertIn('radio_id_aliases', form['data_schema']) + payload = {'radio_id_aliases': '{"local":"radio0"}', 'scan_interval': 15, + 'uptime_unit': 'hours', 'data_size_unit': 'mebibytes'} + saved = asyncio.run(flow.async_step_init(payload)) + self.assertEqual(saved['data']['radio_id_aliases'], {'local': 'radio0'}) + self.assertEqual(saved['data']['unrelated'], 'preserve') + for value in ('null', '{"a":"b","b":"a"}', '{"local":"link"}'): + result = asyncio.run(flow.async_step_init(dict(payload, radio_id_aliases=value))) + self.assertEqual(result['type'], 'form') + self.assertEqual(result['errors'], {'radio_id_aliases': 'invalid_radio_aliases'}) + coordinator.data = fixture('local', 'link', 'radio0', 'radio0') + self.assertEqual(asyncio.run(flow.async_step_init(payload))['type'], 'form', + 'even ambiguous runtime IDs must block alias targets') + self.assertEqual(asyncio.run(flow.async_step_init(dict(payload, radio_id_aliases='{}')))['data']['radio_id_aliases'], {}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_radio_device_removal.py b/tests/test_radio_device_removal.py new file mode 100644 index 0000000..3933ca4 --- /dev/null +++ b/tests/test_radio_device_removal.py @@ -0,0 +1,40 @@ +"""Exercise the HA removal hook with the real confirmed-absence lifecycle.""" +import ast +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +import unittest + +from test_radio_lifecycle import Registry, load_lifecycle, snapshot + +ROOT = Path(__file__).resolve().parents[1] / 'custom_components/pymc_repeater' + + +class RadioRemovalTests(unittest.IsolatedAsyncioTestCase): + async def test_removal_hook_requires_confirmed_absence(self): + tree = ast.parse((ROOT / '__init__.py').read_text()) + functions = [n for n in tree.body if isinstance(n, ast.AsyncFunctionDef) + and n.name == 'async_remove_config_entry_device'] + self.assertEqual(len(functions), 1) + ns = {'DOMAIN': 'pymc_repeater'} + module = ast.Module(body=[ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0), *functions], type_ignores=[]) + exec(compile(ast.fix_missing_locations(module), 'removal', 'exec'), ns) + registry = Registry() + old = registry.add('local') + active = registry.add('radio0') + entry = SimpleNamespace(entry_id='entry', unique_id='example', options={'radio_id_aliases': {'local': 'radio0'}}) + coordinator = SimpleNamespace(last_update_success=True, data=snapshot('radio0'), + last_successful_poll=datetime(2026, 1, 1, tzinfo=timezone.utc)) + hass = SimpleNamespace(registry=registry, data={'pymc_repeater': {'entry': {}}}) + hook = ns['async_remove_config_entry_device'] + self.assertFalse(await hook(hass, entry, old)) + lifecycle = load_lifecycle()['RadioLifecycle'](hass, entry, coordinator) + hass.data['pymc_repeater']['entry']['radio_lifecycle'] = lifecycle + lifecycle.async_reconcile() + self.assertFalse(await hook(hass, entry, old)) + coordinator.last_successful_poll += timedelta(seconds=15) + lifecycle.async_reconcile() + self.assertTrue(await hook(hass, entry, old)) + self.assertFalse(await hook(hass, entry, active)) + coordinator.last_update_success = False + self.assertFalse(await hook(hass, entry, old)) diff --git a/tests/test_radio_lifecycle.py b/tests/test_radio_lifecycle.py new file mode 100644 index 0000000..4426a8e --- /dev/null +++ b/tests/test_radio_lifecycle.py @@ -0,0 +1,215 @@ +"""Execute lifecycle source with fake HA registry boundaries (not live HA).""" +from __future__ import annotations + +import ast +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from enum import Enum +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import unittest + +ROOT = Path(__file__).resolve().parents[1] / "custom_components/pymc_repeater" + + +class Disabler(Enum): + INTEGRATION = "integration" + USER = "user" + CONFIG_ENTRY = "config_entry" + + +def load_lifecycle(): + path = ROOT / "radio_lifecycle.py" + assert path.exists(), "Radio lifecycle implementation is missing" + spec = importlib.util.spec_from_file_location("lifecycle_monitoring", ROOT / "monitoring.py") + assert spec is not None and spec.loader is not None + monitoring = importlib.util.module_from_spec(spec) + spec.loader.exec_module(monitoring) + tree = ast.parse(path.read_text()) + tree.body = [node for node in tree.body if not isinstance(node, ast.ImportFrom) + or not (node.level or (node.module or "").startswith("homeassistant"))] + ns = {"DOMAIN": "pymc_repeater", "CONF_RADIO_ID_ALIASES": "radio_id_aliases", + "parse_radio_aliases": monitoring.parse_radio_aliases, + "radio_inventory": monitoring.radio_inventory, + "dr": SimpleNamespace(DeviceEntryDisabler=Disabler, + async_get=lambda hass: hass.registry, + async_entries_for_config_entry=lambda reg, eid: [ + d for d in reg.devices.values() if eid in d.config_entries])} + exec(compile(tree, str(path), "exec"), ns) + return ns + + +def snapshot(*ids): + return {"stats": {"radio_stack": {"mode": "single" if len(ids) == 1 else "multi", + "radio_ids": list(ids)}, + "radios": [{"id": rid, "radio": {"frequency": 868000000}} for rid in ids]}} + + +class Registry: + def __init__(self): + self.devices = {} + self.writes = [] + + def add(self, rid, disabled_by=None): + device = SimpleNamespace(id=rid, identifiers={("pymc_repeater", "example_radio_" + rid)}, + config_entries={"entry"}, disabled_by=disabled_by, + name_by_user="Custom " + rid) + self.devices[rid] = device + return device + + def async_update_device(self, device_id, **kwargs): + self.writes.append((device_id, kwargs)) + for key, value in kwargs.items(): + setattr(self.devices[device_id], key, value) + + +class RadioLifecycleTests(unittest.TestCase): + def setUp(self): + self.ns = load_lifecycle() + self.registry = Registry() + self.callbacks = [] + self.unloads = [] + self.entry = SimpleNamespace(entry_id="entry", unique_id="example", options={}, + async_on_unload=self.unloads.append) + def add_listener(callback): + self.callbacks.append(callback) + return lambda: self.callbacks.remove(callback) + self.coordinator = SimpleNamespace(data=snapshot("radio0"), last_update_success=True, + last_successful_poll=datetime(2026, 1, 1, tzinfo=timezone.utc), + async_add_listener=add_listener) + self.hass = SimpleNamespace(registry=self.registry) + self.lifecycle = self.ns["RadioLifecycle"](self.hass, self.entry, self.coordinator) + + def poll(self, *ids): + self.coordinator.data = snapshot(*ids) + self.coordinator.last_successful_poll += timedelta(seconds=15) + self.lifecycle.async_reconcile() + + def test_single_dual_single_dual_preserves_registry_identity_and_names(self): + original = self.registry.add("radio0") + self.lifecycle.async_start() + self.poll("radio0", "link") + link = self.registry.add("link") # Existing monitoring discovery owns additions. + before = (deepcopy(link.identifiers), link.name_by_user) + self.poll("radio0") + self.assertIsNone(link.disabled_by) + self.poll("radio0") + self.assertIs(link.disabled_by, Disabler.INTEGRATION) + self.poll("radio0", "link") + self.assertIsNone(link.disabled_by) + self.assertIs(self.registry.devices["radio0"], original) + self.assertIs(self.registry.devices["link"], link) + self.assertEqual((link.identifiers, link.name_by_user), before) + self.assertTrue(all(set(fields) == {"disabled_by"} for _, fields in self.registry.writes)) + + def test_only_integration_disable_is_reversible(self): + for reason in (Disabler.USER, Disabler.CONFIG_ENTRY, "device"): + with self.subTest(reason=reason): + d = self.registry.add("link", reason) + self.poll("radio0") + self.poll("radio0") + self.poll("radio0", "link") + self.assertEqual(d.disabled_by, reason) + self.assertEqual(self.registry.writes, []) + + def test_duplicate_notifications_and_failed_polls_do_not_confirm_absence(self): + d = self.registry.add("link") + self.lifecycle.async_start() + for _ in range(5): + self.lifecycle.async_reconcile() # GPS has the same full-poll stamp. + self.assertIsNone(d.disabled_by) + self.coordinator.last_update_success = False + self.lifecycle.async_reconcile() + self.coordinator.last_update_success = True + self.poll("radio0") + self.assertIsNone(d.disabled_by) + self.poll("radio0") + self.assertIs(d.disabled_by, Disabler.INTEGRATION) + + def test_bad_inventories_never_mutate_and_reset_confirmation(self): + bad = [None, {}, {"stats": {"error": "offline"}}] + for field, value in (("mode", None), ("radio_ids", []), ("radio_ids", ["radio0", "radio0"]), + ("radio_ids", [None]), ("radio_ids", ["bad id"])): + data = snapshot("radio0") + data["stats"]["radio_stack"][field] = value + bad.append(data) + for rows in (None, {}, [None], [{}], [{"id": "extra"}], + [{"id": "radio0"}, {"id": "radio0"}], + [{"id": "radio0", "radio_id": "other"}]): + data = snapshot("radio0") + data["stats"]["radios"] = rows + bad.append(data) + data = snapshot("radio0") + data["stats"]["radio_stack"]["error"] = "offline" + bad.append(data) + d = self.registry.add("link") + for data in bad: + with self.subTest(data=data): + self.poll("radio0") + self.coordinator.data = data + self.coordinator.last_successful_poll += timedelta(seconds=15) + self.lifecycle.async_reconcile() + self.assertIsNone(d.disabled_by) + self.assertIsNone(self.ns["authoritative_radio_ids"](self.coordinator, self.entry)) + self.assertEqual(self.registry.writes, []) + + def test_unrelated_github_errors_do_not_block_valid_radio_inventory(self): + d = self.registry.add("link") + for _ in range(2): + self.coordinator.data = snapshot("radio0") + self.coordinator.data["update_status"] = {"error": "GitHub API rate limit exceeded"} + self.coordinator.last_successful_poll += timedelta(seconds=15) + self.lifecycle.async_reconcile() + self.assertIs(d.disabled_by, Disabler.INTEGRATION) + + def test_alias_roundtrip_and_collision_fail_closed(self): + self.entry.options = {"radio_id_aliases": {"local": "radio0"}} + d = self.registry.add("radio0", Disabler.INTEGRATION) + self.poll("local", "link") + self.assertIsNone(d.disabled_by) + self.poll("radio0") # Alias source absent; canonical target itself returns. + self.assertIsNone(d.disabled_by) + self.assertEqual(self.ns["authoritative_radio_ids"](self.coordinator, self.entry), {"radio0"}) + self.poll("local", "radio0") + self.assertIsNone(self.ns["authoritative_radio_ids"](self.coordinator, self.entry)) + self.poll("local", "radio0") + self.assertIsNone(d.disabled_by) + self.entry.options["radio_id_aliases"] = {"local": "local"} + self.poll("radio0") + self.assertIsNone(self.ns["authoritative_radio_ids"](self.coordinator, self.entry)) + + def test_unload_and_idempotent_start(self): + self.lifecycle.async_start() + self.lifecycle.async_start() + self.assertEqual(len(self.callbacks), 1) + self.assertEqual(len(self.unloads), 1) + self.unloads[0]() + self.assertEqual(self.callbacks, []) + + def test_explicit_removal_requires_confirmed_absence_and_current_valid_inventory(self): + absent = self.registry.add("link") + active = self.registry.add("radio0") + allowed = self.lifecycle.can_remove + self.lifecycle.async_start() + self.assertFalse(allowed(absent)) + self.poll("radio0") + self.assertTrue(allowed(absent)) + self.assertFalse(allowed(active)) + parent = SimpleNamespace(identifiers={("pymc_repeater", "example")}, config_entries={"entry"}) + self.assertFalse(allowed(parent)) + absent.config_entries.add("another_entry") + self.assertFalse(allowed(absent)) + absent.config_entries.remove("another_entry") + self.coordinator.data = {"stats": {"error": "offline"}} + self.assertFalse(allowed(absent)) + + def test_setup_and_removal_are_wired_to_owned_lifecycle(self): + source = (ROOT / "__init__.py").read_text() + self.assertIn('"radio_lifecycle": lifecycle', source) + self.assertIn('lifecycle.async_start()', source) + self.assertIn('lifecycle.can_remove(device_entry)', source) + + +if __name__ == "__main__": + unittest.main() From 9d4a1af9613cd9ae8e942a025f265292f3b57eac Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:59:49 -0400 Subject: [PATCH 11/12] fix: avoid invalid placeholders in radio alias help --- .../pymc_repeater/translations/en.json | 2 +- tests/test_translation_placeholders.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 tests/test_translation_placeholders.py diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index 86993ad..11b5b29 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -41,7 +41,7 @@ "radio_id_aliases": "Radio ID aliases (JSON)" }, "data_description": { - "radio_id_aliases": "Explicitly map a renamed runtime radio ID to its previous Home Assistant radio ID, for example {\"local\": \"radio0\"}. Use only after confirming it is the same physical radio; IDs are case-sensitive, not device names. Use {} for no aliases. IDs must be nonempty strings without whitespace. Targets must be unique, must not be current runtime IDs, and must not also be alias sources (no chains or cycles). Missing sources become unavailable; runtime conflicts are suppressed. Saving reloads the integration; this does not delete existing duplicate devices or entities." + "radio_id_aliases": "Map renamed runtime radio IDs to their previous Home Assistant IDs using a JSON object, for example source local mapped to target radio0. Confirm that each pair is the same physical radio. IDs are case-sensitive and are not device names. Use an empty JSON object to clear aliases. IDs must be nonempty strings without whitespace. Targets must be unique; chains, cycles, and simultaneous source/target runtime IDs are not allowed. A target may return on its own. Saving reloads the integration. See the operational monitoring documentation for JSON examples and radio retirement behavior." } } }, diff --git a/tests/test_translation_placeholders.py b/tests/test_translation_placeholders.py new file mode 100644 index 0000000..e59587a --- /dev/null +++ b/tests/test_translation_placeholders.py @@ -0,0 +1,15 @@ +"""Guard static radio alias help against accidental HA placeholders.""" +import json +from pathlib import Path +import unittest + + +class TranslationPlaceholderTests(unittest.TestCase): + def test_radio_alias_help_has_no_literal_json_braces(self): + root = Path(__file__).resolve().parents[1] + translations = json.loads((root / 'custom_components/pymc_repeater/translations/en.json').read_text()) + text = translations['options']['step']['init']['data_description']['radio_id_aliases'] + self.assertNotIn('{', text) + self.assertNotIn('}', text) + self.assertIn('local', text) + self.assertIn('radio0', text) From da90d43dde5a1144bebb1204c3d2e9dc041825bf Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:33:15 -0400 Subject: [PATCH 12/12] fix: align radio health and advanced actions with repeater dev --- CHANGELOG.md | 10 ++ README.md | 10 ++ custom_components/pymc_repeater/__init__.py | 30 +++- custom_components/pymc_repeater/api.py | 50 +++++- .../pymc_repeater/binary_sensor.py | 14 +- .../pymc_repeater/diagnostics.py | 1 + custom_components/pymc_repeater/monitoring.py | 40 ++++- .../pymc_repeater/radio_lifecycle.py | 6 +- custom_components/pymc_repeater/sensor.py | 44 +++++ custom_components/pymc_repeater/services.yaml | 9 +- .../pymc_repeater/translations/en.json | 5 + dashboards/openhop_repeater_dashboard.yaml | 20 +++ docs/operational-monitoring.md | 29 ++- tests/test_advanced_service_results.py | 166 ++++++++++++++++++ tests/test_radio_health.py | 112 ++++++++++++ tests/test_radio_inventory.py | 78 ++++++++ tests/test_radio_lifecycle.py | 55 ++++++ tests/test_sensor_freshness_cadence.py | 49 ++++++ 18 files changed, 690 insertions(+), 38 deletions(-) create mode 100644 tests/test_advanced_service_results.py create mode 100644 tests/test_radio_health.py create mode 100644 tests/test_radio_inventory.py create mode 100644 tests/test_sensor_freshness_cadence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6306adb..756a020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ## 1.2.0 +- added aggregate parent radio status and problem entities with a boolean or unknown radio error attribute without exposing raw error text or inferring per-radio health from api connectivity +- added aggregate radio health dashboard rows and a badge and problem banner +- recognized `single_fabric` in reversible radio lifecycle handling and guarded single-radio global settings fallback while preferring configured `radio_type` over legacy `type` +- aligned advanced action http budgets with backend waits for ping and manual cad checks and companion sends and login and commands +- bounded ping reply waits to 1–60 seconds and companion status and telemetry waits to 1–120 seconds with finite http margins +- added optional status and telemetry action responses while preserving calls without responses and keeping results out of polling and entity attributes +- surfaced explicit companion send failures as action errors while retaining compatibility with older results without `sent` +- preferred per-reading `poll_interval_seconds` for source freshness with legacy global-summary fallback only when metadata was absent and unknown freshness for invalid metadata +- documented the required backend cadence metadata change as not yet released or deployed without claiming that an integration-only update established legacy plugin cadence + - added automatic retirement and restoration of removed radio devices while preserving custom names and registry IDs, with safe explicit removal of confirmed absent radios - added explicit radio ID aliases to preserve existing Home Assistant radio devices, custom names, and entity IDs when a repeater's runtime radio ID changes - added scheduled repeater update checks at one minute past each hour in Home Assistant's timezone, respecting active checks, installations, and GitHub rate-limit holds without fetching branch lists diff --git a/README.md b/README.md index c5074c1..2143d9e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,11 @@ The integration exposes Home Assistant actions for supported Repeater operations Open **Developer tools → Actions** and search for `openHop Repeater` or `pymc_repeater` to see the actions and their current fields. +- aligned advanced action http budgets with backend waits plus a finite margin for ping and manual cad checks and companion text sends and login and commands +- bounded ping reply waits to 1–60 seconds with 6 additional http seconds and companion status and telemetry waits to 1–120 seconds with 10 additional http seconds +- added optional `response_variable` support to `pymc_repeater.companion_request_status` and `pymc_repeater.companion_request_telemetry` with unwrapped backend dictionaries and non-dictionary results wrapped under `result` while preserving calls without responses and avoiding polling or entity attributes for these results +- surfaced explicit companion `sent: false` results as action errors without treating omitted `sent` fields on older backends as failures + The raw radio-config action accepts the Repeater dev `radio_id` field for multi-radio targeting and `direct_advert_interval_hours` for the additional advert schedule. The raw MQTT-config action accepts custom `base_topic` values and neighbor-publisher settings supported by current Repeater dev builds. ### Plugin health and bucketed neighbor history @@ -199,6 +204,11 @@ Do not publish your admin password, JWT secret, Home Assistant token, or Repeate ## Operational monitoring +- added parent repeater `radio_status` and `radio_problem` entities from aggregate runtime health rather than api connectivity or configured child radios with `radio_error` exposed only as a boolean or unknown attribute and never raw exception text +- recognized `single_fabric` for single-radio lifecycle handling and guarded global settings fallback to the sole named default while preferring `radio_type` over legacy `type` for configured inventory +- preferred each reading envelope's `poll_interval_seconds` for freshness and retained the legacy global-summary fallback only when that field was absent +- documented that effective per-reading cadence required a companion backend scheduler metadata change not yet released or deployed and that updating this integration alone could not establish legacy plugin cadence + Aggregate flood/direct received, transmitted, and duplicate packet counters are exposed on the parent Repeater device using existing stats polling. These are process-lifetime totals, not per-radio counters or hourly rates; missing values diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index 7cae2bb..161edd4 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntry -from .api import PyMCRepeaterApiClient, get_repeater_name_from_stats +from .api import PyMCRepeaterApiClient, PyMCRepeaterError, get_repeater_name_from_stats from .const import CONF_API_TOKEN, DOMAIN from .coordinator import PyMCRepeaterDataUpdateCoordinator from .radio_lifecycle import RadioLifecycle @@ -171,7 +171,10 @@ async def _with_api( ) -> None: entry_id = _resolve_entry_id(hass, call.data) api: PyMCRepeaterApiClient = hass.data[DOMAIN][entry_id]["api"] - await func(api, entry_id) + try: + await func(api, entry_id) + except PyMCRepeaterError as err: + raise HomeAssistantError(str(err)) from err if refresh: await _async_refresh_entry(hass, entry_id) @@ -184,7 +187,10 @@ async def _with_api_response( ) -> ServiceResponse | None: entry_id = _resolve_entry_id(hass, call.data) api: PyMCRepeaterApiClient = hass.data[DOMAIN][entry_id]["api"] - result = await func(api, entry_id) + try: + result = await func(api, entry_id) + except PyMCRepeaterError as err: + raise HomeAssistantError(str(err)) from err if refresh: await _async_refresh_entry(hass, entry_id) if not always_return and not getattr(call, "return_response", False): @@ -208,7 +214,9 @@ async def _with_api_response( { vol.Optional(CONF_ENTRY_ID): str, vol.Required("target_id"): str, - vol.Optional("timeout", default=10): vol.Coerce(int), + vol.Optional("timeout", default=10): vol.All( + vol.Coerce(int), vol.Range(min=1, max=60) + ), } ), ) @@ -537,7 +545,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_REQUEST_STATUS, - lambda call: _with_api( + lambda call: _with_api_response( call, lambda api, _: api.async_companion_request_status( pub_key=call.data["pub_key"], @@ -550,16 +558,19 @@ async def _with_api_response( { vol.Optional(CONF_ENTRY_ID): str, vol.Required("pub_key"): str, - vol.Optional("timeout", default=15.0): vol.Coerce(float), + vol.Optional("timeout", default=15.0): vol.All( + vol.Coerce(float), vol.Range(min=1, max=120) + ), vol.Optional("companion_name"): str, } ), + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_COMPANION_REQUEST_TELEMETRY, - lambda call: _with_api( + lambda call: _with_api_response( call, lambda api, _: api.async_companion_request_telemetry( pub_key=call.data["pub_key"], @@ -575,13 +586,16 @@ async def _with_api_response( { vol.Optional(CONF_ENTRY_ID): str, vol.Required("pub_key"): str, - vol.Optional("timeout", default=20.0): vol.Coerce(float), + vol.Optional("timeout", default=20.0): vol.All( + vol.Coerce(float), vol.Range(min=1, max=120) + ), vol.Optional("companion_name"): str, vol.Optional("want_base", default=True): bool, vol.Optional("want_location", default=True): bool, vol.Optional("want_environment", default=True): bool, } ), + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index 96202d2..ce46aee 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -700,10 +700,14 @@ async def async_db_purge(self, tables: str | list[str]) -> Any: async def async_ping_neighbor(self, target_id: str, timeout: int = 10) -> Any: """Ping a neighbor.""" + if not 1 <= timeout <= 60: + raise PyMCRepeaterApiError("Ping timeout must be between 1 and 60 seconds") return await self._async_request_wrapped( "POST", "/api/ping_neighbor", json_body={"target_id": target_id, "timeout": timeout}, + # Backend waits timeout + 1; allow another 5 seconds for HTTP. + timeout_seconds=timeout + 6, ) async def async_room_post_message( @@ -790,7 +794,13 @@ async def async_cad_manual_check( if cad_symbol_num is not None: payload["cad_symbol_num"] = cad_symbol_num return await self._async_request_wrapped( - "POST", "/api/cad_manual_check", json_body=payload + "POST", "/api/cad_manual_check", json_body=payload, + # Mirror backend-clamped sample/time bounds and its 2-second margin, + # then allow 5 seconds for HTTP. Calibration start is asynchronous. + timeout_seconds=max( + REQUEST_TIMEOUT, + min(32, max(1, samples)) * min(5000, max(50, cad_timeout_ms)) / 1000 + 7, + ), ) async def async_save_cad_settings( @@ -855,9 +865,14 @@ async def async_companion_send_text( payload: dict[str, Any] = {"pub_key": pub_key, "text": text, "txt_type": txt_type} if companion_name: payload["companion_name"] = companion_name - return await self._async_request_wrapped( - "POST", "/api/companion/send_text", json_body=payload + result = await self._async_request_wrapped( + "POST", "/api/companion/send_text", json_body=payload, + # Companion _run_async defaults to 30 seconds, plus HTTP margin. + timeout_seconds=35, ) + if isinstance(result, dict) and result.get("sent") is False: + raise PyMCRepeaterApiError("Companion did not send the message") + return result async def async_companion_send_channel_message( self, @@ -870,9 +885,14 @@ async def async_companion_send_channel_message( payload: dict[str, Any] = {"channel_idx": channel_idx, "text": text} if companion_name: payload["companion_name"] = companion_name - return await self._async_request_wrapped( - "POST", "/api/companion/send_channel_message", json_body=payload + result = await self._async_request_wrapped( + "POST", "/api/companion/send_channel_message", json_body=payload, + # Companion _run_async defaults to 30 seconds, plus HTTP margin. + timeout_seconds=35, ) + if isinstance(result, dict) and result.get("sent") is False: + raise PyMCRepeaterApiError("Companion did not send the message") + return result async def async_companion_login( self, @@ -886,7 +906,9 @@ async def async_companion_login( if companion_name: payload["companion_name"] = companion_name return await self._async_request_wrapped( - "POST", "/api/companion/login", json_body=payload + "POST", "/api/companion/login", json_body=payload, + # Backend login waits up to 15 seconds. + timeout_seconds=20, ) async def async_companion_request_status( @@ -897,11 +919,15 @@ async def async_companion_request_status( companion_name: str | None = None, ) -> Any: """Request status from a companion target.""" + if not 1 <= timeout <= 120: + raise PyMCRepeaterApiError("Companion timeout must be between 1 and 120 seconds") payload: dict[str, Any] = {"pub_key": pub_key, "timeout": timeout} if companion_name: payload["companion_name"] = companion_name return await self._async_request_wrapped( - "POST", "/api/companion/request_status", json_body=payload + "POST", "/api/companion/request_status", json_body=payload, + # Backend waits timeout + 5; add 5 seconds for HTTP. + timeout_seconds=timeout + 10, ) async def async_companion_request_telemetry( @@ -915,6 +941,8 @@ async def async_companion_request_telemetry( want_environment: bool = True, ) -> Any: """Request telemetry from a companion target.""" + if not 1 <= timeout <= 120: + raise PyMCRepeaterApiError("Companion timeout must be between 1 and 120 seconds") payload: dict[str, Any] = { "pub_key": pub_key, "timeout": timeout, @@ -925,7 +953,9 @@ async def async_companion_request_telemetry( if companion_name: payload["companion_name"] = companion_name return await self._async_request_wrapped( - "POST", "/api/companion/request_telemetry", json_body=payload + "POST", "/api/companion/request_telemetry", json_body=payload, + # Backend waits timeout + 5; add 5 seconds for HTTP. + timeout_seconds=timeout + 10, ) async def async_companion_send_command( @@ -943,7 +973,9 @@ async def async_companion_send_command( if companion_name: payload["companion_name"] = companion_name return await self._async_request_wrapped( - "POST", "/api/companion/send_command", json_body=payload + "POST", "/api/companion/send_command", json_body=payload, + # Backend command waits up to 20 seconds. + timeout_seconds=25, ) async def async_companion_reset_path( diff --git a/custom_components/pymc_repeater/binary_sensor.py b/custom_components/pymc_repeater/binary_sensor.py index 7b51cf6..304789e 100644 --- a/custom_components/pymc_repeater/binary_sensor.py +++ b/custom_components/pymc_repeater/binary_sensor.py @@ -25,6 +25,7 @@ _external_sensor_readings, _lbt_summary, _nested, + _radio_problem, _room_items, ) @@ -33,7 +34,7 @@ class PyMCBinarySensorDescription(BinarySensorEntityDescription): """Binary sensor description.""" - value_fn: Callable[[dict[str, Any]], bool] + value_fn: Callable[[dict[str, Any]], bool | None] def _any_mqtt_connected(data: dict[str, Any]) -> bool: @@ -42,6 +43,15 @@ def _any_mqtt_connected(data: dict[str, Any]) -> bool: BINARY_SENSORS: tuple[PyMCBinarySensorDescription, ...] = ( + PyMCBinarySensorDescription( + key="radio_problem", + translation_key="radio_problem", + name="Radio problem", + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=_radio_problem, + ), PyMCBinarySensorDescription( key="mqtt_handler_active", translation_key="mqtt_handler_active", @@ -214,7 +224,7 @@ def __init__(self, entry: ConfigEntry, coordinator, description: PyMCBinarySenso self._attr_unique_id = f"{entry.unique_id or entry.entry_id}_{description.key}" @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: return self.entity_description.value_fn(self.coordinator.data) diff --git a/custom_components/pymc_repeater/diagnostics.py b/custom_components/pymc_repeater/diagnostics.py index a3be6b2..f9ab11c 100644 --- a/custom_components/pymc_repeater/diagnostics.py +++ b/custom_components/pymc_repeater/diagnostics.py @@ -34,6 +34,7 @@ "identity_hash", "companion_hash", "room_hash", + "radio_error", } diff --git a/custom_components/pymc_repeater/monitoring.py b/custom_components/pymc_repeater/monitoring.py index 6ef8597..5cc53d4 100644 --- a/custom_components/pymc_repeater/monitoring.py +++ b/custom_components/pymc_repeater/monitoring.py @@ -39,9 +39,16 @@ def reading_age(reading: dict, *, now: float | None = None) -> float | None: def reading_stale(reading: dict, interval: Any, *, now: float | None = None) -> bool | None: - """Allow three source polling intervals, with a 60-second minimum.""" + """Allow three source intervals, with a 60-second minimum. + + The reading envelope's poll_interval_seconds is the backend scheduler's + effective cadence, including plugin defaults/overrides. ``interval`` is + only the legacy global-summary fallback when that metadata is absent; + it cannot establish a legacy plugin's actual cadence. Present but invalid + metadata is unknown, not permission to substitute the global interval. + """ age = reading_age(reading, now=now) - cadence = finite_number(interval) + cadence = finite_number(reading.get("poll_interval_seconds", interval)) if age is None or cadence is None or cadence <= 0: return None return age > max(60, cadence * 3) @@ -170,8 +177,11 @@ def radio_inventory(data: dict, aliases: Any = _NO_RADIO_ALIASES) -> dict[str, d continue seen_rows.add(rid) result.setdefault(rid, {}) - if isinstance(row.get("type"), str): - result[rid]["type"] = row["type"] + radio_type = row.get("radio_type") + if not isinstance(radio_type, str): + radio_type = row.get("type") + if isinstance(radio_type, str): + result[rid]["type"] = radio_type settings = row.get("radio") if isinstance(settings, dict): for field in RADIO_FIELDS: @@ -179,11 +189,27 @@ def radio_inventory(data: dict, aliases: Any = _NO_RADIO_ALIASES) -> dict[str, d if value is not None: result[rid][field] = value # Legacy single-radio configuration is global, but belongs only to the - # explicitly named default. Never copy global configuration across a fabric. + # explicitly named sole default, including a single physical radio wrapped + # by the fabric. Validate raw topology before normalization hides bad rows. default = stack.get("default_radio") config = stats.get("config") - if (stack.get("mode") == "single" and len(result) == 1 and isinstance(default, str) and default in result - and isinstance(config, dict) and not config.get("error")): + fallback_rows = stats.get("radios", []) + valid_rows = isinstance(fallback_rows, list) and all( + isinstance(row, dict) + and row.get("id", row.get("radio_id")) == default + and ("radio_id" not in row or row["radio_id"] == default) + and not row.get("error") and row.get("success") is not False + and isinstance(row.get("radio", {}), dict) + and not row.get("radio", {}).get("error") + and row.get("radio", {}).get("success") is not False + for row in fallback_rows + ) + if (stack.get("mode") in ("single", "single_fabric") + and isinstance(default, str) and default and default.isprintable() + and not any(char.isspace() for char in default) + and ids == [default] and set(result) == {default} and not ambiguous + and valid_rows and not stack.get("error") and stack.get("success") is not False + and isinstance(config, dict) and not config.get("error") and config.get("success") is not False): settings = config.get("radio") if isinstance(settings, dict): for field in RADIO_FIELDS: diff --git a/custom_components/pymc_repeater/radio_lifecycle.py b/custom_components/pymc_repeater/radio_lifecycle.py index 2608a31..770ec34 100644 --- a/custom_components/pymc_repeater/radio_lifecycle.py +++ b/custom_components/pymc_repeater/radio_lifecycle.py @@ -34,7 +34,7 @@ def authoritative_radio_ids( ) -> set[str] | None: """Return canonical IDs only when the complete inventory is unambiguous. - Empty inventories are deliberately unknown: neither supported mode proves a + Empty inventories are deliberately unknown: no supported mode proves a healthy zero-radio topology. Optional settings rows may be omitted, but any supplied rows must agree with the authoritative stack IDs. Validate before normalization, which intentionally drops ambiguous telemetry rows. @@ -53,13 +53,13 @@ def authoritative_radio_ids( except RecursionError: return None stack = stats.get("radio_stack") - if not isinstance(stack, dict) or stack.get("mode") not in ("single", "multi"): + if not isinstance(stack, dict) or stack.get("mode") not in ("single", "single_fabric", "multi"): return None ids = stack.get("radio_ids") if not isinstance(ids, list) or not ids or not all(_valid_id(rid) for rid in ids): return None sources = set(ids) - if len(sources) != len(ids) or (stack["mode"] == "single" and len(ids) != 1): + if len(sources) != len(ids) or (stack["mode"] in ("single", "single_fabric") and len(ids) != 1): return None rows = stats.get("radios", []) if not isinstance(rows, list): diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index f10febf..5807c7d 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -72,6 +72,39 @@ def _mqtt_neighbors_status(data: dict[str, Any]) -> dict[str, Any]: return status if isinstance(status, dict) else {} +def _radio_status(data: dict[str, Any]) -> str | None: + """Return only known aggregate runtime states, never inferred RF health.""" + stats = _nested(data, "stats") + if not isinstance(stats, dict) or stats.get("error") or stats.get("success") is False: + return None + status = stats.get("radio_status") + return status if isinstance(status, str) and status in ("ok", "degraded", "disabled") else None + + +def _radio_error(data: dict[str, Any]) -> bool | None: + """Expose presence only: backend exception text can contain private data.""" + stats = _nested(data, "stats") + if not isinstance(stats, dict) or stats.get("error") or stats.get("success") is False: + return None + error = stats.get("radio_error") + if isinstance(error, str) and error.strip(): + return True + # get_stats omits radio_error when clear. Without a known runtime state, + # omission is not evidence of health (older backends omit both fields). + if error is None or isinstance(error, str): + return False if _radio_status(data) is not None else None + return None + + +def _radio_problem(data: dict[str, Any]) -> bool | None: + """Disabled/degraded or an explicit aggregate error is a radio problem.""" + status = _radio_status(data) + error = _radio_error(data) + if status in ("degraded", "disabled") or error is True: + return True + return False if status == "ok" and error is False else None + + def _radio_stack(data: dict[str, Any]) -> dict[str, Any]: stack = _nested(data, "stats", "radio_stack") or {} return stack if isinstance(stack, dict) else {} @@ -352,6 +385,17 @@ class PyMCSensorDescription(SensorEntityDescription): "node_name": get_repeater_name_from_stats(_nested(data, "stats") or {}), }, ), + PyMCSensorDescription( + key="radio_status", + translation_key="radio_status", + name="Radio status", + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=["ok", "degraded", "disabled"], + value_fn=_radio_status, + attrs_fn=lambda data: {"radio_error": _radio_error(data)}, + ), PyMCSensorDescription( key="radio_stack_mode", translation_key="radio_stack_mode", diff --git a/custom_components/pymc_repeater/services.yaml b/custom_components/pymc_repeater/services.yaml index 1432054..8948714 100644 --- a/custom_components/pymc_repeater/services.yaml +++ b/custom_components/pymc_repeater/services.yaml @@ -11,6 +11,7 @@ ping_neighbor: selector: text: timeout: + description: Remote reply wait in seconds (1–60). HTTP allows 6 additional seconds. default: 10 selector: number: @@ -166,7 +167,7 @@ cad_calibration_stop: cad_manual_check: name: Run manual CAD check - description: Run immediate CAD checks and return detection diagnostics. + description: Run immediate CAD checks and return detection diagnostics. The HTTP budget scales with samples and CAD timeout, up to 167 seconds. fields: config_entry_id: selector: @@ -364,7 +365,7 @@ companion_login: companion_request_status: name: Companion request status - description: Request status from a remote companion target. + description: Request status from a remote companion target. Optionally set response_variable to receive the backend result dictionary (non-dictionary results are wrapped under result). Existing calls without a response remain supported. fields: config_entry_id: selector: @@ -375,6 +376,7 @@ companion_request_status: selector: text: timeout: + description: Remote reply wait in seconds (1–120). HTTP allows 10 additional seconds. default: 15 selector: number: @@ -387,7 +389,7 @@ companion_request_status: companion_request_telemetry: name: Companion request telemetry - description: Request telemetry from a remote companion target. + description: Request telemetry from a remote companion target. Optionally set response_variable to receive the backend result dictionary (non-dictionary results are wrapped under result). Existing calls without a response remain supported. fields: config_entry_id: selector: @@ -398,6 +400,7 @@ companion_request_telemetry: selector: text: timeout: + description: Remote reply wait in seconds (1–120). HTTP allows 10 additional seconds. default: 20 selector: number: diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index 11b5b29..c08319b 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -51,6 +51,10 @@ }, "entity": { "sensor": { + "radio_status": { + "name": "Radio status", + "state": {"ok": "OK", "degraded": "Degraded", "disabled": "Disabled"} + }, "recv_flood_count": {"name": "Flood packets received"}, "recv_direct_count": {"name": "Direct packets received"}, "sent_flood_count": {"name": "Flood packets transmitted"}, @@ -142,6 +146,7 @@ } }, "binary_sensor": { + "radio_problem": {"name": "Radio problem"}, "mqtt_handler_active": { "name": "MQTT handler active" }, diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml index fb34808..67f75aa 100644 --- a/dashboards/openhop_repeater_dashboard.yaml +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -20,6 +20,8 @@ # Repeater's device/child devices. Radio children have independent prefixes. # Remove unsupported rows/cards; never borrow another Repeater's readings. # Plugin-manager counts are unknown on older builds or if the manager is down. +# Aggregate radio health belongs to the parent, not configured radio children. +# Radio status is unknown on older builds; radio_error is presence only, not text. # Replace these example rows with broker entities from your installation. # Replace these example rows with companion entities from your installation. # Ordered full-width bands require a current Home Assistant Sections frontend. @@ -28,6 +30,9 @@ path: example-openhop-repeater icon: mdi:radio-tower type: sections badges: +- entity: binary_sensor.REPEATER_SLUG_radio_problem + name: Radio + show_name: true - entity: binary_sensor.REPEATER_SLUG_mqtt_broker_connected name: MQTT show_name: true @@ -93,6 +98,17 @@ sections: grid_options: columns: full rows: auto + - type: conditional + conditions: + - condition: state + entity: binary_sensor.REPEATER_SLUG_radio_problem + state: 'on' + card: + type: markdown + content: '**Attention:** Aggregate radio is degraded, disabled, or reporting an error. API connectivity does not prove RF operation. See radio status below; use local Repeater logs for error details.' + grid_options: + columns: full + rows: auto - type: tile entity: sensor.REPEATER_SLUG_packets_received_per_hour name: Received / h @@ -449,6 +465,10 @@ sections: name: API connected - entity: sensor.REPEATER_SLUG_component_api_last_success name: API last success + - entity: sensor.REPEATER_SLUG_radio_status + name: Aggregate radio status + - entity: binary_sensor.REPEATER_SLUG_radio_problem + name: Aggregate radio problem - entity: binary_sensor.REPEATER_SLUG_component_stats_problem name: Stats problem - entity: binary_sensor.REPEATER_SLUG_component_mqtt_status_problem diff --git a/docs/operational-monitoring.md b/docs/operational-monitoring.md index a33fc8c..71cfed1 100644 --- a/docs/operational-monitoring.md +++ b/docs/operational-monitoring.md @@ -10,14 +10,21 @@ type when supplied, otherwise `configured`. `radio_stack.radio_ids` also works when `radios` is empty. No aggregate packet, noise, CRC, RSSI or connectivity value is attributed to a child radio. Read-only configuration sensors expose frequency (Hz), bandwidth (Hz), TX power (dBm), spreading factor, coding rate and preamble -length only when supplied in that radio's `radio` configuration. In explicit -single mode, global `config.radio` may fill missing fields for the sole named -default radio, never for multiple radios. Settings are configuration, not proof -of measured RF behavior or successful hardware application. +length only when supplied in that radio's `radio` configuration. Settings are +configuration, not proof of measured RF behavior or successful hardware application. Genuine per-radio traffic/health sensors remain unsupported until a verified per-radio telemetry contract exists. Quiet RF is not a failure signal. +- added parent repeater `radio_status` with recognized aggregate states `ok` and `degraded` and `disabled` from existing stats polling and unknown for missing or unrecognized states +- added parent repeater `radio_problem` for degraded or disabled status or an explicit aggregate radio error and reported clear only for `ok` with no error +- exposed `radio_error` on `radio_status` as true for nonempty error text or false for a known state without an error and otherwise unknown without publishing raw exception text in these entities or dashboard cards +- kept aggregate radio health separate from api connectivity and per-radio configuration without inferring rf delivery from a healthy api or quiet traffic +- preferred configured `radio_type` over legacy `type` without treating either as live telemetry +- allowed global `config.radio` to fill missing settings only for a validated sole named default in explicit `single` or `single_fabric` mode and never across multiple radios or ambiguous inventories +- recognized `single_fabric` in the existing reversible radio lifecycle with exactly one authoritative radio id and unchanged alias and two-full-poll absence safeguards +- added aggregate radio status and problem rows with a radio badge and an explicit problem banner to the comprehensive dashboard + ## Preserving radio names after a backend ID change Renaming a radio device in Home Assistant does not change its identity. If the @@ -66,14 +73,24 @@ report explicit endpoint errors for stats, MQTT, hardware, GPS, plugins and upda status. These indicate API payload health, not a claim that every component's physical subsystem works. -Each sensor-manager reading gains age (seconds) and stale diagnostics. Stale means -source age exceeds three backend `poll_interval_seconds`, with a 60-second minimum. +Each sensor-manager reading gains age (seconds) and stale diagnostics. Missing, invalid, naive or future timestamps, or missing/invalid source cadence, are unknown, not fresh. Missing sources become unavailable. Source measurements are unavailable when the read failed, stale, or freshness cannot be established. Freshness is evaluated on existing coordinator notifications, with shared-poll resolution; no high-frequency timer or per-sensor polling task is added. +- preferred the reading envelope's effective `poll_interval_seconds` and marked readings stale after three source intervals with a 60-second minimum +- retained the legacy global-summary interval only when per-reading metadata was absent and left present but invalid metadata unknown rather than substituting the global interval +- documented that the required backend scheduler metadata change had not yet been released or deployed and that the integration alone could not supply effective per-reading cadence or prove a legacy plugin's cadence from the global fallback + +- aligned advanced action http budgets to cover backend waits with ping timeout plus 6 seconds and companion status or telemetry timeout plus 10 seconds +- enforced reply wait bounds of 1–60 seconds for ping and 1–120 seconds for companion status and telemetry +- scaled manual cad http budgets with backend-clamped sample and timeout bounds plus 7 seconds with a 10-second minimum and 167-second maximum while leaving asynchronous calibration start separate +- allowed 35 seconds for companion text and channel sends and 20 seconds for login and 25 seconds for commands +- added optional `response_variable` support to `pymc_repeater.companion_request_status` and `pymc_repeater.companion_request_telemetry` returning unwrapped backend dictionaries or wrapping other results under `result` without a coordinator refresh or automatic polling or entity-state storage +- preserved calls without requested responses and converted expected client failures to action errors including explicit companion `sent: false` while accepting older results without `sent` + ## Plugins Existing aggregate counts are preserved. Stable plugin IDs additionally identify diff --git a/tests/test_advanced_service_results.py b/tests/test_advanced_service_results.py new file mode 100644 index 0000000..180f1b8 --- /dev/null +++ b/tests/test_advanced_service_results.py @@ -0,0 +1,166 @@ +"""Execute real advanced client/service bodies with fake HTTP and HA boundaries.""" +from __future__ import annotations + +import ast +import asyncio +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import AsyncMock + +COMPONENT = Path(__file__).resolve().parents[1] / "custom_components/pymc_repeater" + + +def load_source(filename, namespace, predicate): + tree = ast.parse((COMPONENT / filename).read_text()) + nodes: list[ast.stmt] = [ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0)] + nodes.extend(node for node in tree.body if predicate(node)) + exec(compile(ast.fix_missing_locations(ast.Module(body=nodes, type_ignores=[])), filename, "exec"), namespace) + return namespace + + +def api_namespace(): + return load_source("api.py", {"asyncio": asyncio, "DEFAULT_PACKET_WINDOW_HOURS": 24}, + lambda n: isinstance(n, ast.Assign) or + isinstance(n, ast.ClassDef) and n.name.startswith("PyMCRepeater")) + + +class AdvancedClientTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.ns = api_namespace() + self.api = object.__new__(self.ns["PyMCRepeaterApiClient"]) + self.request = self.api._async_request_json = AsyncMock(return_value={"success": True, "data": {}}) + + async def test_http_budgets_cover_backend_waits(self): + cases = [ + ("companion_request_status", {"pub_key": "example"}, 25), + ("companion_request_telemetry", {"pub_key": "example"}, 30), + ("companion_request_status", {"pub_key": "example", "timeout": 120}, 130), + ("companion_request_telemetry", {"pub_key": "example", "timeout": 1}, 11), + ("companion_login", {"pub_key": "example"}, 20), + ("companion_send_command", {"pub_key": "example", "command": "example"}, 25), + ("companion_send_text", {"pub_key": "example", "text": "example"}, 35), + ("companion_send_channel_message", {"channel_idx": 0, "text": "example"}, 35), + ("ping_neighbor", {"target_id": "01"}, 16), + ("ping_neighbor", {"target_id": "01", "timeout": 60}, 66), + ("cad_manual_check", {}, 10), + ("cad_manual_check", {"samples": 32, "cad_timeout_ms": 5000}, 167), + ("cad_manual_check", {"samples": 320, "cad_timeout_ms": 50000}, 167), + ("cad_calibration_start", {"samples": 64, "cad_timeout_ms": 5000}, 10), + ] + for name, kwargs, budget in cases: + with self.subTest(name=name, kwargs=kwargs): + await getattr(self.api, "async_" + name)(**kwargs) + self.assertEqual(self.request.call_args.kwargs["timeout_seconds"], budget) + for key, value in kwargs.items(): + self.assertEqual(self.request.call_args.kwargs["json_body"][key], value) + + async def test_real_http_body_applies_budget_and_preserves_auth(self): + # Restore the real JSON method; fake only the session/response boundary. + del self.api._async_request_json + self.api.host, self.api.port, self.api.api_token = "example.invalid", 8000, "example-token" + self.ns["URL"] = SimpleNamespace(build=lambda **kw: "http://example.invalid:8000") + self.ns["ClientError"] = type("ClientError", (Exception,), {}) + budgets = [] + http_calls = [] + + class Response: + status = 200 + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def json(self, **kwargs): + return {"success": True, "data": {"status_data": {"battery": 3.5}}} + + def request(*args, **kwargs): + http_calls.append((args, kwargs)) + return Response() + + def timeout(seconds): + budgets.append(seconds) + return asyncio.timeout(seconds) + + self.ns["asyncio"] = SimpleNamespace(timeout=timeout) + self.api._session = SimpleNamespace(request=request) + result = await self.api.async_companion_request_status(pub_key="example", timeout=120) + self.assertEqual(result, {"status_data": {"battery": 3.5}}) + self.assertEqual(budgets, [130]) + self.assertEqual(http_calls[0][0], ("POST", "http://example.invalid:8000/api/companion/request_status")) + self.assertEqual(http_calls[0][1]["json"]["timeout"], 120) + self.assertEqual(http_calls[0][1]["headers"]["X-API-Key"], "example-token") + + async def test_invalid_timeouts_never_reach_http(self): + for name, key, maximum in [("companion_request_status", "pub_key", 120), + ("companion_request_telemetry", "pub_key", 120), + ("ping_neighbor", "target_id", 60)]: + for timeout in [0, -1, maximum + 1, float("nan"), float("inf")]: + with self.subTest(name=name, timeout=timeout): + with self.assertRaises(self.ns["PyMCRepeaterApiError"]): + await getattr(self.api, "async_" + name)(**{key: "example", "timeout": timeout}) + self.request.assert_not_called() + + async def test_explicit_send_failure_is_not_success(self): + for name, kwargs in [("companion_send_text", {"pub_key": "example", "text": "example"}), + ("companion_send_channel_message", {"channel_idx": 0, "text": "example"})]: + for payload in [{"success": True, "data": {"sent": False}}, {"sent": False}]: + self.request.return_value = payload + with self.subTest(name=name, payload=payload): + with self.assertRaises(self.ns["PyMCRepeaterApiError"]): + await getattr(self.api, "async_" + name)(**kwargs) + for data in [{"sent": True, "expected_ack": 123}, {}]: + self.request.return_value = {"success": True, "data": data} + self.assertEqual(await getattr(self.api, "async_" + name)(**kwargs), data) + + +class SchemaBoundary: + """Only stand in for dependency construction; not HA schema validation.""" + def __getattr__(self, name): + return lambda *args, **kwargs: (name, repr(args), repr(kwargs)) + + +class AdvancedServiceTests(unittest.IsolatedAsyncioTestCase): + async def test_optional_response_preserves_legacy_calls_and_payload(self): + registry = {} + ns = api_namespace() + ns.update(vol=SchemaBoundary(), DOMAIN="pymc_repeater", HomeAssistantError=RuntimeError, + SupportsResponse=SimpleNamespace(ONLY="only", OPTIONAL="optional")) + load_source("__init__.py", ns, lambda n: + isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and + (t.id.startswith("SERVICE_") or t.id in {"CONF_ENTRY_ID", "LEGACY_CONF_ENTRY_ID"}) for t in n.targets) + or isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and + n.name in {"_async_register_services", "_resolve_entry_id", "_async_refresh_entry"}) + api = object.__new__(ns["PyMCRepeaterApiClient"]) + api._async_request_json = AsyncMock() + refresh = AsyncMock() + hass = SimpleNamespace(data={"pymc_repeater": {"example": {"api": api, "coordinator": SimpleNamespace(async_request_refresh=refresh)}}}, + services=SimpleNamespace(has_service=lambda *a: False, + async_register=lambda domain, service, handler, **kw: registry.update({service: (handler, kw)}))) + await ns["_async_register_services"](hass) + for service in ["companion_request_status", "companion_request_telemetry"]: + handler, metadata = registry[service] + self.assertEqual(metadata.get("supports_response"), "optional") + for result in [{"status_data": {"battery": 3.5}}, {"telemetry_data": {"sensors": []}}, None, [1, 2]]: + api._async_request_json.return_value = {"success": True, "data": result} + for requested in [True, False]: + call = SimpleNamespace(data={"pub_key": "example", "config_entry_id": "example"}, return_response=requested) + actual = await handler(call) + expected = result if isinstance(result, dict) else {"result": result} + self.assertEqual(actual, expected if requested else None) + for service, data in [ + ("companion_send_text", {"pub_key": "example", "text": "example"}), + ("companion_send_channel_message", {"channel_idx": 0, "text": "example"}), + ("companion_request_status", {"pub_key": "example"}), + ("companion_request_telemetry", {"pub_key": "example"}), + ]: + api._async_request_json.return_value = ( + {"success": True, "data": {"sent": False}} if "send" in service + else {"success": False, "error": "Example backend failure"} + ) + with self.subTest(service=service): + with self.assertRaises(RuntimeError): + await registry[service][0](SimpleNamespace(data=data, return_response=True)) + refresh.assert_not_called() diff --git a/tests/test_radio_health.py b/tests/test_radio_health.py new file mode 100644 index 0000000..4b5b172 --- /dev/null +++ b/tests/test_radio_health.py @@ -0,0 +1,112 @@ +"""Aggregate radio health contracts; real source with minimal HA boundaries.""" +import ast +import json +from pathlib import Path +from types import SimpleNamespace +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COMPONENT = ROOT / "custom_components/pymc_repeater" + + +def load_health(): + namespace = {"PyMCSensorDescription": lambda **kw: SimpleNamespace(**kw), + "PyMCBinarySensorDescription": lambda **kw: SimpleNamespace(**kw), + "EntityCategory": SimpleNamespace(DIAGNOSTIC="diagnostic"), + "SensorDeviceClass": SimpleNamespace(ENUM="enum"), + "BinarySensorDeviceClass": SimpleNamespace(PROBLEM="problem")} + descriptions = {} + for filename in ("sensor.py", "binary_sensor.py"): + tree = ast.parse((COMPONENT / filename).read_text()) + nodes: list[ast.stmt] = [n for n in tree.body if isinstance(n, ast.FunctionDef) and + n.name in {"_nested", "_radio_status", "_radio_error", "_radio_problem"}] + nodes.insert(0, ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0)) + exec(compile(ast.fix_missing_locations(ast.Module(body=nodes, type_ignores=[])), filename, "exec"), namespace) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + if node.func.id not in ("PyMCSensorDescription", "PyMCBinarySensorDescription"): + continue + key = next((kw.value.value for kw in node.keywords + if kw.arg == "key" and isinstance(kw.value, ast.Constant)), None) + if key in ("radio_status", "radio_problem"): + # Only execute trusted checked-in descriptions, never API data. + descriptions[key] = eval(compile(ast.Expression(node), filename, "eval"), namespace) + return namespace, descriptions + + +class RadioHealthTests(unittest.TestCase): + def setUp(self): + self.ns, self.descriptions = load_health() + + def descriptions_present(self): + self.assertEqual(set(self.descriptions), {"radio_status", "radio_problem"}) + return self.descriptions["radio_status"], self.descriptions["radio_problem"] + + def test_backend_states_and_safe_error_presence(self): + sensor, binary = self.descriptions_present() + for status, error, problem in (("ok", False, False), ("degraded", False, True), + ("disabled", False, True), ("ok", True, True)): + data = {"stats": {"radio_status": status}} + if error: + data["stats"]["radio_error"] = "synthetic-private-path token=DO_NOT_EXPOSE" + with self.subTest(status=status, error=error): + self.assertEqual(sensor.value_fn(data), status) + self.assertIs(binary.value_fn(data), problem) + self.assertEqual(sensor.attrs_fn(data), {"radio_error": error}) + self.assertNotIn("DO_NOT_EXPOSE", json.dumps(sensor.attrs_fn(data))) + self.assertEqual(binary.device_class, "problem") + + def test_missing_malformed_and_failed_stats_are_unknown(self): + sensor, binary = self.descriptions_present() + for stats in (None, [], {}, {"radio_status": "unknown"}, {"radio_status": "future-state"}, + {"radio_status": []}, {"radio_status": True}, + {"radio_status": "ok", "success": False}, + {"radio_status": "ok", "error": "endpoint failed"}): + with self.subTest(stats=stats): + data = {"stats": stats} + self.assertIsNone(sensor.value_fn(data)) + self.assertIsNone(binary.value_fn(data)) + self.assertEqual(sensor.attrs_fn(data), {"radio_error": None}) + for raw in (False, 0, [], {}): + data = {"stats": {"radio_status": "ok", "radio_error": raw}} + self.assertIsNone(binary.value_fn(data)) + self.assertEqual(sensor.attrs_fn(data), {"radio_error": None}) + + def test_error_without_known_status_is_still_a_problem(self): + sensor, binary = self.descriptions_present() + data = {"stats": {"radio_error": "synthetic failure"}} + self.assertIsNone(sensor.value_fn(data)) + self.assertIs(binary.value_fn(data), True) + self.assertEqual(sensor.attrs_fn(data), {"radio_error": True}) + + def test_raw_radio_error_is_redacted_from_diagnostics(self): + tree = ast.parse((COMPONENT / "diagnostics.py").read_text()) + assignment = next(n for n in tree.body if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "TO_REDACT" for t in n.targets)) + namespace = {"CONF_API_TOKEN": "api_token"} + exec(compile(ast.Module(body=[assignment], type_ignores=[]), "diagnostics.py", "exec"), namespace) + self.assertIn("radio_error", namespace["TO_REDACT"]) + + def test_configuration_does_not_prove_health(self): + sensor, binary = self.descriptions_present() + data = {"stats": {"radio_stack": {"mode": "multi", "radio_ids": ["a", "b"]}, + "radios": [{"id": "a", "type": "kiss"}, {"id": "b", "type": "null"}], + "config": {"radio": {"enabled": True}}}} + self.assertIsNone(sensor.value_fn(data)) + self.assertIsNone(binary.value_fn(data)) + self.assertEqual(sensor.attrs_fn(data), {"radio_error": None}) + + def test_translation_and_builtin_parent_dashboard_contract(self): + translations = json.loads((COMPONENT / "translations/en.json").read_text())["entity"] + self.assertIn("radio_status", translations["sensor"]) + self.assertIn("radio_problem", translations["binary_sensor"]) + dashboard = (ROOT / "dashboards/openhop_repeater_dashboard.yaml").read_text() + self.assertIn("sensor.REPEATER_SLUG_radio_status", dashboard) + self.assertIn("binary_sensor.REPEATER_SLUG_radio_problem", dashboard) + self.assertIn("Aggregate radio", dashboard) + self.assertNotIn("custom:", dashboard) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_radio_inventory.py b/tests/test_radio_inventory.py new file mode 100644 index 0000000..b8ea7f4 --- /dev/null +++ b/tests/test_radio_inventory.py @@ -0,0 +1,78 @@ +"""Radio configuration parity, executing real helpers without Home Assistant.""" +from copy import deepcopy +from pathlib import Path +import runpy +import unittest + +ROOT = Path(__file__).resolve().parents[1] / "custom_components/pymc_repeater" + + +def single_fabric(): + return {"stats": {"radio_stack": {"mode": "single_fabric", "radio_ids": ["radio0"], + "default_radio": "radio0", "fabric": True}, + "radios": [], "config": {"radio": {"frequency": 868000000, + "tx_power": 22}}}} + + +class RadioInventoryTests(unittest.TestCase): + def setUp(self): + self.inventory = runpy.run_path(str(ROOT / "monitoring.py"))["radio_inventory"] + + def test_single_fabric_sole_default_gets_global_settings_before_aliasing(self): + data = single_fabric() + before = deepcopy(data) + self.assertEqual(self.inventory(data, {"radio0": "original"}), + {"original": {"frequency": 868000000, "tx_power": 22}}) + self.assertEqual(data, before) + + def test_explicit_row_settings_override_global_single_fabric_settings(self): + data = single_fabric() + data["stats"]["radios"] = [{"id": "radio0", "radio": {"frequency": 915000000}}] + self.assertEqual(self.inventory(data)["radio0"], {"frequency": 915000000, "tx_power": 22}) + + def test_global_settings_require_valid_sole_default_topology(self): + mutations = [ + ("stack", "mode", "multi"), ("stack", "mode", "unknown"), + ("stack", "default_radio", "other"), ("stack", "default_radio", None), + ("stack", "radio_ids", []), ("stack", "radio_ids", None), + ("stack", "radio_ids", ["radio0", None]), + ("stack", "radio_ids", ["radio0", "radio0"]), + ("stack", "radio_ids", ["radio0", "other"]), + ("stack", "error", "offline"), ("stack", "success", False), + ("stats", "radios", [None]), ("stats", "radios", None), + ("stats", "radios", [{"id": "other"}]), + ("stats", "radios", [{"id": "radio0", "radio": None}]), + ("stats", "radios", [{"id": "radio0", "error": "offline"}]), + ("stats", "radios", [{"id": "radio0", "success": False}]), + ("stats", "radios", [{"id": "radio0", "radio": {"error": "offline"}}]), + ("stats", "radios", [{"id": "radio0", "radio_id": "other"}]), + ("stats", "radios", [{"id": "radio0"}, {"id": "radio0"}]), + ("config", "success", False), ("config", "error", "offline"), + ] + for mode in ("single", "single_fabric"): + for section, key, value in mutations: + with self.subTest(mode=mode, section=section, key=key, value=value): + data = single_fabric() + data["stats"]["radio_stack"]["mode"] = mode + target = (data["stats"]["radio_stack"] if section == "stack" else + data["stats"]["config"] if section == "config" else data["stats"]) + target[key] = value + self.assertTrue(all("frequency" not in row and "tx_power" not in row + for row in self.inventory(data).values())) + + def test_radio_type_is_preferred_with_legacy_string_fallback(self): + for row, expected in [ + ({"radio_type": "modem_usb"}, "modem_usb"), + ({"radio_type": "modem_tcp", "type": "legacy"}, "modem_tcp"), + ({"type": "legacy"}, "legacy"), + ({"radio_type": None, "type": "legacy"}, "legacy"), + ({"radio_type": {}, "type": "legacy"}, "legacy"), + ]: + with self.subTest(row=row): + data = single_fabric() + data["stats"]["radios"] = [{"id": "radio0", **row}] + self.assertEqual(self.inventory(data)["radio0"]["type"], expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_radio_lifecycle.py b/tests/test_radio_lifecycle.py index 4426a8e..7fa891b 100644 --- a/tests/test_radio_lifecycle.py +++ b/tests/test_radio_lifecycle.py @@ -179,6 +179,61 @@ def test_alias_roundtrip_and_collision_fail_closed(self): self.poll("radio0") self.assertIsNone(self.ns["authoritative_radio_ids"](self.coordinator, self.entry)) + def test_single_fabric_alias_roundtrip_retires_and_restores_without_identity_changes(self): + self.entry.options = {"radio_id_aliases": {"local": "radio0"}} + original = self.registry.add("radio0", Disabler.INTEGRATION) + user_disabled = self.registry.add("user", Disabler.USER) + link = self.registry.add("link") + before = (deepcopy(original.identifiers), original.name_by_user) + self.poll("local", "link", "user") + self.assertIsNone(original.disabled_by) + for index in range(2): + self.coordinator.data = snapshot("radio0") + self.coordinator.data["stats"]["radio_stack"]["mode"] = "single_fabric" + self.coordinator.last_successful_poll += timedelta(seconds=15) + self.lifecycle.async_reconcile() + self.assertEqual(self.ns["authoritative_radio_ids"](self.coordinator, self.entry), {"radio0"}) + if index == 0: + self.lifecycle.async_reconcile() # Same stamp/GPS cannot confirm absence. + self.assertIsNone(link.disabled_by) + self.assertFalse(self.lifecycle.can_remove(link)) + self.assertIs(link.disabled_by, Disabler.INTEGRATION) + self.assertTrue(self.lifecycle.can_remove(link)) + self.assertIs(user_disabled.disabled_by, Disabler.USER) + self.poll("local", "link", "user") + self.assertIsNone(link.disabled_by) + self.assertIs(user_disabled.disabled_by, Disabler.USER) + self.assertIs(self.registry.devices["radio0"], original) + self.assertEqual((original.identifiers, original.name_by_user), before) + self.assertTrue(all(set(fields) == {"disabled_by"} for _, fields in self.registry.writes)) + + def test_single_fabric_keeps_single_mode_validation_guards(self): + cases = [snapshot(), snapshot("radio0", "link"), snapshot("radio0", "radio0")] + for field, value in (("radio_ids", [None]), ("radio_ids", ["bad id"]), ("error", "offline")): + data = snapshot("radio0") + data["stats"]["radio_stack"][field] = value + cases.append(data) + for rows in ([None], [{"id": "other"}], [{"id": "radio0"}, {"id": "radio0"}], + [{"id": "radio0", "radio_id": "other"}], [{"id": "radio0", "radio": None}]): + data = snapshot("radio0") + data["stats"]["radios"] = rows + cases.append(data) + absent = self.registry.add("absent") + for data in cases: + with self.subTest(data=data): + self.coordinator.data = snapshot("radio0") + self.coordinator.data["stats"]["radio_stack"]["mode"] = "single_fabric" + self.coordinator.last_successful_poll += timedelta(seconds=15) + self.lifecycle.async_reconcile() + data["stats"]["radio_stack"]["mode"] = "single_fabric" + self.coordinator.data = data + self.coordinator.last_successful_poll += timedelta(seconds=15) + self.lifecycle.async_reconcile() + self.assertIsNone(self.ns["authoritative_radio_ids"](self.coordinator, self.entry)) + self.assertIsNone(absent.disabled_by) + self.assertFalse(self.lifecycle.can_remove(absent)) + self.assertEqual(self.registry.writes, []) + def test_unload_and_idempotent_start(self): self.lifecycle.async_start() self.lifecycle.async_start() diff --git a/tests/test_sensor_freshness_cadence.py b/tests/test_sensor_freshness_cadence.py new file mode 100644 index 0000000..26b81ec --- /dev/null +++ b/tests/test_sensor_freshness_cadence.py @@ -0,0 +1,49 @@ +"""Execute real freshness helpers without importing Home Assistant.""" +import importlib.util +from pathlib import Path +import unittest + + +PATH = Path(__file__).resolve().parents[1] / "custom_components/pymc_repeater/monitoring.py" +spec = importlib.util.spec_from_file_location("freshness_monitoring", PATH) +assert spec is not None and spec.loader is not None +monitoring = importlib.util.module_from_spec(spec) +spec.loader.exec_module(monitoring) + + +class SensorFreshnessCadenceTests(unittest.TestCase): + def test_effective_reading_cadence_overrides_global(self): + reading = {"timestamp": 900, "ok": True, "poll_interval_seconds": 60, + "data": {"solar_charge_rate_percent_per_hour": 1}} + self.assertIs(monitoring.reading_stale(reading, 10, now=1000), False) + self.assertTrue(monitoring.reading_usable(reading, 10, now=1000)) + self.assertEqual(monitoring.charge_state(reading, 10, now=1000), "charging") + self.assertIs(monitoring.reading_stale(reading, 10, now=1080), False) + self.assertIs(monitoring.reading_stale(reading, 10, now=1081), True) + + def test_short_override_does_not_inherit_long_global(self): + reading = {"timestamp": 900, "poll_interval_seconds": 10} + self.assertIs(monitoring.reading_stale(reading, 300, now=1000), True) + + def test_legacy_missing_metadata_uses_only_explicit_global_fallback(self): + self.assertIs(monitoring.reading_stale({"timestamp": 900}, 60, now=1000), False) + self.assertIs(monitoring.reading_stale({"timestamp": 900}, 10, now=1000), True) + self.assertIsNone(monitoring.reading_stale({"timestamp": 900}, None, now=1000)) + + def test_present_invalid_metadata_is_unknown_not_global_fallback(self): + for interval in (None, True, False, 0, -1, "bad", "nan", "inf", [], {}): + with self.subTest(interval=interval): + reading = {"timestamp": 990, "ok": True, "poll_interval_seconds": interval} + self.assertIsNone(monitoring.reading_stale(reading, 60, now=1000)) + self.assertFalse(monitoring.reading_usable(reading, 60, now=1000)) + + def test_effective_metadata_does_not_bypass_timestamp_or_success(self): + for timestamp in (None, True, "bad", float("nan"), 1100): + reading = {"timestamp": timestamp, "ok": True, "poll_interval_seconds": 60} + self.assertFalse(monitoring.reading_usable(reading, 10, now=1000)) + self.assertFalse(monitoring.reading_usable( + {"timestamp": 990, "ok": False, "poll_interval_seconds": 60}, 10, now=1000)) + + def test_nested_measurement_cannot_override_envelope_cadence(self): + reading = {"timestamp": 900, "data": {"poll_interval_seconds": 600}} + self.assertIs(monitoring.reading_stale(reading, 10, now=1000), True)