diff --git a/.github/workflows/python-smoke.yaml b/.github/workflows/python-smoke.yaml index 7a46c32..883d893 100644 --- a/.github/workflows/python-smoke.yaml +++ b/.github/workflows/python-smoke.yaml @@ -4,11 +4,13 @@ on: push: paths: - "custom_components/**" + - "tests/**" - "hacs.json" - ".github/workflows/python-smoke.yaml" pull_request: paths: - "custom_components/**" + - "tests/**" - "hacs.json" - ".github/workflows/python-smoke.yaml" release: @@ -40,3 +42,6 @@ jobs: - name: Compile integration run: python -m compileall custom_components/pymc_repeater + + - name: Run contract tests + run: python -m unittest discover -s tests -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbaefd..c2b76e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.1.6 + +- changed the integration-wide API refresh interval to a configurable option, defaulting to 15 seconds +- fixed GPS stream updates so they notify entities without continually postponing the coordinator's regular API refresh +- added support for Repeater dev neighbor-link snapshots and history, including aggregate Home Assistant diagnostics and response-returning actions +- added the Repeater dev manual CAD check action and aligned calibration options with known-signal, CAD symbol count, and timeout controls +- exposed the Repeater metrics storage source and RRD availability diagnostics introduced by the SQLite metrics fallback +- added contract tests for the new Repeater dev API coverage and run them in the Python smoke workflow +- renamed the example dashboard to `openhop_repeater_dashboard.yaml` and replaced it with an anonymized, single-placeholder view covering current telemetry, controls, external modem data, neighbor links, and metrics diagnostics +- reworked the README with relevant HACS, release, openHop Discord, validation, and test badges; streamlined installation and setup guidance; documented options, actions, dashboard use, persistent token storage, and community links +- updated the Python smoke workflow to `actions/setup-python@v7` + ## 1.1.5 - fixed the Home Assistant options flow crash on newer Home Assistant versions by avoiding assignment to the read-only `OptionsFlow.config_entry` property diff --git a/README.md b/README.md index 9ad5597..71ae836 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,182 @@ -# openHop Repeater for Home Assistant +

+ openHop Repeater +

-Custom Home Assistant integration for [openHop Repeater](https://github.com/openhop-dev/openhop_repeater). +

openHop Repeater Integration for Home Assistant

-This integration connects directly to the repeater's local HTTP API, signs in once with the admin password, creates a dedicated API token for Home Assistant, and then uses that token for ongoing polling and history-friendly logging inside HA. +

+ Monitor and control an openHop Repeater from Home Assistant. +

-The Home Assistant integration domain and folder remain `pymc_repeater` for compatibility with existing installations while the user-facing brand is openHop Repeater. +

+ HACS Custom + Latest release + openHop Discord +

-## Features +

+ HACS validation + Hassfest + Tests +

-- UI config flow inside Home Assistant -- Prompts for repeater IP or hostname, port, and admin password -- Automatically creates a dedicated API token for Home Assistant -- Stores the API token instead of the admin password after setup -- Polls repeater telemetry, packet stats, radio metrics, hardware stats, database stats, MQTT status, ACL stats, identity totals, and configured Repeater sensor-manager readings -- Exposes Home Assistant sensors and binary sensors for easy dashboards, history graphs, and automations +## About + +This custom integration connects Home Assistant directly to the Repeater's local HTTP API. During setup it signs in once with the Repeater admin password, creates a dedicated API token, stores that token in the Home Assistant config entry, and discards the admin password. + +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. + +> [!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**. + +## Highlights + +- UI-based setup and options flows +- Configurable integration-wide polling interval +- Repeater, radio, packet, routing, and signal-quality telemetry +- MQTT broker and handler 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 +- Update status, update-channel selection, and update actions +- CAD calibration controls and manual CAD checks +- Native Home Assistant diagnostics and an extensive example dashboard ## Requirements -- A running [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) instance -- The repeater web/API port reachable from Home Assistant -- The repeater admin password -- A trusted local network, VPN, or other secure path between Home Assistant and the repeater +- Home Assistant 2024.1 or newer +- A running [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) +- The Repeater HTTP API reachable from Home Assistant +- The Repeater admin password for initial setup or reauthentication +- A trusted local network, VPN, or another secure path between Home Assistant and the Repeater ## Installation -### Install with HACS +### HACS + +[![Open your Home Assistant instance and add this repository to HACS.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=openhop-dev&repository=openHop-HA-Integration&category=Integration) -This integration is intended to be installed in HACS as a custom repository. +This integration is currently installed as a HACS custom repository: -1. Open Home Assistant. -2. Go to `HACS` -> `Integrations`. -3. Open the top-right menu and select `Custom repositories`. -4. Paste: +1. Open **HACS → Integrations**. +2. Open the top-right menu and select **Custom repositories**. +3. Add: ```text https://github.com/openhop-dev/openHop-HA-Integration ``` -5. Choose `Integration` as the category. -6. Add the repository. -7. Find `openHop Repeater` in HACS and install it. -8. Restart Home Assistant. +4. Select **Integration** as the category. +5. Install **openHop Repeater**. +6. Restart Home Assistant. ### Manual installation -1. Copy the `custom_components/pymc_repeater` folder into your Home Assistant config directory: +1. Download the latest release. +2. Copy `custom_components/pymc_repeater` into your Home Assistant configuration directory: ```text /config/custom_components/pymc_repeater ``` -2. Your final layout should look like this: - - ```text - /config/custom_components/pymc_repeater/__init__.py - /config/custom_components/pymc_repeater/manifest.json - /config/custom_components/pymc_repeater/config_flow.py - ... - ``` - 3. Restart Home Assistant. -## Setup inside Home Assistant +## Setup After installation and restart: -1. Open `Settings` -> `Devices & Services`. -2. Click `Add Integration`. -3. Search for `openHop Repeater`. -4. Enter: - - Repeater IP address or hostname - - Repeater HTTP API port - - Repeater admin password -5. Click `Submit`. +1. Open **Settings → Devices & services**. +2. Select **Add integration**. +3. Search for **openHop Repeater**. +4. Enter the Repeater hostname or IP address, HTTP API port, and admin password. +5. Submit the form. + +Home Assistant will create and retain a dedicated API token. The admin password is not stored after setup completes. + +### Integration options -During setup the integration will: +Open **Settings → Devices & services → openHop Repeater → Configure** to change: -1. Connect to the repeater API -2. Sign in as `admin` -3. Create a dedicated API token for Home Assistant -4. Save that token in the config entry -5. Discard the admin password after the setup flow finishes -6. Start polling repeater data automatically +- Integration polling interval, defaulting to 15 seconds +- Data-size display unit +- Uptime display unit + +Changing an option reloads the integration automatically. ## Dashboard template -A native Lovelace dashboard template is included at: +A comprehensive native Lovelace view is included at: + +[`dashboards/openhop_repeater_dashboard.yaml`](dashboards/openhop_repeater_dashboard.yaml) -- `dashboards/pymc_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. To use it: -1. Open the YAML file from this repo. -2. Replace `REPEATER_SLUG` with your actual entity prefix. - Example: `repeater_name_here` -3. In Home Assistant, create a new dashboard or open an existing one in raw YAML mode. -4. Paste the template YAML. -5. Update the example MQTT broker and companion entity rows in the `Network` view so they match the dynamic entities created in your installation. - -The template only uses built-in Home Assistant cards, so it does not require extra frontend dependencies. - -## Security notes - -- The repeater API is currently accessed over `http://` -- Use this integration only on a trusted network, or place both systems behind a VPN or another secure transport boundary -- If the API token is revoked on the repeater, Home Assistant should trigger reauthentication - -## Repository structure - -```text -custom_components/pymc_repeater/ - __init__.py - api.py - binary_sensor.py - config_flow.py - const.py - coordinator.py - diagnostics.py - manifest.json - sensor.py - translations/en.json - brand/icon.png - brand/icon@2x.png -dashboards/ - pymc_repeater_dashboard.yaml -hacs.json -README.md +1. Find one Repeater entity in Home Assistant, such as `sensor.my_repeater_repeater_version`. +2. Copy the entity prefix—in this example, `my_repeater`. +3. Replace every `REPEATER_SLUG` in the template with that prefix. +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. + +The dashboard uses only built-in Home Assistant cards. + +## Actions + +The integration exposes Home Assistant actions for supported Repeater operations, including: + +- Sending adverts and restarting the Repeater service +- Checking for and installing updates +- Reading broker presets and neighbor-link history +- 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. + +## 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. + +For the openHop Home Assistant add-on, use the persistent openHop storage path: + +```yaml +storage: + storage_dir: /var/lib/openhop_repeater ``` -## Development notes +Using the legacy, unmapped `/var/lib/pymc_repeater` path in the renamed add-on can cause the API-token database to disappear when the add-on restarts. The integration will then report `Authentication failed for /api/stats` and request reauthentication. + +Do not publish your admin password, JWT secret, Home Assistant token, or Repeater API token in an issue or diagnostic upload. -- The config flow follows current Home Assistant custom integration patterns with `manifest.json`, `config_flow.py`, and `translations/en.json` -- The integration uses coordinated polling rather than per-entity API calls -- The client implementation matches the current [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) auth flow: - - `POST /auth/login` - - `POST /api/auth/tokens` - - ongoing reads with `X-API-Key` +## Security + +- The Repeater API is currently accessed over plain `http://`. +- Keep Home Assistant and the Repeater on a trusted network, behind a VPN, or within another secure transport boundary. +- Revoke unused Home Assistant API tokens from the Repeater. +- If a token is revoked or no longer validates, Home Assistant starts its standard reauthentication flow. + +## Community and related projects + +- [Join the openHop Discord](https://discord.gg/3s8MMaSTzq) +- [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) +- [openHop Repeater Home Assistant add-on](https://github.com/openhop-dev/openHop-HA-Add-on) +- [Release notes](CHANGELOG.md) +- [Issue tracker](https://github.com/openhop-dev/openHop-HA-Integration/issues) + +## Development + +The repository includes HACS validation, Hassfest, Python compilation, and contract tests. To run the local checks: + +```bash +python3 -m unittest discover -s tests -v +python3 -m compileall -q custom_components/pymc_repeater +``` -## Releases +Release versions are defined in `custom_components/pymc_repeater/manifest.json` and documented in [`CHANGELOG.md`](CHANGELOG.md). -- The integration version is defined in `custom_components/pymc_repeater/manifest.json` -- GitHub Actions validate HACS compatibility, Hassfest, and a Python smoke test on every push and pull request -- Dependabot monitors the workflow dependencies automatically -- For the first stable release, create a Git tag and GitHub release for `v1.0.0` +Contributions and focused bug reports are welcome. Please include the Home Assistant version, integration version, Repeater version, and relevant redacted logs when reporting a problem. diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index c4c8903..6294be2 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -32,6 +32,7 @@ SERVICE_ROOM_MESSAGES_CLEAR = "room_messages_clear" SERVICE_CAD_CALIBRATION_START = "cad_calibration_start" SERVICE_CAD_CALIBRATION_STOP = "cad_calibration_stop" +SERVICE_CAD_MANUAL_CHECK = "cad_manual_check" SERVICE_SAVE_CAD_SETTINGS = "save_cad_settings" SERVICE_DB_PURGE = "db_purge" SERVICE_UPDATE_RADIO_CONFIG = "update_radio_config" @@ -50,6 +51,8 @@ SERVICE_GET_RECENT_PACKETS = "get_recent_packets" SERVICE_GET_FILTERED_PACKETS = "get_filtered_packets" SERVICE_GET_PACKET_BY_HASH = "get_packet_by_hash" +SERVICE_GET_NEIGHBOR_LINKS = "get_neighbor_links" +SERVICE_GET_NEIGHBOR_LINK_HISTORY = "get_neighbor_link_history" SERVICE_GET_ADVERTS_BY_CONTACT_TYPE = "get_adverts_by_contact_type" SERVICE_GET_ADVERTS_COUNT_BY_CONTACT_TYPE = "get_adverts_count_by_contact_type" SERVICE_GET_ACL_CLIENTS = "get_acl_clients" @@ -246,14 +249,28 @@ async def _with_api_response( lambda api, _: api.async_cad_calibration_start( samples=call.data.get("samples", 8), delay=call.data.get("delay", 100), + known_signal_present=call.data.get("known_signal_present", False), + cad_symbol_num=call.data.get("cad_symbol_num", 2), + cad_timeout_ms=call.data.get("cad_timeout_ms", 500), ), refresh=False, ), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, - vol.Optional("samples", default=8): vol.Coerce(int), - vol.Optional("delay", default=100): vol.Coerce(int), + vol.Optional("samples", default=8): vol.All( + vol.Coerce(int), vol.Range(min=1, max=64) + ), + vol.Optional("delay", default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=2000) + ), + vol.Optional("known_signal_present", default=False): bool, + vol.Optional("cad_symbol_num", default=2): vol.All( + vol.Coerce(int), vol.In([1, 2, 4, 8, 16]) + ), + vol.Optional("cad_timeout_ms", default=500): vol.All( + vol.Coerce(int), vol.Range(min=50, max=5000) + ), } ), ) @@ -267,6 +284,45 @@ async def _with_api_response( schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str}), ) + hass.services.async_register( + DOMAIN, + SERVICE_CAD_MANUAL_CHECK, + lambda call: _with_api_response( + call, + lambda api, _: api.async_cad_manual_check( + samples=call.data.get("samples", 1), + det_peak=call.data.get("det_peak"), + det_min=call.data.get("det_min"), + cad_symbol_num=call.data.get("cad_symbol_num"), + cad_timeout_ms=call.data.get("cad_timeout_ms", 500), + apply_live=call.data.get("apply_live", False), + ), + always_return=True, + ), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("samples", default=1): vol.All( + vol.Coerce(int), vol.Range(min=1, max=32) + ), + vol.Optional("det_peak"): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional("det_min"): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional("cad_symbol_num"): vol.All( + vol.Coerce(int), vol.In([1, 2, 4, 8, 16]) + ), + vol.Optional("cad_timeout_ms", default=500): vol.All( + vol.Coerce(int), vol.Range(min=50, max=5000) + ), + vol.Optional("apply_live", default=False): bool, + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_SAVE_CAD_SETTINGS, @@ -275,6 +331,7 @@ async def _with_api_response( lambda api, _: api.async_save_cad_settings( peak=call.data["peak"], min_val=call.data["min_val"], + cad_symbol_num=call.data.get("cad_symbol_num", 2), detection_rate=call.data.get("detection_rate", 0), ), ), @@ -283,6 +340,9 @@ async def _with_api_response( vol.Optional(CONF_ENTRY_ID): str, vol.Required("peak"): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), vol.Required("min_val"): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), + vol.Optional("cad_symbol_num", default=2): vol.All( + vol.Coerce(int), vol.In([1, 2, 4, 8, 16]) + ), vol.Optional("detection_rate", default=0): vol.All( vol.Coerce(int), vol.Range(min=0, max=255) ), @@ -631,6 +691,62 @@ async def _with_api_response( supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_NEIGHBOR_LINKS, + lambda call: _with_api_response( + call, + lambda api, _: api.async_get_neighbor_links( + active_within_seconds=call.data.get("active_within_seconds", 90), + limit=call.data.get("limit", 500), + ), + always_return=True, + ), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("active_within_seconds", default=90): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Optional("limit", default=500): vol.All( + vol.Coerce(int), vol.Range(min=1, max=5000) + ), + } + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_NEIGHBOR_LINK_HISTORY, + lambda call: _with_api_response( + call, + lambda api, _: api.async_get_neighbor_link_history( + peer_hash=call.data["peer_hash"], + path_hash_size=call.data["path_hash_size"], + hours=call.data.get("hours", 24), + limit=call.data.get("limit", 1000), + ), + always_return=True, + ), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Required("peer_hash"): str, + vol.Required("path_hash_size"): vol.All( + vol.Coerce(int), vol.Range(min=1, max=3) + ), + vol.Optional("hours", default=24): vol.All( + vol.Coerce(int), vol.Range(min=1, max=168) + ), + vol.Optional("limit", default=1000): vol.All( + vol.Coerce(int), vol.Range(min=1, max=5000) + ), + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_GET_ADVERTS_BY_CONTACT_TYPE, diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index 4cc1573..19f047c 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -148,6 +148,7 @@ async def async_fetch_all(self) -> dict[str, Any]: "packet_type_stats": self.async_get_packet_type_stats(), "lbt_diagnostics": self.async_get_lbt_diagnostics(), "default_region": self.async_get_default_region(), + "neighbor_links": self.async_get_neighbor_links(), } results = await asyncio.gather(*endpoints.values(), return_exceptions=True) @@ -219,6 +220,39 @@ async def async_get_route_stats(self) -> dict[str, Any]: params={"hours": DEFAULT_PACKET_WINDOW_HOURS}, ) + async def async_get_neighbor_links( + self, *, active_within_seconds: int = 90, limit: int = 500 + ) -> dict[str, Any]: + """Return observed upstream neighbor link snapshots.""" + return await self._async_request_wrapped( + "GET", + "/api/neighbor_links", + params={ + "active_within_seconds": active_within_seconds, + "limit": limit, + }, + ) + + async def async_get_neighbor_link_history( + self, + *, + peer_hash: str, + path_hash_size: int, + hours: int = DEFAULT_PACKET_WINDOW_HOURS, + limit: int = 1000, + ) -> dict[str, Any]: + """Return stored observations for one upstream neighbor link.""" + 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, + }, + ) + async def async_get_noise_floor_stats(self) -> dict[str, Any]: """Return noise floor stats.""" payload = await self._async_request_wrapped( @@ -609,12 +643,25 @@ async def async_room_messages_clear( "DELETE", "/api/room_messages_clear", params=params ) - async def async_cad_calibration_start(self, samples: int = 8, delay: int = 100) -> Any: + async def async_cad_calibration_start( + self, + samples: int = 8, + delay: int = 100, + known_signal_present: bool = False, + cad_symbol_num: int = 2, + cad_timeout_ms: int = 500, + ) -> Any: """Start CAD calibration.""" return await self._async_request_wrapped( "POST", "/api/cad_calibration_start", - json_body={"samples": samples, "delay": delay}, + json_body={ + "samples": samples, + "delay": delay, + "known_signal_present": known_signal_present, + "cad_symbol_num": cad_symbol_num, + "cad_timeout_ms": cad_timeout_ms, + }, ) async def async_cad_calibration_stop(self) -> Any: @@ -623,8 +670,39 @@ async def async_cad_calibration_stop(self) -> Any: "POST", "/api/cad_calibration_stop", json_body={} ) + async def async_cad_manual_check( + self, + *, + samples: int = 1, + det_peak: int | None = None, + det_min: int | None = None, + cad_symbol_num: int | None = None, + cad_timeout_ms: int = 500, + apply_live: bool = False, + ) -> dict[str, Any]: + """Run one or more immediate CAD checks.""" + payload: dict[str, Any] = { + "samples": samples, + "cad_timeout_ms": cad_timeout_ms, + "apply_live": apply_live, + } + if det_peak is not None: + payload["det_peak"] = det_peak + if det_min is not None: + payload["det_min"] = det_min + 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 + ) + async def async_save_cad_settings( - self, *, peak: int, min_val: int, detection_rate: int = 0 + self, + *, + peak: int, + min_val: int, + cad_symbol_num: int = 2, + detection_rate: int = 0, ) -> Any: """Save CAD settings.""" return await self._async_request_wrapped( @@ -633,6 +711,7 @@ async def async_save_cad_settings( json_body={ "peak": peak, "min_val": min_val, + "cad_symbol_num": cad_symbol_num, "detection_rate": detection_rate, }, ) diff --git a/custom_components/pymc_repeater/config_flow.py b/custom_components/pymc_repeater/config_flow.py index e00dde7..76b2340 100644 --- a/custom_components/pymc_repeater/config_flow.py +++ b/custom_components/pymc_repeater/config_flow.py @@ -26,14 +26,18 @@ from .const import ( CONF_API_TOKEN, CONF_DATA_SIZE_UNIT, + CONF_SCAN_INTERVAL, CONF_TOKEN_ID, CONF_TOKEN_NAME, CONF_UPTIME_UNIT, DATA_SIZE_UNITS, DEFAULT_DATA_SIZE_UNIT, DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL_SECONDS, DEFAULT_UPTIME_UNIT, DOMAIN, + MAX_SCAN_INTERVAL_SECONDS, + MIN_SCAN_INTERVAL_SECONDS, UPTIME_UNITS, ) @@ -204,12 +208,25 @@ async def async_step_init( current_data_size_unit = self._config_entry.options.get( CONF_DATA_SIZE_UNIT, DEFAULT_DATA_SIZE_UNIT ) + current_scan_interval = self._config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL_SECONDS + ) schema = vol.Schema( { vol.Required(CONF_DATA_SIZE_UNIT, default=current_data_size_unit): vol.In( DATA_SIZE_UNITS ), vol.Required(CONF_UPTIME_UNIT, default=current_unit): vol.In(UPTIME_UNITS), + vol.Required( + CONF_SCAN_INTERVAL, + default=current_scan_interval, + ): vol.All( + vol.Coerce(int), + vol.Range( + min=MIN_SCAN_INTERVAL_SECONDS, + max=MAX_SCAN_INTERVAL_SECONDS, + ), + ), } ) return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/pymc_repeater/const.py b/custom_components/pymc_repeater/const.py index c990f90..95b0160 100644 --- a/custom_components/pymc_repeater/const.py +++ b/custom_components/pymc_repeater/const.py @@ -1,7 +1,5 @@ """Constants for the openHop Repeater integration.""" -from datetime import timedelta - DOMAIN = "pymc_repeater" PLATFORMS = ["sensor", "binary_sensor"] @@ -9,13 +7,16 @@ CONF_TOKEN_ID = "token_id" CONF_TOKEN_NAME = "token_name" CONF_DATA_SIZE_UNIT = "data_size_unit" +CONF_SCAN_INTERVAL = "scan_interval" CONF_UPTIME_UNIT = "uptime_unit" DEFAULT_PORT = 8000 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_SCAN_INTERVAL_SECONDS = 15 DEFAULT_DATA_SIZE_UNIT = "mebibytes" DEFAULT_PACKET_WINDOW_HOURS = 24 DEFAULT_UPTIME_UNIT = "hours" +MIN_SCAN_INTERVAL_SECONDS = 5 +MAX_SCAN_INTERVAL_SECONDS = 300 MANUFACTURER = "openHop" MODEL = "openHop Repeater" diff --git a/custom_components/pymc_repeater/coordinator.py b/custom_components/pymc_repeater/coordinator.py index 53c63c3..c0bf9a7 100644 --- a/custom_components/pymc_repeater/coordinator.py +++ b/custom_components/pymc_repeater/coordinator.py @@ -4,6 +4,7 @@ import asyncio import contextlib +from datetime import timedelta import json import logging @@ -17,7 +18,13 @@ PyMCRepeaterAuthenticationError, PyMCRepeaterCannotConnect, ) -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ( + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL_SECONDS, + DOMAIN, + MAX_SCAN_INTERVAL_SECONDS, + MIN_SCAN_INTERVAL_SECONDS, +) _LOGGER = logging.getLogger(__name__) GPS_STREAM_RETRY_SECONDS = 15 @@ -32,11 +39,18 @@ def __init__( entry: ConfigEntry, api: PyMCRepeaterApiClient, ) -> None: + configured_scan_interval = int( + entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL_SECONDS) + ) + configured_scan_interval = max( + MIN_SCAN_INTERVAL_SECONDS, + min(MAX_SCAN_INTERVAL_SECONDS, configured_scan_interval), + ) super().__init__( hass, logger=_LOGGER, name=DOMAIN, - update_interval=DEFAULT_SCAN_INTERVAL, + update_interval=timedelta(seconds=configured_scan_interval), ) self.config_entry = entry self.api = api @@ -115,11 +129,16 @@ def _async_apply_gps_snapshot(self, snapshot: dict) -> None: if self._same_snapshot(old_snapshot, snapshot): return current["gps"] = snapshot - self.async_set_updated_data(current) + self._async_publish_partial_data(current) + + def _async_publish_partial_data(self, data: dict) -> None: + """Notify entities without postponing the coordinator's full refresh.""" + self.data = data + self.async_update_listeners() @staticmethod def _same_snapshot(old_snapshot: object, new_snapshot: dict) -> bool: - """Compare two GPS snapshots.""" + """Compare two coordinator snapshots.""" if not isinstance(old_snapshot, dict): return False return json.dumps(old_snapshot, sort_keys=True, default=str) == json.dumps( diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index 226f6bd..89ef108 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.5" + "version": "1.1.6" } diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index ae6397e..dbc1c49 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -137,6 +137,19 @@ def _packet_type_count(data: dict[str, Any]) -> int: return len(_packet_type_totals(data)) +def _neighbor_links(data: dict[str, Any]) -> list[dict[str, Any]]: + links = _nested(data, "neighbor_links", "links") or [] + return links if isinstance(links, list) else [] + + +def _active_neighbor_link_count(data: dict[str, Any]) -> int: + return sum( + 1 + for link in _neighbor_links(data) + if isinstance(link, dict) and link.get("active") + ) + + def _room_items(data: dict[str, Any]) -> list[dict[str, Any]]: payload = data.get("room_stats") or {} rooms = payload.get("rooms") if isinstance(payload, dict) else None @@ -842,6 +855,39 @@ class PyMCSensorDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: _nested(data, "db_stats", "rrd_size_bytes"), ), + PyMCSensorDescription( + key="metrics_data_source", + name="Metrics data source", + icon="mdi:database-sync-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: _nested(data, "db_stats", "metrics_data_source"), + attrs_fn=lambda data: { + "rrd_enabled": _nested(data, "db_stats", "rrd_enabled"), + "rrd_available": _nested(data, "db_stats", "rrd_available"), + }, + ), + PyMCSensorDescription( + key="neighbor_link_count", + name="Observed neighbor links", + icon="mdi:access-point-network", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "neighbor_links", "count"), + attrs_fn=lambda data: { + "active_within_seconds": _nested( + data, "neighbor_links", "active_within_seconds" + ), + "limit": _nested(data, "neighbor_links", "limit"), + }, + ), + PyMCSensorDescription( + key="active_neighbor_link_count", + name="Active neighbor links", + icon="mdi:access-point-network-off", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=_active_neighbor_link_count, + ), PyMCSensorDescription( key="transport_key_count", translation_key="transport_key_count", diff --git a/custom_components/pymc_repeater/services.yaml b/custom_components/pymc_repeater/services.yaml index 3359c2a..022264d 100644 --- a/custom_components/pymc_repeater/services.yaml +++ b/custom_components/pymc_repeater/services.yaml @@ -84,6 +84,27 @@ cad_calibration_start: selector: number: min: 0 + max: 2000 + mode: box + known_signal_present: + default: false + selector: + boolean: + cad_symbol_num: + default: 2 + selector: + select: + options: + - "1" + - "2" + - "4" + - "8" + - "16" + cad_timeout_ms: + default: 500 + selector: + number: + min: 50 max: 5000 mode: box @@ -96,6 +117,54 @@ cad_calibration_stop: config_entry: integration: pymc_repeater +cad_manual_check: + name: Run manual CAD check + description: Run immediate CAD checks and return detection diagnostics. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + samples: + default: 1 + selector: + number: + min: 1 + max: 32 + mode: box + det_peak: + selector: + number: + min: 0 + max: 255 + mode: box + det_min: + selector: + number: + min: 0 + max: 255 + mode: box + cad_symbol_num: + selector: + select: + options: + - "1" + - "2" + - "4" + - "8" + - "16" + cad_timeout_ms: + default: 500 + selector: + number: + min: 50 + max: 5000 + mode: box + apply_live: + default: false + selector: + boolean: + save_cad_settings: name: Save CAD settings description: Save CAD calibration settings to the repeater. @@ -118,6 +187,16 @@ save_cad_settings: min: 0 max: 255 mode: box + cad_symbol_num: + default: 2 + selector: + select: + options: + - "1" + - "2" + - "4" + - "8" + - "16" detection_rate: default: 0 selector: @@ -453,6 +532,63 @@ get_packet_by_hash: selector: text: +get_neighbor_links: + name: Get neighbor links + description: Return current observed upstream neighbor-link snapshots. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + active_within_seconds: + default: 90 + selector: + number: + min: 1 + max: 86400 + mode: box + limit: + default: 500 + selector: + number: + min: 1 + max: 5000 + mode: box + +get_neighbor_link_history: + name: Get neighbor link history + description: Return stored packet observations for one upstream neighbor hash. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + peer_hash: + required: true + selector: + text: + path_hash_size: + required: true + selector: + number: + min: 1 + max: 3 + mode: box + hours: + default: 24 + selector: + number: + min: 1 + max: 168 + mode: box + limit: + default: 1000 + selector: + number: + min: 1 + max: 5000 + mode: box + get_adverts_by_contact_type: name: Get adverts by contact type description: Return adverts for one contact type, with paging support. diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index cc48cb8..7aac20e 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -33,10 +33,11 @@ "step": { "init": { "title": "openHop Repeater options", - "description": "Adjust how selected openHop Repeater values are shown in Home Assistant.", + "description": "Adjust display units and how often the integration refreshes data from the Repeater API.", "data": { "data_size_unit": "Data size display unit", - "uptime_unit": "System uptime display unit" + "uptime_unit": "System uptime display unit", + "scan_interval": "Integration refresh interval (seconds)" } } } diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml new file mode 100644 index 0000000..5132707 --- /dev/null +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -0,0 +1,650 @@ +# openHop Repeater dashboard view template +# +# QUICK SETUP +# 1. In Home Assistant, find one entity for your Repeater, such as: +# sensor.my_repeater_repeater_version +# 2. The part before `_repeater_version` is your entity prefix (`my_repeater`). +# 3. Find and replace every `REPEATER_SLUG` below with that prefix. +# 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. +# +# This template uses only built-in Home Assistant cards. + +title: Example openHop Repeater +path: example-openhop-repeater +icon: mdi:radio-tower +type: masonry +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 + 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: horizontal-stack + cards: + - type: gauge + entity: sensor.REPEATER_SLUG_current_airtime + name: Airtime + min: 0 + max: 100 + severity: + green: 0 + yellow: 50 + red: 80 + - 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: + - entity: binary_sensor.REPEATER_SLUG_sensor_system_health_ok + name: System Health OK + icon: mdi:heart-pulse + - entity: sensor.REPEATER_SLUG_cpu_usage + name: CPU Usage + icon: mdi:cpu-64-bit + - entity: sensor.REPEATER_SLUG_memory_usage + name: Memory Usage + icon: mdi:memory + - entity: sensor.REPEATER_SLUG_memory_used + name: Memory Used + icon: mdi:memory + - entity: sensor.REPEATER_SLUG_disk_usage + name: Disk Usage + icon: mdi:harddisk + - entity: sensor.REPEATER_SLUG_system_uptime + name: System Uptime + icon: mdi:clock-outline + - entity: sensor.REPEATER_SLUG_total_processes + name: Total Processes + icon: mdi:application-cog-outline + - type: divider + - entity: sensor.REPEATER_SLUG_external_sensors_loaded + name: External Sensors Loaded + icon: mdi:devices + - 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: + - entity: sensor.REPEATER_SLUG_packets_24h + name: Packets Received 24h + icon: mdi:download-network + - entity: sensor.REPEATER_SLUG_transmitted_packets_24h + name: Transmitted Packets 24h + icon: mdi:upload-network + - entity: sensor.REPEATER_SLUG_dropped_packets_24h + name: Dropped Packets 24h + icon: mdi:close-network + - entity: sensor.REPEATER_SLUG_crc_errors_24h + name: CRC Errors 24h + icon: mdi:alert-circle-outline + - entity: sensor.REPEATER_SLUG_packets_received_per_hour + name: Packets Received / Hour + icon: mdi:chart-line + - entity: sensor.REPEATER_SLUG_packets_forwarded_per_hour + name: Packets Forwarded / Hour + icon: mdi:call-split + - 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_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 + + - 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 + + - 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 Advert + icon: mdi:bullhorn + - 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: + - entity: sensor.REPEATER_SLUG_connected_mqtt_brokers + name: Connected Brokers + icon: mdi:cloud-check + - entity: binary_sensor.REPEATER_SLUG_mqtt_broker_connected + name: Broker Connected + - 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 + name: Example Broker 2 + - entity: binary_sensor.REPEATER_SLUG_mqtt_example_broker_3 + 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: + - entity: sensor.REPEATER_SLUG_configured_companions + name: Configured Companions + icon: mdi:account-multiple + - entity: sensor.REPEATER_SLUG_running_companion_bridges + 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 + name: Example Bridge + - entity: sensor.REPEATER_SLUG_companion_example_channels + 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: + - entity: binary_sensor.REPEATER_SLUG_gps_enabled + name: GPS Enabled + - entity: binary_sensor.REPEATER_SLUG_gps_running + name: GPS Running + - entity: binary_sensor.REPEATER_SLUG_gps_fix_valid + name: Fix Valid + - entity: binary_sensor.REPEATER_SLUG_gps_stale + name: GPS Stale + - entity: sensor.REPEATER_SLUG_gps_state + name: GPS State + - entity: sensor.REPEATER_SLUG_gps_quality + name: GPS Quality + - entity: sensor.REPEATER_SLUG_gps_position_source + name: Position Source + - type: divider + - entity: sensor.REPEATER_SLUG_gps_latitude + name: Latitude + - entity: sensor.REPEATER_SLUG_gps_longitude + name: Longitude + - entity: sensor.REPEATER_SLUG_gps_altitude + name: Altitude + - entity: sensor.REPEATER_SLUG_gps_speed + name: Speed + - entity: sensor.REPEATER_SLUG_gps_course + name: Course + - entity: sensor.REPEATER_SLUG_gps_geoid_separation + name: Geoid Separation + - entity: sensor.REPEATER_SLUG_gps_magnetic_variation + name: Magnetic Variation + - type: divider + - entity: sensor.REPEATER_SLUG_gps_satellites_used + name: Satellites Used + - entity: sensor.REPEATER_SLUG_gps_satellites_in_view + name: Satellites In View + - entity: sensor.REPEATER_SLUG_gps_hdop + name: HDOP + - entity: sensor.REPEATER_SLUG_gps_pdop + name: PDOP + - entity: sensor.REPEATER_SLUG_gps_vdop + name: VDOP + + - 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: 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 + - entity: sensor.REPEATER_SLUG_sensor_modem_source + name: Source + icon: mdi:source-branch + - entity: sensor.REPEATER_SLUG_sensor_modem_url + name: Modem URL + icon: mdi:web + - entity: sensor.REPEATER_SLUG_sensor_modem_datetime_utc + 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 + + - 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: + - entity: sensor.REPEATER_SLUG_database_size + name: Database Size + icon: mdi:database + - entity: sensor.REPEATER_SLUG_metrics_history_size + name: Metrics History Size + icon: mdi:chart-timeline + - entity: sensor.REPEATER_SLUG_metrics_data_source + name: Metrics Data Source + icon: mdi:database-sync-outline + - entity: sensor.REPEATER_SLUG_db_rows_packets + name: Packet Rows + - entity: sensor.REPEATER_SLUG_db_rows_api_tokens + name: API Token Rows + - entity: sensor.REPEATER_SLUG_db_rows_migrations + name: Migration Rows + - entity: sensor.REPEATER_SLUG_db_rows_adverts + name: Advert Rows + - entity: sensor.REPEATER_SLUG_db_rows_crc_errors + name: CRC Error Rows + - entity: sensor.REPEATER_SLUG_db_rows_noise_floor + name: Noise Floor Rows + - entity: sensor.REPEATER_SLUG_db_rows_transport_keys + name: Transport Key Rows + - entity: sensor.REPEATER_SLUG_db_rows_room_messages + name: Room Message Rows + - entity: sensor.REPEATER_SLUG_db_rows_room_client_sync + name: Room Client Sync Rows + - entity: sensor.REPEATER_SLUG_db_rows_companion_messages + name: Companion Message Rows + - entity: sensor.REPEATER_SLUG_db_rows_companion_channels + name: Companion Channel Rows + - entity: sensor.REPEATER_SLUG_db_rows_companion_contacts + name: Companion Contact Rows + - entity: sensor.REPEATER_SLUG_db_rows_companion_prefs + name: Companion Pref Rows + - entity: button.REPEATER_SLUG_vacuum_metrics_database + name: Vacuum Metrics Database + icon: mdi:database-refresh + +show_icon_and_title: true diff --git a/dashboards/pymc_repeater_dashboard.yaml b/dashboards/pymc_repeater_dashboard.yaml deleted file mode 100644 index e85393a..0000000 --- a/dashboards/pymc_repeater_dashboard.yaml +++ /dev/null @@ -1,304 +0,0 @@ -# openHop Repeater dashboard template -# -# Replace every instance of REPEATER_SLUG with your real entity prefix. -# Example: -# sensor.repeater_name_here_radio_utilization -# binary_sensor.repeater_name_here_mqtt_handler_active -# -# Home Assistant entity IDs are based on the entity's display name and can also -# be preserved by the entity registry across updates. If one row does not match -# your install exactly, swap it for the real entity from HA's entity picker. -# -# Dynamic broker and companion entities vary by installation. In the Network -# view below, swap the example placeholders for the broker/companion entity IDs -# that exist in your Home Assistant instance. - -title: openHop Repeater -views: - - title: Overview - path: overview - icon: mdi:radio-tower - badges: [] - cards: - - type: markdown - content: | - # openHop Repeater - Clean operational view for radio health, MQTT state, packet flow, and system load. - - - type: grid - columns: 4 - square: false - cards: - - type: tile - entity: sensor.REPEATER_SLUG_repeater_version - name: Version - vertical: true - - type: tile - entity: sensor.REPEATER_SLUG_system_uptime - name: Uptime - vertical: true - - type: tile - entity: sensor.REPEATER_SLUG_connected_mqtt_brokers - name: MQTT Up - vertical: true - - type: tile - entity: sensor.REPEATER_SLUG_configured_companions - name: Companions - vertical: true - - - type: grid - columns: 4 - square: false - cards: - - type: gauge - entity: sensor.REPEATER_SLUG_radio_utilization - name: Radio Utilization - min: 0 - max: 100 - severity: - green: 0 - yellow: 40 - red: 75 - - type: gauge - entity: sensor.REPEATER_SLUG_packet_drop_rate_24h - name: Packet Drop - min: 0 - max: 100 - severity: - green: 0 - yellow: 10 - red: 25 - - type: gauge - entity: sensor.REPEATER_SLUG_cpu_usage - name: CPU - min: 0 - max: 100 - severity: - green: 0 - yellow: 60 - red: 85 - - type: gauge - entity: sensor.REPEATER_SLUG_disk_usage - name: Disk - min: 0 - max: 100 - severity: - green: 0 - yellow: 70 - red: 90 - - - type: history-graph - title: Packet Activity - hours_to_show: 24 - refresh_interval: 60 - entities: - - entity: sensor.REPEATER_SLUG_packets_24h - name: Total - - entity: sensor.REPEATER_SLUG_transmitted_packets_24h - name: TX - - entity: sensor.REPEATER_SLUG_dropped_packets_24h - name: Dropped - - - type: history-graph - title: RF Noise Trend - hours_to_show: 24 - refresh_interval: 60 - entities: - - entity: sensor.REPEATER_SLUG_current_noise_floor - name: Current - - entity: sensor.REPEATER_SLUG_avg_noise_floor_24h - name: Average - - - type: grid - columns: 3 - square: false - cards: - - type: tile - entity: sensor.REPEATER_SLUG_packets_received_per_hour - name: RX / Hour - - type: tile - entity: sensor.REPEATER_SLUG_packets_forwarded_per_hour - name: Forwarded / Hour - - type: tile - entity: sensor.REPEATER_SLUG_current_airtime - name: Current Airtime - - type: tile - entity: sensor.REPEATER_SLUG_average_rssi_24h - name: Avg RSSI - - type: tile - entity: sensor.REPEATER_SLUG_average_snr_24h - name: Avg SNR - - type: tile - entity: sensor.REPEATER_SLUG_crc_errors_24h - name: CRC Errors - - - type: entities - title: System and Storage - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_memory_usage - name: Memory usage - - entity: sensor.REPEATER_SLUG_memory_used - name: Memory used - - entity: sensor.REPEATER_SLUG_database_size - name: Database size - - entity: sensor.REPEATER_SLUG_metrics_history_size - name: Metrics history size - - entity: sensor.REPEATER_SLUG_network_bytes_sent - name: Network sent - - entity: sensor.REPEATER_SLUG_network_bytes_received - name: Network received - - entity: sensor.REPEATER_SLUG_total_processes - name: Processes - - - title: Network - path: network - icon: mdi:lan - badges: [] - cards: - - type: entities - title: MQTT Summary - show_header_toggle: false - entities: - - entity: binary_sensor.REPEATER_SLUG_mqtt_handler_active - name: MQTT handler - - entity: binary_sensor.REPEATER_SLUG_mqtt_broker_connected - name: Any broker connected - - entity: sensor.REPEATER_SLUG_mqtt_connected_brokers - name: Connected brokers - - - type: entities - title: Broker Connections - show_header_toggle: false - entities: - # Replace these example rows with the broker entities created in your HA instance. - - entity: binary_sensor.REPEATER_SLUG_mqtt_localhost - name: Localhost - - entity: binary_sensor.REPEATER_SLUG_mqtt_us_west_letsmesh_v1 - name: US West - - entity: binary_sensor.REPEATER_SLUG_mqtt_meshmapper - name: MeshMapper - - - type: entities - title: ACL and Identity - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_acl_clients - name: ACL clients - - entity: sensor.REPEATER_SLUG_authenticated_clients - name: Authenticated clients - - entity: sensor.REPEATER_SLUG_registered_identities - name: Registered identities - - entity: sensor.REPEATER_SLUG_configured_companions - name: Configured companions - - - type: entities - title: Companion Presence - show_header_toggle: false - entities: - # Replace these example rows with the companion entities created in your HA instance. - - entity: binary_sensor.REPEATER_SLUG_companion_phone - name: Phone - - entity: binary_sensor.REPEATER_SLUG_companion_tablet - name: Tablet - - - type: entities - title: Routing and Advert Rate Control - show_header_toggle: false - entities: - - entity: sensor.REPEATER_SLUG_advert_tier - name: Advert tier - - entity: sensor.REPEATER_SLUG_adverts_allowed - name: Adverts allowed - - entity: sensor.REPEATER_SLUG_adverts_dropped - name: Adverts dropped - - entity: sensor.REPEATER_SLUG_advert_drop_rate - name: Advert drop rate - - entity: binary_sensor.REPEATER_SLUG_advert_adaptive_enabled - name: Adaptive enabled - - entity: binary_sensor.REPEATER_SLUG_advert_dedupe_enabled - name: Dedupe enabled - - - title: Control - path: control - icon: mdi:tune - badges: [] - cards: - - type: grid - columns: 3 - square: false - cards: - - type: tile - entity: select.REPEATER_SLUG_repeater_mode - name: Mode - features_position: bottom - - type: tile - entity: switch.REPEATER_SLUG_duty_cycle_enforcement - name: Duty Cycle - - type: tile - entity: number.REPEATER_SLUG_max_airtime_percent - name: Max Airtime - features_position: bottom - - type: tile - entity: switch.REPEATER_SLUG_advert_rate_limit - name: Rate Limit - - type: tile - entity: switch.REPEATER_SLUG_advert_penalty_box - name: Penalty Box - - type: tile - entity: switch.REPEATER_SLUG_advert_adaptive_control - name: Adaptive - - - type: horizontal-stack - cards: - - type: button - entity: button.REPEATER_SLUG_send_advert - name: Send Advert - icon: mdi:radio-tower - tap_action: - action: toggle - - type: button - entity: button.REPEATER_SLUG_restart_repeater_service - name: Restart - icon: mdi:restart - tap_action: - action: toggle - - type: button - entity: button.REPEATER_SLUG_vacuum_metrics_database - name: Vacuum DB - icon: mdi:database-refresh-outline - tap_action: - action: toggle - - - type: entities - title: Advert Rate Limit Tuning - show_header_toggle: false - entities: - - 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_minimum_interval - name: Minimum interval - - 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_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_ewma_alpha - name: EWMA alpha - - entity: number.REPEATER_SLUG_advert_hysteresis - name: Hysteresis - - 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 diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..b46f807 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,217 @@ +# Repository Guide for Coding Agents + +## Scope and purpose + +This repository is a HACS-compatible Home Assistant custom integration for monitoring and controlling an [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) through its local HTTP API. + +Important compatibility names: + +- User-facing name: **openHop Repeater**. +- Home Assistant domain and component directory: **`pymc_repeater`**. +- Do not rename the domain, `custom_components/pymc_repeater`, existing config-entry keys, service domain, or established entity unique-ID suffixes without an explicit migration. The retained pyMC identifiers preserve existing installations. + +## Repository map + +- `custom_components/pymc_repeater/`: installable integration. + - `manifest.json`: Home Assistant/HACS metadata and release version. + - `config_flow.py`: initial setup, token bootstrap, reauthentication, and options flow. + - `api.py`: asynchronous Repeater HTTP client and endpoint normalization. + - `coordinator.py`: integration-wide polling and the GPS SSE listener. + - `__init__.py`: config-entry lifecycle, platform forwarding, and advanced Home Assistant action registration. + - `sensor.py`, `binary_sensor.py`, `button.py`, `select.py`, `switch.py`, `number.py`: entity platforms. + - `services.yaml`: user-visible action descriptions and selectors. + - `translations/en.json`: config/options strings and translated entity names. + - `diagnostics.py`: downloadable diagnostics and redaction. + - `brand/`: checked-in 1x/2x PNG icons. +- `tests/test_dev_api_alignment.py`: source/AST contract tests that run without installing Home Assistant. +- `dashboards/openhop_repeater_dashboard.yaml`: anonymized Lovelace view template; users replace `REPEATER_SLUG`. +- `.github/workflows/`: HACS, Hassfest, and Python smoke checks. +- `hacs.json`: HACS display name, README rendering, and minimum Home Assistant version. +- `CHANGELOG.md`: release history; keep the current release section complete. +- `RELEASING.md`: stable release checklist. +- `.github/release.yml`: GitHub-generated release-note categories. + +There is no standalone Python/JavaScript package manifest, requirements file, Makefile, Dockerfile, deployment definition, formatter configuration, linter configuration, type-checker configuration, or pytest configuration in this repository. + +## Runtime and dependencies + +- Language: Python plus JSON, YAML, Markdown, and PNG assets. +- Runtime target verified from CI: **Python 3.12**. +- Minimum Home Assistant version: **2024.1.0**, from `hacs.json`. +- Integration model: Home Assistant config-entry integration with `iot_class: local_polling` and six forwarded platforms: sensor, binary sensor, button, select, switch, and number. +- `manifest.json` declares no third-party requirements. Home Assistant supplies its own APIs and the imported runtime libraries (`aiohttp`, `yarl`, and `voluptuous`). +- No repository-supported standalone virtual-environment or dependency-install command exists. The source-only contract suite uses the Python standard library. + +Do not invent a pip/npm setup step. For live runtime testing, install the component in a compatible Home Assistant development or test instance; this repository does not provide that harness. + +## Verified commands + +Run commands from the repository root. + +Full source-contract suite: + +```bash +python3 -m unittest discover -s tests -v +``` + +Focused contract test: + +```bash +python3 -m unittest tests.test_dev_api_alignment.DevApiAlignmentTests.test_integration_polling_is_configurable_and_defaults_to_15_seconds -v +``` + +Replace the final method name with another method from `DevApiAlignmentTests` when focusing on a different contract. + +Compile all integration modules: + +```bash +python3 -m compileall -q custom_components/pymc_repeater +``` + +Validate checked-in JSON: + +```bash +python3 -m json.tool custom_components/pymc_repeater/manifest.json >/dev/null +python3 -m json.tool custom_components/pymc_repeater/translations/en.json >/dev/null +python3 -m json.tool hacs.json >/dev/null +``` + +Check patch whitespace: + +```bash +git diff --check +``` + +Optional YAML parse check, only when PyYAML is already available (PyYAML is not declared by this repository): + +```bash +python3 - <<'PY' +import yaml + +for path in ( + "custom_components/pymc_repeater/services.yaml", + "dashboards/openhop_repeater_dashboard.yaml", + ".github/workflows/python-smoke.yaml", + ".github/workflows/hacs.yaml", + ".github/workflows/hassfest.yaml", +): + with open(path, encoding="utf-8") as stream: + assert yaml.safe_load(stream) is not None, path +PY +``` + +No local build, formatter, lint, or type-check command is defined. Do not claim Ruff, Black, mypy, pytest, or a packaging build passed unless the repository later adds and configures it. + +## Architecture and control flow + +1. `config_flow.py` normalizes the host field, combines it with the separately entered port for the unique ID, logs in once with the admin password, creates a dedicated API token, and stores the token metadata—not the admin password—in the config entry. +2. `async_setup_entry` in `__init__.py` creates one `PyMCRepeaterApiClient` using Home Assistant's shared aiohttp session, creates the coordinator, performs the first refresh, starts runtime tasks, then forwards all entity platforms. +3. `PyMCRepeaterDataUpdateCoordinator` runs one configurable full-API refresh. The default is 15 seconds and the enforced range is 5–300 seconds. +4. `api.async_fetch_all()` starts the normal endpoint requests concurrently. Authentication and connection failures fail the coordinator refresh; other endpoint failures are retained as `{ "error": ... }` for that payload key so unrelated telemetry can remain available. +5. GPS uses `/api/gps_stream` as a separate SSE task. Partial GPS snapshots update listeners with `async_update_listeners()` and must not call `async_set_updated_data()`, because that would postpone the next scheduled full refresh. +6. Static entities are built from description tuples. Dynamic entities use API-provided MQTT brokers, rooms, companions, database tables, temperatures, and external sensor readings. External sensor entities may be added later through coordinator listeners. +7. Config/control entities call the API and normally request a coordinator refresh afterward. Restart/install operations intentionally skip immediate refresh where the remote service may be unavailable. +8. Advanced actions are registered once for the `pymc_repeater` domain in `__init__.py`. When multiple entries exist, callers must supply `config_entry_id`. Response-returning actions use Home Assistant's `SupportsResponse` behavior. + +Keep large or filtered payloads such as neighbor-link history on demand through actions instead of adding them to every coordinator update or entity state. + +## Coding conventions + +Follow patterns already present in the component: + +- Start Python modules with a short module docstring and `from __future__ import annotations`. +- Use async Home Assistant APIs; reuse `async_get_clientsession(hass)` rather than creating sessions. +- Add type hints to public helpers, methods, and payload shapes. Existing description records use frozen, keyword-only dataclasses; `BootstrapResult` uses `slots=True`. +- Use `_LOGGER = logging.getLogger(__name__)`; never use `print` in integration code. +- Entity names are sentence case. Entities set `_attr_has_entity_name = True` and attach to the shared Repeater `DeviceInfo`. +- Stable unique IDs use `entry.unique_id or entry.entry_id` plus a durable key. Dynamic names are normalized with Home Assistant's `slugify`. +- Prefer description tables (`SENSORS`, `BINARY_SENSORS`, `BUTTONS`, `SWITCHES`, `NUMBERS`) over repetitive entity classes when behavior is uniform. +- Read nested coordinator fields defensively with `_nested(...)`, tolerate absent optional API data, and expose configuration/diagnostic categories consistently. +- Preserve API payload field names exactly when they are part of the Repeater contract. + +No formatter is configured. Preserve the surrounding style and make `compileall`, tests, and `git diff --check` pass. + +## Change rules + +### API or telemetry additions + +- Add or update the async wrapper in `api.py`. +- Add steady, lightweight telemetry to `async_fetch_all()` only when it belongs in every poll. Keep histories and large query results action-only. +- Map coordinator keys to entities in the appropriate platform file. +- Preserve partial-endpoint failure behavior and the special authentication/connection handling. +- Add source-contract coverage in `tests/test_dev_api_alignment.py`; tests must remain importable without Home Assistant unless a real Home Assistant test harness is intentionally introduced. + +### Entities and controls + +- Do not change an existing description key or unique-ID format casually; that can create replacement entities in Home Assistant. +- Add user-facing translated names to `translations/en.json` when using `translation_key`. +- For write entities, update the Repeater through `api.py` and request a coordinator refresh unless the operation disrupts connectivity. +- For dynamic entities, deduplicate by stable unique ID and mark entities unavailable when their source object disappears. + +### Actions + +Keep these three surfaces synchronized: + +1. API client method in `api.py`. +2. Registration, Voluptuous schema, refresh behavior, and response support in `__init__.py`. +3. Name, description, required fields, bounds, and selectors in `services.yaml`. + +Add or update contract tests whenever endpoint paths, fields, limits, or response behavior changes. + +### Options, authentication, and diagnostics + +- Option changes reload the config entry. Keep defaults and bounds synchronized among `const.py`, `config_flow.py`, `coordinator.py`, translations, tests, and README. +- Never store or log the admin password. Treat API tokens, JWTs, Home Assistant tokens, room/message data, identities, GPS data, and client addresses as sensitive. +- `diagnostics.py` currently redacts `api_token` from config-entry data but returns coordinator data as-is. If a new coordinator payload can contain credentials or other secrets, expand diagnostics redaction before exposing it. +- The API base URL is plain HTTP. Do not claim transport security; documentation requires a trusted local network, VPN, or equivalent boundary. + +### Documentation and dashboard + +- Keep `README.md`, `CHANGELOG.md`, action documentation, and the dashboard aligned with user-visible additions. +- Keep the dashboard anonymized. Do not commit real node names, broker names, companion names, locations, entity prefixes, or coordinates. Preserve `REPEATER_SLUG` and its setup comments. +- Update dashboard contract assertions if the template path or required v1.1.6 entities change. + +## CI and release expectations + +GitHub Actions provides: + +- `Python Smoke`: Python 3.12 JSON validation, `compileall`, and unittest discovery. +- `HACS`: `hacs/action@main` for integration validation. +- `Hassfest`: `home-assistant/actions/hassfest@master`. + +Workflow path filters mean README-, changelog-, or dashboard-only edits may not trigger every workflow. Run applicable local checks even when GitHub does not schedule them. HACS and Hassfest are not reproduced by a checked-in local command; verify their GitHub runs for integration metadata/platform changes. + +Observed branch/release convention: + +- Development changes accumulate on `dev`. +- Stable work is merged into `main`; release tags are on the stable line. +- Before release, ensure `main` CI is green, set the intended version in `custom_components/pymc_repeater/manifest.json`, update `CHANGELOG.md`, create a matching `vX.Y.Z` tag, and create a GitHub release from that tag. +- `.github/release.yml` groups PRs by labels; `skip-changelog` excludes a PR from generated notes. +- Dependabot only updates GitHub Actions, weekly on Monday. + +There is no generated-code or vendored-code directory identified. Do not commit ignored `__pycache__/`, `*.pyc`, `.pytest_cache/`, or `.ruff_cache/` files. Treat `brand/*.png` as intentional binary assets and update both resolutions when changing branding. + +## Repository-specific pitfalls + +- The public name is openHop, but compatibility identifiers still say pyMC. A broad rename is a breaking migration, not cleanup. +- The contract tests inspect source and AST; they verify important wiring but do not prove Home Assistant runtime behavior or compatibility with a live Repeater. +- Keep one integration-wide polling schedule plus GPS SSE. The tests intentionally reject a separate external-sensor polling loop. +- Do not let GPS stream updates reset the coordinator's next full refresh. +- `async_fetch_all()` intentionally tolerates optional endpoint failures but not authentication or connection failure; preserve that distinction. +- Repeater API changes should be checked against the corresponding Repeater runtime/OpenAPI contract rather than inferred from UI behavior. +- Multiple configured Repeaters make an omitted action `config_entry_id` ambiguous. +- The Repeater's persistent token database is outside this repository. Documentation for the Home Assistant add-on uses `/var/lib/openhop_repeater`; do not reintroduce the old unmapped `/var/lib/pymc_repeater` path. + +## Completion checklist + +- [ ] Scope is narrow and compatibility identifiers/unique IDs are preserved. +- [ ] API, coordinator, entities, actions, services YAML, translations, and docs are synchronized where applicable. +- [ ] New behavior has a focused contract test; bug fixes include a regression assertion. +- [ ] `python3 -m unittest discover -s tests -v` passes. +- [ ] `python3 -m compileall -q custom_components/pymc_repeater` passes. +- [ ] Manifest, translations, and `hacs.json` parse as JSON. +- [ ] Changed YAML parses when a YAML parser is available. +- [ ] `git diff --check` passes and no cache/generated artifacts are staged. +- [ ] No credentials, private node data, GPS coordinates, or identifying dashboard values are present. +- [ ] `manifest.json` and `CHANGELOG.md` agree for release work. +- [ ] Relevant GitHub HACS, Hassfest, and Python Smoke checks are green before declaring release-ready. diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py new file mode 100644 index 0000000..9f33baf --- /dev/null +++ b/tests/test_dev_api_alignment.py @@ -0,0 +1,172 @@ +"""Contract tests for openHop Repeater dev API alignment. + +These tests intentionally use the Python AST so they can run without installing +Home Assistant while still pinning the integration's public API/service contract. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COMPONENT = ROOT / "custom_components" / "pymc_repeater" + + +def _module(name: str) -> ast.Module: + return ast.parse((COMPONENT / name).read_text(encoding="utf-8")) + + +def _class_method(module: ast.Module, class_name: str, method_name: str) -> ast.AsyncFunctionDef: + for node in module.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + for child in node.body: + if isinstance(child, ast.AsyncFunctionDef) and child.name == method_name: + return child + raise AssertionError(f"Missing {class_name}.{method_name}") + + +def _async_function(module: ast.Module, name: str) -> ast.AsyncFunctionDef: + for node in module.body: + if isinstance(node, ast.AsyncFunctionDef) and node.name == name: + return node + raise AssertionError(f"Missing async function {name}") + + +class DevApiAlignmentTests(unittest.TestCase): + def test_neighbor_link_client_methods_and_polling_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) + + self.assertIn("async_get_neighbor_links", source) + self.assertIn("/api/neighbor_links", source) + self.assertIn("active_within_seconds", source) + self.assertIn("async_get_neighbor_link_history", source) + self.assertIn("/api/neighbor_link_history", source) + self.assertIn("'neighbor_links': self.async_get_neighbor_links()", source) + + def test_cad_client_contract_includes_new_dev_fields(self) -> None: + module = _module("api.py") + start = ast.unparse( + _class_method(module, "PyMCRepeaterApiClient", "async_cad_calibration_start") + ) + manual = ast.unparse( + _class_method(module, "PyMCRepeaterApiClient", "async_cad_manual_check") + ) + save = ast.unparse( + _class_method(module, "PyMCRepeaterApiClient", "async_save_cad_settings") + ) + + for field in ("known_signal_present", "cad_symbol_num", "cad_timeout_ms"): + self.assertIn(field, start) + for field in ( + "samples", + "det_peak", + "det_min", + "cad_symbol_num", + "cad_timeout_ms", + "apply_live", + ): + self.assertIn(field, manual) + self.assertIn("/api/cad_manual_check", manual) + self.assertIn("cad_symbol_num", save) + + def test_home_assistant_services_cover_new_routes_and_bounds(self) -> None: + module = _module("__init__.py") + setup = ast.unparse(_async_function(module, "_async_register_services")) + + for service in ( + "SERVICE_CAD_MANUAL_CHECK", + "SERVICE_GET_NEIGHBOR_LINKS", + "SERVICE_GET_NEIGHBOR_LINK_HISTORY", + ): + self.assertIn(service, setup) + self.assertIn("vol.In([1, 2, 4, 8, 16])", setup) + self.assertIn("vol.Range(min=50, max=5000)", setup) + self.assertIn("vol.Range(min=1, max=3)", setup) + self.assertIn("vol.Range(min=1, max=168)", setup) + self.assertIn("vol.Range(min=1, max=5000)", setup) + + def test_new_diagnostics_are_exposed_as_sensors(self) -> None: + source = (COMPONENT / "sensor.py").read_text(encoding="utf-8") + self.assertIn('key="neighbor_link_count"', source) + self.assertIn('key="active_neighbor_link_count"', source) + self.assertIn('key="metrics_data_source"', 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("cad_symbol_num:", source) + self.assertIn("known_signal_present:", source) + + def test_integration_polling_is_configurable_and_defaults_to_15_seconds(self) -> None: + constants = (COMPONENT / "const.py").read_text(encoding="utf-8") + coordinator = (COMPONENT / "coordinator.py").read_text(encoding="utf-8") + config_flow = (COMPONENT / "config_flow.py").read_text(encoding="utf-8") + + self.assertIn("CONF_SCAN_INTERVAL", constants) + self.assertIn("DEFAULT_SCAN_INTERVAL_SECONDS = 15", constants) + self.assertIn("CONF_SCAN_INTERVAL", coordinator) + self.assertIn("update_interval=timedelta(seconds=configured_scan_interval)", coordinator) + self.assertNotIn("_sensor_poll_task", coordinator) + self.assertNotIn("_async_sensor_poll_loop", coordinator) + self.assertIn("CONF_SCAN_INTERVAL", config_flow) + self.assertIn("MIN_SCAN_INTERVAL_SECONDS", config_flow) + self.assertIn("MAX_SCAN_INTERVAL_SECONDS", config_flow) + 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: + 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.assertIn("## 1.1.6", changelog) + self.assertNotIn("## Unreleased", changelog) + for expected in ( + "15 seconds", + "GPS stream", + "neighbor-link snapshots and history", + "manual CAD check", + "metrics storage source", + "contract tests", + "actions/setup-python@v7", + ): + self.assertIn(expected, changelog) + self.assertIn("actions/setup-python@v7", workflow) + + 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" + ) + + self.assertIn("REPEATER_SLUG", dashboard) + 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("External Modem Status", dashboard) + self.assertIn("Example Broker 1", dashboard) + self.assertIn("Example Companion", dashboard) + for private_value in ( + "Pleasant Cove", + "pleasant-cove", + "shop_pleasant", + "Boston Mesh", + "Cove Companion", + "Cove Bridge", + ): + self.assertNotIn(private_value, dashboard) + + +if __name__ == "__main__": + unittest.main()