From 1dac7ab42ed33c966e326ff9e2b5126b209945ee Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:11:26 -0400 Subject: [PATCH 1/4] feat: expand Repeater HA monitoring and management for 1.3.0 --- .github/dependabot.yml | 1 + .github/workflows/python-smoke.yaml | 7 +- CHANGELOG.md | 26 + README.md | 123 +++- custom_components/pymc_repeater/__init__.py | 454 +++++++++--- custom_components/pymc_repeater/api.py | 658 +++++++++++++++++- custom_components/pymc_repeater/manifest.json | 2 +- custom_components/pymc_repeater/monitoring.py | 94 +++ .../pymc_repeater/monitoring_entities.py | 18 +- custom_components/pymc_repeater/services.yaml | 438 +++++++++++- .../pymc_repeater/translations/en.json | 24 + dashboards/openhop_operations.yaml | 15 + dashboards/openhop_repeater_dashboard.yaml | 157 +++++ docs/AGENTS.md | 6 +- docs/dashboard-management.md | 130 ++++ docs/dev-api-1.3.0.md | 118 ++++ docs/management-actions.md | 222 ++++++ docs/operational-monitoring.md | 58 +- docs/plugin-upgrades.md | 89 +++ examples/daily-plugin-upgrades.yaml | 29 + examples/openhop_dashboard_scripts.yaml | 520 ++++++++++++++ tests/test_comprehensive_dashboard.py | 23 + tests/test_dashboard_management.py | 153 ++++ tests/test_dev_130_actions.py | 345 +++++++++ tests/test_dev_api_alignment.py | 8 +- tests/test_installed_api_behavior.py | 13 +- tests/test_management_actions.py | 320 +++++++++ tests/test_plugin_upgrades.py | 248 +++++++ tests/test_radio_telemetry.py | 283 ++++++++ 29 files changed, 4419 insertions(+), 163 deletions(-) create mode 100644 docs/dashboard-management.md create mode 100644 docs/dev-api-1.3.0.md create mode 100644 docs/management-actions.md create mode 100644 docs/plugin-upgrades.md create mode 100644 examples/daily-plugin-upgrades.yaml create mode 100644 examples/openhop_dashboard_scripts.yaml create mode 100644 tests/test_dashboard_management.py create mode 100644 tests/test_dev_130_actions.py create mode 100644 tests/test_management_actions.py create mode 100644 tests/test_plugin_upgrades.py create mode 100644 tests/test_radio_telemetry.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3e18ee9..c632f55 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,7 @@ version: 2 updates: - package-ecosystem: github-actions directory: "/" + target-branch: dev schedule: interval: weekly day: monday diff --git a/.github/workflows/python-smoke.yaml b/.github/workflows/python-smoke.yaml index 883d893..af8c3e0 100644 --- a/.github/workflows/python-smoke.yaml +++ b/.github/workflows/python-smoke.yaml @@ -23,6 +23,11 @@ permissions: {} jobs: smoke: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # HA 2024.1 requires 3.11; current HA 2026.9 requires 3.14. + python-version: ["3.11", "3.12", "3.13", "3.14"] permissions: contents: read steps: @@ -32,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Validate JSON files run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index c599ed0..8f3a486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ## Unreleased +## 1.3.0 + +- fixed advanced action dispatch by registering native async handlers so home assistant awaits operations and receives actual responses + +- added explicit catalogue install and plugin enable, disable, start, stop, restart, data-preserving uninstall, and settings management actions with shared fail-fast write admission and bounded responses +- added sensor type and configuration actions with complete replacement validation, preserved password masks and rename origins, and explicit restart-required save results +- added acl permission management with firmware role-byte bounds and optional responses and extended client removal with named identities while preserving legacy public_key calls +- redacted common credentials from explicit configuration reads by default and documented sensitive opt-in edits, action traces, hardware side effects, and wheel-upload and raw-log limitations +- added bounded response-returning plugin update checks, optional-version single upgrades, and sequential catalogue-only update-all actions without changing polling or enabling a schedule +- guarded overlapping plugin upgrades and stopped uncertain batches without retrying writes while excluding settings and raw installation logs from outcomes +- documented operator-owned daily 03:00 plugin upgrades with an anonymized single-mode automation and captured action responses +- added alias-safe radio child sensors for shared channel utilization and airtime budgets and cached noise without extra polling or assigning aggregate readings to children +- added per-radio one-hour and 24-hour received, duplicate, physical transmission, average rssi, and average snr measurements while keeping sliding windows distinct from lifetime totals +- added per-radio 24-hour lbt transmission, retry, attempt, failure, busy-channel, and severe-contention summaries without storing raw timelines in entity attributes +- added response-returning radio packet rate, noise-floor statistics, crc error count, local companion statistics, and lbt diagnostics actions with bounded queries and no coordinator refresh +- added optional exact radio filters and per-radio bucket grouping to neighbor-history actions while preserving raw rows and omitted-field defaults +- rejected fractional, boolean, and nonfinite neighbor-history query values before http instead of silently truncating or coercing them +- increased advert-send http timeout to 15 seconds to allow for the backend's 10-second response wait +- fixed zero-sample noise averages displaying as measured 0 dbm while preserving legacy replies without sample counts +- added radio telemetry dashboard examples and documented independent child entity ids, shared-channel budgets, fan-out transmission counts, and older-backend limitations +- extended bundled dashboards with response-aware management scripts, confirmed plugin upgrades, editor-only settings and acl workflows, data-preserving uninstall, and on-demand diagnostic buttons +- documented the latest repeater dev api review and unresolved backend per-reading cadence metadata limitation while preserving existing integration identifiers, radio aliases, and polling schedules +- expanded python source-contract ci coverage to 3.11–3.14 and directed dependabot version updates to dev +- checked runtime and ci dependencies while retaining home assistant-managed library versions and the existing minimum home assistant version +- expanded executable client, action, radio-discovery, availability, validation, translation, and dashboard regression coverage + ## 1.2.1 - fixed the GPS stream listener blocking Home Assistant startup wrap-up by using a config-entry background task, as reported in [#16](https://github.com/openhop-dev/openHop-HA-Integration/issues/16) diff --git a/README.md b/README.md index 5df90a0..9a0ba90 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,13 @@ Normal polling reads the Repeater's cached update status without discovering Git - UI-based setup and options flows - Configurable integration-wide polling interval - Repeater, single/multi-radio stack, packet, routing, and signal-quality telemetry +- Named radio airtime budgets, cached noise, packet-window and LBT summaries when the backend supplies per-radio 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 - Application-plugin health counts (installed, enabled, running, and failed) +- [Explicit plugin update checks, single upgrades and sequential update-all](docs/plugin-upgrades.md), with an opt-in [daily 03:00 automation example](examples/daily-plugin-upgrades.yaml); no plugin schedule is enabled automatically - 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 @@ -127,6 +129,33 @@ 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. +### Install management companion scripts + +The 1.3.0 view also includes read-only catalogue/update checks, an explicitly +confirmed bulk upgrade, sensor-type inspection, five on-demand diagnostic buttons, +and short plugin/settings, sensor and ACL editor workflows. + +Merge [`examples/openhop_dashboard_scripts.yaml`](examples/openhop_dashboard_scripts.yaml) +into your existing `scripts.yaml` without overwriting existing scripts. Replace +**every `EXAMPLE_CONFIG_ENTRY_ID`** with the actual integration entry ID selected +in Developer tools → Actions (`config_entry_id` in its YAML) or shown in the +integration entry URL. **`REPEATER_SLUG` is an entity prefix, not an entry ID.** +Rename the `openhop_example_...` script IDs and matching dashboard targets for +each additional Repeater, check configuration, and reload scripts. These script +mappings belong inside the file included by `script: !include scripts.yaml`, +not under a second `script:` key. Remove script buttons if not installing them. + +Response-only actions use sibling `response_variable` in companion scripts, not +direct Lovelace calls. Notifications show only public outcome summaries, never +configuration, keys or logs. Required lifecycle/configuration/ACL fields are +entered through **Developer tools → Actions**, not a one-click placeholder write. +All write wrappers require explicit review confirmation; uninstall preserves data. +Read the [dashboard workflow guide](docs/dashboard-management.md) and +[management action contracts](docs/management-actions.md) before changing settings +or access permissions. Configuration reads and sensitive edit inputs remain +editor-only and may appear in private HA traces; never store them as sensor attrs. +No automation schedule or automatic Repeater restart is installed. + ### Layout and compatibility - Uses built-in Home Assistant cards; no custom card installation is required @@ -136,14 +165,16 @@ To use it: - Shows named badges, compact metric tiles, and explicit API, statistics, and radio problem banners; disabled GPS does not trigger an alarm The template uses the current **Sections** frontend, which requires a newer Home -Assistant frontend than the integration's minimum supported version. Configuration -was saved and read back on HA 2026.9; that is not a rendered-layout verification. +Assistant frontend than the integration's minimum supported version. An earlier +layout was saved and read back on HA 2026.9; the new management extension has only +local structural/YAML verification, not live schema or rendered-layout verification. Numeric precision remains controlled by Home Assistant, without global entity-registry changes. ### Match entities to your installation - Use exact entity IDs from the same Repeater config entry, including its radio child devices - Radio child prefixes are independent of `REPEATER_SLUG`; replacing the parent prefix alone does not resolve every row +- Replace every complete `sensor.EXAMPLE_RADIO_...` telemetry ID with the exact active entity from that radio child; repeat the card for other radios or remove unsupported rows - Repeat rows for multiple radios, plugins, or sensor sources, and remove unsupported rows or cards - Keep the existing view's title, path, icon, and other metadata when replacing it, and back up the complete dashboard first @@ -216,20 +247,84 @@ 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 +Ping reply waits are bounded to 1–60 seconds, with 6 additional HTTP seconds. +Companion status and telemetry waits accept 1–120 seconds, with 10 additional +HTTP seconds; text/channel sends, login and commands have finite budgets matching +backend waits. `companion_request_status` and `companion_request_telemetry` support +optional `response_variable` results while retaining calls without responses. +Explicit `sent: false` raises an action error; older responses without `sent` +remain compatible. Results are never automatically polled or stored in entities. 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. +### Radio diagnostics and rate-history actions + +These five actions return data through `response_variable` and never trigger a +coordinator refresh or add background requests: + +| Action | Fields | +| --- | --- | +| `pymc_repeater.get_radio_packet_rates` | `hours` 1–168; optional `bucket_seconds` 60–86400 | +| `pymc_repeater.get_noise_floor_stats` | `hours` 1–168; optional exact `radio_id`; zero-sample mean is unknown | +| `pymc_repeater.get_crc_error_count` | `hours` 1–168; optional exact `radio_id` | +| `pymc_repeater.get_companion_stats` | `type`: `core`, `radio`, or `packets`; optional `companion_name`; local diagnostics, not remote RF telemetry | +| `pymc_repeater.get_lbt_diagnostics` | `hours` 1–168; optional `bucket_seconds` 60–3600 and `severe_attempt_threshold` 2–16; no `radio_id` filter | + +All default to 24 hours where applicable. Omitted bucket sizes retain backend +defaults. Supply `config_entry_id` when multiple Repeaters are configured. For example: + +```yaml +action: pymc_repeater.get_radio_packet_rates +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID + hours: 6 + bucket_seconds: 300 +response_variable: radio_rates +``` + +New endpoints require a supporting Repeater build. Unsupported endpoints return +an action error rather than invented data. Noise statistics retain the established +unwrapped `stats` dictionary. Other dictionaries are returned without reshaping; +non-dictionary results use `result`. Rate buckets can include unattributed counts; +physical TX counts can exceed parent packet totals because of fan-out. +Advert sends now allow a 15-second HTTP budget around the backend's 10-second wait. + +### Plugin lifecycle, sensor configuration and ACL permissions + +[Explicit management actions](docs/management-actions.md) cover catalogue list/install, +plugin enable/disable/start/stop/restart/uninstall, effective settings read and full +settings replacement; sensor type/configuration read and complete configuration +replacement; and ACL role assignment plus named-identity client removal. + +Plugin and sensor actions require response variables, stay out of coordinator +polling, and do not trigger immediate refreshes. Plugin writes share fail-fast +admission with upgrades; uncertain completion requires Repeater reconciliation +before entry reload, never blind retries. Uninstall preserves data unless +`delete_data: true` is explicit. Configuration reads redact common credential keys +by default; `include_sensitive: true` is opt-in and can expose credentials to HA +traces. Writes require actual complete JSON objects, not partial patches or encoded +strings. Sensor `*****` password masks and `_original_name` rename hints are +preserved; saving requires a separate Repeater restart to apply. + +`set_acl_permissions` accepts a full client public key, exact identity name and +permissions byte 1–255 whose low two bits are 1 read-only, 2 read-write or 3 admin. +Room servers persist only admins; inspect the optional `persisted` response. +`remove_acl_client` adds `identity_name` while preserving `public_key` and hash-only +legacy calls. These ACL writes refresh telemetry once after success. + +Manual wheel upload remains in the Repeater UI: backend multipart `wheel` upload +is not a HA filesystem path, and no confined bounded HA file-upload contract is +provided. Plugin logs/progress/runtime documents and forced sensor hardware reads +are intentionally not added. See the management guide for budgets, bounds, +replacement semantics, side effects, examples and verification limitations. + ### 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: +The existing `pymc_repeater.get_neighbor_link_history` response-returning action accepts optional `bucket_seconds` (integer, 60–86400). 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. Optional `radio_id` filters by exact backend ID; `by_radio: true` splits buckets by receiving radio and requires `bucket_seconds`. Omit these new fields for older builds; historical result IDs are backend IDs, not HA aliases. This action is never polled automatically. For example: ```yaml action: pymc_repeater.get_neighbor_link_history @@ -277,10 +372,16 @@ 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 +Parent `radio_status` and `radio_problem` describe aggregate runtime health, +not API connectivity or the health of each configured radio. Raw radio exceptions +are not exposed in those entities. Child configuration and explicitly attributed +telemetry remain separate; aggregate RF counters are never assigned to children. + +Freshness prefers each reading's `poll_interval_seconds`. The audited Repeater dev +still omits effective per-reading cadence, so the legacy global-summary fallback +cannot establish every plugin's real interval. HA 1.3.0 does not fix that backend +limitation. See [1.3.0 API and dependency notes](docs/dev-api-1.3.0.md) for exact +source provenance, supported contracts and deliberately excluded administration. Aggregate flood/direct received, transmitted, and duplicate packet counters are exposed on the parent Repeater device using existing stats polling. These are diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index 161edd4..f4c9b3a 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -38,6 +38,9 @@ async def async_remove_config_entry_device( Platform.NUMBER, ] +SERVICE_CHECK_PLUGIN_UPDATES = "check_plugin_updates" +SERVICE_UPDATE_PLUGIN = "update_plugin" +SERVICE_UPDATE_ALL_PLUGINS = "update_all_plugins" SERVICE_PING_NEIGHBOR = "ping_neighbor" SERVICE_SEND_ADVERT = "send_advert" SERVICE_PUBLISH_NEIGHBORS = "publish_neighbors" @@ -68,6 +71,11 @@ async def async_remove_config_entry_device( 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_RADIO_PACKET_RATES = "get_radio_packet_rates" +SERVICE_GET_NOISE_FLOOR_STATS = "get_noise_floor_stats" +SERVICE_GET_CRC_ERROR_COUNT = "get_crc_error_count" +SERVICE_GET_COMPANION_STATS = "get_companion_stats" +SERVICE_GET_LBT_DIAGNOSTICS = "get_lbt_diagnostics" 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" @@ -163,6 +171,23 @@ async def _async_register_services(hass: HomeAssistant) -> None: if hass.services.has_service(DOMAIN, SERVICE_PING_NEIGHBOR): return + def _bounded_query_integer(minimum: int, maximum: int) -> Callable: + """Use the client's strict finite bounds in action schemas too.""" + def validate(value: Any) -> int: + try: + return PyMCRepeaterApiClient.validate_query_integer(value, minimum, maximum) + except PyMCRepeaterError as err: + raise vol.Invalid(str(err)) from err + return validate + + + def _radio_id(value: Any) -> str: + """Validate exact radio identity without coercion.""" + try: + return PyMCRepeaterApiClient.validate_radio_id(value) + except PyMCRepeaterError as err: + raise vol.Invalid(str(err)) from err + async def _with_api( call: ServiceCall, func: Callable[[PyMCRepeaterApiClient, str], Awaitable[Any]], @@ -199,17 +224,169 @@ async def _with_api_response( return result return {"result": result} + def _async_service_handler( + handler: Callable[[ServiceCall], Awaitable[Any]], + ) -> Callable[[ServiceCall], Awaitable[Any]]: + """Expose native async handlers so HA awaits work on its event loop.""" + async def async_handle(call: ServiceCall) -> Any: + return await handler(call) + + return async_handle + + def _plugin_text(value: Any) -> str: + try: + return PyMCRepeaterApiClient.validate_plugin_text(value) + except PyMCRepeaterError as err: + raise vol.Invalid(str(err)) from err + + def _management_object(value: Any) -> dict[str, Any]: + try: + return PyMCRepeaterApiClient.validate_json_object(value) + except PyMCRepeaterError as err: + raise vol.Invalid(str(err)) from err + + def _management_validator(method: str) -> Callable: + def validate(value: Any) -> Any: + try: + return getattr(PyMCRepeaterApiClient, method)(value) + except PyMCRepeaterError as err: + raise vol.Invalid(str(err)) from err + return validate + + hass.services.async_register( + DOMAIN, "set_acl_permissions", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_set_acl_permissions( + identity_name=call.data["identity_name"], client_pubkey=call.data["client_pubkey"], + permissions=call.data["permissions"]), refresh=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, + vol.Required("identity_name"): _management_validator("validate_management_name"), + vol.Required("client_pubkey"): _management_validator("validate_acl_public_key"), + vol.Required("permissions"): _management_validator("validate_acl_permissions")}), + supports_response=SupportsResponse.OPTIONAL, + ) + + def _sensor_configuration(value: Any) -> dict[str, Any]: + try: + return PyMCRepeaterApiClient.validate_sensor_configuration(value) + except PyMCRepeaterError as err: + raise vol.Invalid(str(err)) from err + + hass.services.async_register( + DOMAIN, "get_sensor_types", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_get_sensor_types(), always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str}), supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, "get_sensor_configuration", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_get_sensor_configuration( + include_sensitive=call.data.get("include_sensitive", False)), always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("include_sensitive", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, "update_sensor_configuration", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_update_sensor_configuration( + config=call.data["config"]), always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, vol.Required("config"): _sensor_configuration}), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, "get_plugin_settings", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_get_plugin_settings( + plugin_id=call.data["plugin_id"], include_sensitive=call.data.get("include_sensitive", False)), + always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, vol.Required("plugin_id"): _plugin_text, + vol.Optional("include_sensitive", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, "update_plugin_settings", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_update_plugin_settings( + plugin_id=call.data["plugin_id"], config=call.data["config"], restart=call.data.get("restart", False)), + always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, vol.Required("plugin_id"): _plugin_text, + vol.Required("config"): _management_object, + vol.Optional("restart", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, "get_plugin_catalogue", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_get_plugin_catalogue( + force_refresh=call.data.get("force_refresh", False)), always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("force_refresh", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, "install_catalogue_plugin", + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_install_catalogue_plugin( + plugin_id=call.data["plugin_id"], version=call.data.get("version"), + force_refresh=call.data.get("force_refresh", False)), always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, + vol.Required("plugin_id"): _plugin_text, + vol.Optional("version"): _plugin_text, + vol.Optional("force_refresh", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + + for operation in ("enable", "disable", "start", "stop", "restart", "uninstall"): + fields = {vol.Optional(CONF_ENTRY_ID): str, vol.Required("plugin_id"): _plugin_text} + if operation == "uninstall": + fields[vol.Optional("delete_data", default=False)] = bool + hass.services.async_register( + DOMAIN, operation + "_plugin", + _async_service_handler(lambda call, operation=operation: _with_api_response( + call, lambda api, _: api.async_plugin_lifecycle( + plugin_id=call.data["plugin_id"], operation=operation, + delete_data=call.data.get("delete_data", False)), always_return=True)), + schema=vol.Schema(fields), supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, SERVICE_CHECK_PLUGIN_UPDATES, + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_check_plugin_updates( + plugin_id=call.data.get("plugin_id"), force_refresh=call.data.get("force_refresh", False)), + always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("plugin_id"): _plugin_text, + vol.Optional("force_refresh", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, SERVICE_UPDATE_PLUGIN, + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_update_plugin( + plugin_id=call.data["plugin_id"], version=call.data.get("version"), + force_refresh=call.data.get("force_refresh", False)), always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, + vol.Required("plugin_id"): _plugin_text, + vol.Optional("version"): _plugin_text, + vol.Optional("force_refresh", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, SERVICE_UPDATE_ALL_PLUGINS, + _async_service_handler(lambda call: _with_api_response(call, lambda api, _: api.async_update_all_plugins( + force_refresh=call.data.get("force_refresh", False)), always_return=True)), + schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("force_refresh", default=False): bool}), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_PING_NEIGHBOR, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_ping_neighbor( target_id=call.data["target_id"], timeout=call.data.get("timeout", 10), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -224,11 +401,11 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_SEND_ADVERT, - lambda call: _with_api( + _async_service_handler(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, @@ -240,22 +417,22 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_PUBLISH_NEIGHBORS, - lambda call: _with_api( + _async_service_handler(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( + _async_service_handler(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, ) @@ -263,11 +440,11 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_QUERY_NEIGHBOR_SCOPES, - lambda call: _with_api_response( + _async_service_handler(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, @@ -280,7 +457,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_ROOM_POST_MESSAGE, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_room_post_message( room_name=call.data.get("room_name"), @@ -290,7 +467,7 @@ async def _with_api_response( txt_type=call.data.get("txt_type", 0), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -306,14 +483,14 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_ROOM_MESSAGES_CLEAR, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_room_messages_clear( room_name=call.data.get("room_name"), room_hash=call.data.get("room_hash"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -326,7 +503,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_CAD_CALIBRATION_START, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_cad_calibration_start( samples=call.data.get("samples", 8), @@ -336,7 +513,7 @@ async def _with_api_response( cad_timeout_ms=call.data.get("cad_timeout_ms", 500), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -360,16 +537,16 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_CAD_CALIBRATION_STOP, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_cad_calibration_stop(), refresh=False - ), + )), schema=vol.Schema({vol.Optional(CONF_ENTRY_ID): str}), ) hass.services.async_register( DOMAIN, SERVICE_CAD_MANUAL_CHECK, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_cad_manual_check( samples=call.data.get("samples", 1), @@ -380,7 +557,7 @@ async def _with_api_response( apply_live=call.data.get("apply_live", False), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -408,7 +585,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_SAVE_CAD_SETTINGS, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_save_cad_settings( peak=call.data["peak"], @@ -416,7 +593,7 @@ async def _with_api_response( cad_symbol_num=call.data.get("cad_symbol_num", 2), detection_rate=call.data.get("detection_rate", 0), ), - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -435,9 +612,9 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_DB_PURGE, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_db_purge(call.data["tables"]) - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -449,9 +626,9 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_UPDATE_RADIO_CONFIG, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_update_radio_config(call.data["payload"]) - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -463,9 +640,9 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_UPDATE_MQTT_CONFIG, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_update_mqtt_config(call.data["payload"]) - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -477,7 +654,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_SEND_TEXT, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_companion_send_text( pub_key=call.data["pub_key"], @@ -486,7 +663,7 @@ async def _with_api_response( companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -501,7 +678,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_SEND_CHANNEL_MESSAGE, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_companion_send_channel_message( channel_idx=call.data["channel_idx"], @@ -509,7 +686,7 @@ async def _with_api_response( companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -523,7 +700,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_LOGIN, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_companion_login( pub_key=call.data["pub_key"], @@ -531,7 +708,7 @@ async def _with_api_response( companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -545,7 +722,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_REQUEST_STATUS, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_companion_request_status( pub_key=call.data["pub_key"], @@ -553,7 +730,7 @@ async def _with_api_response( companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -570,7 +747,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_REQUEST_TELEMETRY, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_companion_request_telemetry( pub_key=call.data["pub_key"], @@ -581,7 +758,7 @@ async def _with_api_response( want_environment=call.data.get("want_environment", True), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -601,7 +778,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_SEND_COMMAND, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_companion_send_command( pub_key=call.data["pub_key"], @@ -610,7 +787,7 @@ async def _with_api_response( companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -625,14 +802,14 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_RESET_PATH, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_companion_reset_path( pub_key=call.data["pub_key"], companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -645,14 +822,14 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_SET_ADVERT_NAME, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_companion_set_advert_name( advert_name=call.data["advert_name"], companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -665,7 +842,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_COMPANION_SET_ADVERT_LOCATION, - lambda call: _with_api( + _async_service_handler(lambda call: _with_api( call, lambda api, _: api.async_companion_set_advert_location( latitude=call.data["latitude"], @@ -673,7 +850,7 @@ async def _with_api_response( companion_name=call.data.get("companion_name"), ), refresh=False, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -687,11 +864,11 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_BROKER_PRESETS, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_broker_presets(), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -703,11 +880,11 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_LOGS, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_logs(), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -719,13 +896,13 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_RECENT_PACKETS, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_recent_packets( limit=call.data.get("limit", 100), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -738,7 +915,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_FILTERED_PACKETS, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_filtered_packets( packet_type=call.data.get("packet_type"), @@ -748,7 +925,7 @@ async def _with_api_response( limit=call.data.get("limit", 1000), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -765,11 +942,11 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_PACKET_BY_HASH, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_packet_by_hash(call.data["packet_hash"]), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -782,14 +959,14 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_NEIGHBOR_LINKS, - lambda call: _with_api_response( + _async_service_handler(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, @@ -807,7 +984,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_NEIGHBOR_LINK_HISTORY, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_neighbor_link_history( peer_hash=call.data["peer_hash"], @@ -815,25 +992,21 @@ async def _with_api_response( hours=call.data.get("hours", 24), limit=call.data.get("limit", 1000), bucket_seconds=call.data.get("bucket_seconds"), + radio_id=call.data.get("radio_id"), + by_radio=call.data.get("by_radio"), ), always_return=True, - ), + )), schema=vol.Schema( { 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) - ), - 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) - ), + vol.Optional("bucket_seconds"): _bounded_query_integer(60, 86400), + vol.Optional("radio_id"): _radio_id, + vol.Optional("by_radio"): bool, + vol.Required("path_hash_size"): _bounded_query_integer(1, 3), + vol.Optional("hours", default=24): _bounded_query_integer(1, 168), + vol.Optional("limit", default=1000): _bounded_query_integer(1, 5000), } ), supports_response=SupportsResponse.ONLY, @@ -842,7 +1015,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_ADVERTS_BY_CONTACT_TYPE, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_adverts_by_contact_type( contact_type=call.data["contact_type"], @@ -851,7 +1024,7 @@ async def _with_api_response( hours=call.data.get("hours"), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -867,14 +1040,14 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_ADVERTS_COUNT_BY_CONTACT_TYPE, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_adverts_count_by_contact_type( contact_type=call.data["contact_type"], hours=call.data.get("hours"), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -888,14 +1061,14 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_ACL_CLIENTS, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_acl_clients( identity_hash=call.data.get("identity_hash"), identity_name=call.data.get("identity_name"), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -909,19 +1082,21 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_REMOVE_ACL_CLIENT, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_remove_acl_client( public_key=call.data["public_key"], identity_hash=call.data.get("identity_hash"), + identity_name=call.data.get("identity_name"), ), refresh=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, vol.Required("public_key"): str, vol.Optional("identity_hash"): str, + vol.Optional("identity_name"): _management_validator("validate_management_name"), } ), supports_response=SupportsResponse.OPTIONAL, @@ -930,7 +1105,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_ROOM_MESSAGES, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_room_messages( room_name=call.data.get("room_name"), @@ -940,7 +1115,7 @@ async def _with_api_response( since_timestamp=call.data.get("since_timestamp"), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -957,14 +1132,14 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_GET_ROOM_CLIENTS, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_get_room_clients( room_name=call.data.get("room_name"), room_hash=call.data.get("room_hash"), ), always_return=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -978,7 +1153,7 @@ async def _with_api_response( hass.services.async_register( DOMAIN, SERVICE_DELETE_ROOM_MESSAGE, - lambda call: _with_api_response( + _async_service_handler(lambda call: _with_api_response( call, lambda api, _: api.async_delete_room_message( message_id=call.data["message_id"], @@ -986,7 +1161,7 @@ async def _with_api_response( room_hash=call.data.get("room_hash"), ), refresh=True, - ), + )), schema=vol.Schema( { vol.Optional(CONF_ENTRY_ID): str, @@ -997,3 +1172,110 @@ async def _with_api_response( ), supports_response=SupportsResponse.OPTIONAL, ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_RADIO_PACKET_RATES, + _async_service_handler(lambda call: _with_api_response( + call, + lambda api, _: api.async_get_radio_packet_rates( + hours=call.data.get("hours", 24), + bucket_seconds=call.data.get("bucket_seconds"), + ), + always_return=True, + )), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("hours", default=24): _bounded_query_integer(1, 168), + vol.Optional("bucket_seconds"): _bounded_query_integer(60, 86400), + } + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_NOISE_FLOOR_STATS, + _async_service_handler(lambda call: _with_api_response( + call, + lambda api, _: api.async_get_noise_floor_stats( + hours=call.data.get("hours", 24), + radio_id=call.data.get("radio_id"), + ), + always_return=True, + )), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("hours", default=24): _bounded_query_integer(1, 168), + vol.Optional("radio_id"): _radio_id, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_CRC_ERROR_COUNT, + _async_service_handler(lambda call: _with_api_response( + call, + lambda api, _: api.async_get_crc_error_count( + hours=call.data.get("hours", 24), + radio_id=call.data.get("radio_id"), + ), + always_return=True, + )), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("hours", default=24): _bounded_query_integer(1, 168), + vol.Optional("radio_id"): _radio_id, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_COMPANION_STATS, + _async_service_handler(lambda call: _with_api_response( + call, + lambda api, _: api.async_get_companion_stats( + type=call.data.get("type", "packets"), + companion_name=call.data.get("companion_name"), + ), + always_return=True, + )), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("type", default="packets"): vol.In(["core", "radio", "packets"]), + vol.Optional("companion_name"): vol.All(str, vol.Length(min=1)), + } + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_LBT_DIAGNOSTICS, + _async_service_handler(lambda call: _with_api_response( + call, + lambda api, _: api.async_get_lbt_diagnostics( + hours=call.data.get("hours", 24), + bucket_seconds=call.data.get("bucket_seconds"), + severe_attempt_threshold=call.data.get("severe_attempt_threshold"), + ), + always_return=True, + )), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Optional("hours", default=24): _bounded_query_integer(1, 168), + vol.Optional("bucket_seconds"): _bounded_query_integer(60, 3600), + vol.Optional("severe_attempt_threshold"): _bounded_query_integer(2, 16), + } + ), + supports_response=SupportsResponse.ONLY, + ) diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index ce46aee..5d6d3cd 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -9,12 +9,14 @@ from typing import Any from urllib.parse import urlparse -from aiohttp import ClientError, ClientResponse, ClientSession +from aiohttp import ClientError, ClientResponse, ClientSession, ClientTimeout from yarl import URL from .const import CLIENT_ID_PREFIX, DEFAULT_PACKET_WINDOW_HOURS REQUEST_TIMEOUT = 10 +PLUGIN_REQUEST_TIMEOUT = 930 # IPC completion 900 seconds plus HTTP margin +PLUGIN_MAX_BATCH = 100 NEIGHBOR_SCOPE_QUERY_TIMEOUT = 50 SENSITIVE_RESPONSE_KEYS = { "identity_key", @@ -121,6 +123,8 @@ def __init__( self.host = normalize_host(host) self.port = int(port) self.api_token = api_token + self._plugin_upgrade_active = False + self._plugin_upgrade_uncertain = False @property def base_url(self) -> str: @@ -190,6 +194,476 @@ async def async_fetch_all(self) -> dict[str, Any]: return payload + @staticmethod + def validate_plugin_text(value: Any, maximum: int = 128) -> str: + """Bound exact plugin IDs/version tokens without trimming or coercion.""" + if (not isinstance(value, str) or not 1 <= len(value) <= maximum + or not value.isascii() or not value[0].isalnum() + or any(not (char.isalnum() or char in "._+-") for char in value)): + raise PyMCRepeaterApiError("Invalid plugin ID or version token") + return value + + async def _async_plugin_payload( + self, method: str, path: str, **kwargs: Any + ) -> dict[str, Any]: + """Use finite IPC-compatible budgets; never expose backend error text.""" + try: + payload = await self._async_request_json( + method, path, auth="api_token", timeout_seconds=PLUGIN_REQUEST_TIMEOUT, + plugin_request=True, **kwargs, + ) + except PyMCRepeaterAuthenticationError: + return {"success": False, "reason": "authentication_failed"} + except PyMCRepeaterCannotConnect: + return {"success": False, "outcome": "unknown", "reason": "connection_lost"} + except (PyMCRepeaterApiError, TimeoutError, ValueError): + return {"success": False, "outcome": "unknown", "reason": "invalid_or_lost_response"} + if not isinstance(payload, dict): + return {"success": False, "outcome": "unknown", "reason": "invalid_response"} + if payload.get("success") is not False and "data" in payload: + payload = payload["data"] + if not isinstance(payload, dict): + return {"success": False, "outcome": "unknown", "reason": "invalid_response"} + if payload.get("success") is False: + status = payload.get("http_status") + uncertain = payload.get("outcome") == "unknown" or status in (409, 502, 504) + return {"success": False, "outcome": "unknown" if uncertain else "failure", + "reason": "operation_in_progress" if status == 409 else "backend_error"} + return payload + + async def _async_plugin_inventory(self) -> list[dict[str, Any]]: + """Keep only installed identity/version/provenance for action planning.""" + data = await self._async_plugin_payload("GET", "/api/plugins/") + rows = data.get("plugins") + if data.get("success") is False or not isinstance(rows, list) or len(rows) > PLUGIN_MAX_BATCH: + raise PyMCRepeaterApiError("Plugin inventory unavailable or exceeds 100 plugins") + result, seen = [], set() + for row in rows: + if not isinstance(row, dict): + raise PyMCRepeaterApiError("Invalid plugin inventory") + plugin_id = self.validate_plugin_text(row.get("id")) + if plugin_id in seen: + raise PyMCRepeaterApiError("Duplicate plugin inventory identity") + seen.add(plugin_id) + try: + version = self.validate_plugin_text(row.get("version")) + except PyMCRepeaterApiError: + version = None + repository = row.get("repository") + result.append({"id": plugin_id, "version": version, + "eligible": row.get("source") == "catalogue" + and isinstance(repository, str) and bool(repository.strip())}) + return result + + async def _async_plugin_check(self, row: dict[str, Any], force_refresh: bool) -> dict[str, Any]: + result = {"id": row["id"], "installed_version": row["version"]} + if not row["eligible"]: + return {**result, "outcome": "skipped", "reason": "not_catalogue_or_repository_unavailable"} + data = await self._async_plugin_payload( + "GET", "/api/plugins/updates", params={"id": row["id"], "refresh": str(force_refresh).lower()}, + ) + if data.get("success") is False: + return {**result, "outcome": data.get("outcome", "failure"), "reason": data["reason"]} + latest = data.get("latestVersion") + if not isinstance(data.get("updateAvailable"), bool) or data.get("id") != row["id"]: + return {**result, "outcome": "unknown", "reason": "invalid_check_response"} + if latest is not None: + try: + latest = self.validate_plugin_text(latest) + except PyMCRepeaterApiError: + return {**result, "outcome": "unknown", "reason": "invalid_version_response"} + if data["updateAvailable"] and latest is None: + return {**result, "outcome": "unknown", "reason": "missing_target_version"} + return {**result, "outcome": "success", "latest_version": latest, + "update_available": data["updateAvailable"]} + + @staticmethod + def _plugin_summary(results: list[dict[str, Any]], *, stopped: bool = False) -> dict[str, Any]: + return {"results": results, "counts": {outcome: sum(row["outcome"] == outcome for row in results) + for outcome in ("success", "skipped", "failure", "unknown")}, "stopped": stopped} + + async def async_check_plugin_updates( + self, *, plugin_id: str | None = None, force_refresh: bool = False + ) -> dict[str, Any]: + """Check installed catalogue plugins on demand, without changing polling.""" + if plugin_id is not None: + plugin_id = self.validate_plugin_text(plugin_id) + if not isinstance(force_refresh, bool): + raise PyMCRepeaterApiError("force_refresh must be boolean") + rows = await self._async_plugin_inventory() + if plugin_id is not None: + rows = [row for row in rows if row["id"] == plugin_id] + if not rows: + return self._plugin_summary([{"id": plugin_id, "outcome": "skipped", "reason": "not_installed"}]) + return self._plugin_summary([await self._async_plugin_check(row, force_refresh) for row in rows]) + + async def _async_plugin_upgrade( + self, row: dict[str, Any], *, version: str | None, force_refresh: bool + ) -> dict[str, Any]: + checked = await self._async_plugin_check(row, force_refresh) + if checked["outcome"] != "success": + return checked + if version is None and not checked["update_available"]: + return {**checked, "outcome": "skipped", "reason": "no_update"} + target = version or checked["latest_version"] + data = await self._async_plugin_payload( + "POST", "/api/plugins/update", + json_body={"id": row["id"], "version": target, "force_refresh": force_refresh}, + ) + if data.get("success") is False: + return {**checked, "outcome": data.get("outcome", "failure"), "reason": data["reason"]} + plugin = data.get("plugin") + # Enabled plugins return enable()/status(), which has no updated flag. + # Verify returned identity and exact installed target, not the HTTP envelope. + if not isinstance(plugin, dict) or plugin.get("id") != row["id"]: + return {**checked, "outcome": "unknown", "reason": "invalid_update_response"} + if plugin.get("updated") is False: + return {**checked, "outcome": "skipped", "reason": "no_update"} + actual = plugin.get("version") + if not isinstance(actual, str) or actual.removeprefix("v") != target.removeprefix("v"): + return {**checked, "outcome": "unknown", "reason": "target_version_unconfirmed"} + return {**checked, "outcome": "success", "version": actual, "updated": True} + + async def _async_plugin_upgrade_batch( + self, *, plugin_id: str | None = None, version: str | None = None, + force_refresh: bool = False, + ) -> dict[str, Any]: + """Fail-fast admission per client; never enqueue upgrades or retry writes.""" + if plugin_id is not None: + plugin_id = self.validate_plugin_text(plugin_id) + if version is not None: + version = self.validate_plugin_text(version) + if not isinstance(force_refresh, bool): + raise PyMCRepeaterApiError("force_refresh must be boolean") + if getattr(self, "_plugin_upgrade_active", False): + raise PyMCRepeaterApiError("Plugin upgrade already in progress") + if getattr(self, "_plugin_upgrade_uncertain", False): + raise PyMCRepeaterApiError("Previous plugin upgrade outcome uncertain; reconcile on Repeater before reloading entry") + self._plugin_upgrade_active = True + write_possible = False + try: + rows = await self._async_plugin_inventory() + if plugin_id is not None: + rows = [row for row in rows if row["id"] == plugin_id] + if not rows: + return self._plugin_summary([{"id": plugin_id, "outcome": "skipped", "reason": "not_installed"}]) + results, stopped = [], False + for row in rows: + if stopped: + results.append({"id": row["id"], "outcome": "skipped", "reason": "batch_stopped"}) + continue + write_possible = True + result = await self._async_plugin_upgrade(row, version=version, force_refresh=force_refresh) + write_possible = False + results.append(result) + if result["outcome"] == "unknown": + stopped = True + self._plugin_upgrade_uncertain = True + return self._plugin_summary(results, stopped=stopped) + except asyncio.CancelledError: + # Cancellation cannot cancel an accepted remote IPC install. + if write_possible: + self._plugin_upgrade_uncertain = True + raise + finally: + self._plugin_upgrade_active = False + + @staticmethod + def validate_sensor_configuration(value: Any) -> dict[str, Any]: + """Require complete replacement input, not a defaults-resetting patch.""" + import math + value = PyMCRepeaterApiClient.validate_json_object(value) + required = {"enabled", "poll_interval_seconds", "auto_install_packages", "definitions"} + if set(value) != required: + raise PyMCRepeaterApiError("Sensor configuration requires all four replacement fields") + for key in ("enabled", "auto_install_packages"): + if not isinstance(value[key], bool): + raise PyMCRepeaterApiError(key + " must be boolean") + interval = value["poll_interval_seconds"] + if isinstance(interval, bool) or not isinstance(interval, (int, float)) or not math.isfinite(interval) or interval <= 0: + raise PyMCRepeaterApiError("poll_interval_seconds must be finite and positive") + definitions = value["definitions"] + if not isinstance(definitions, list) or len(definitions) > 100: + raise PyMCRepeaterApiError("definitions must be an array of at most 100 sensors") + names, origins = set(), set() + for definition in definitions: + if not isinstance(definition, dict): + raise PyMCRepeaterApiError("Sensor definitions must be objects") + name = PyMCRepeaterApiClient.validate_management_name(definition.get("name")) + PyMCRepeaterApiClient.validate_management_name(definition.get("type")) + if name in names: + raise PyMCRepeaterApiError("Duplicate sensor name") + names.add(name) + if "settings" in definition and not isinstance(definition["settings"], dict): + raise PyMCRepeaterApiError("Sensor settings must be a JSON object") + for key in ("enabled", "auto_install_packages"): + if key in definition and not isinstance(definition[key], bool): + raise PyMCRepeaterApiError(key + " must be boolean") + if "_original_name" in definition: + origin = (definition["type"], PyMCRepeaterApiClient.validate_management_name(definition["_original_name"])) + if origin in origins: + raise PyMCRepeaterApiError("Duplicate sensor origin") + origins.add(origin) + return value + + @staticmethod + def validate_management_name(value: Any) -> str: + """Bound an exact human-readable identity without changing it.""" + if not isinstance(value, str) or not value.strip() or len(value) > 256 or any(ord(char) < 32 for char in value): + raise PyMCRepeaterApiError("Name must be a nonempty string of at most 256 characters without controls") + return value + + async def _async_management_payload(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + """Use finite budgets and fixed errors for sensitive management calls.""" + try: + data = await self._async_request_json(method, path, auth="api_token", timeout_seconds=30, + plugin_request=True, max_response_bytes=1024 * 1024, **kwargs) + except PyMCRepeaterError as err: + raise PyMCRepeaterApiError("Management request failed; reconcile before retrying writes") from err + if not isinstance(data, dict) or data.get("success") is False: + raise PyMCRepeaterApiError("Management request failed; reconcile before retrying writes") + data = data.get("data", data) + if not isinstance(data, dict) or data.get("success") is False: + raise PyMCRepeaterApiError("Invalid management response") + return self.validate_json_object(data, allow_redacted=True) + + async def async_get_sensor_types(self) -> dict[str, Any]: + """Read available types/settings schemas without touching hardware.""" + data = await self._async_management_payload("GET", "/api/sensors_types") + types = data.get("types") + if not isinstance(types, list) or len(types) > 100 or any(not isinstance(row, dict) for row in types): + raise PyMCRepeaterApiError("Invalid sensor types response") + return {"types": self._management_config_response(types, include_sensitive=False)} + + async def async_get_sensor_configuration(self, *, include_sensitive: bool = False) -> dict[str, Any]: + """Read configuration on demand, retaining password masks and rename origins.""" + if not isinstance(include_sensitive, bool): + raise PyMCRepeaterApiError("include_sensitive must be boolean") + data = await self._async_management_payload("GET", "/api/sensors_config") + # Validate before redaction so a redacted response is never used as a write. + config = self.validate_sensor_configuration(data) + return self._management_config_response(config, include_sensitive=include_sensitive, sensor_masks=True) + + async def async_update_sensor_configuration(self, *, config: dict[str, Any]) -> dict[str, Any]: + """Save a complete replacement; application requires an operator restart.""" + config = self.validate_sensor_configuration(config) + data = await self._async_management_payload("POST", "/api/sensors_config_update", json_body=config) + if data.get("saved") is not True or not isinstance(data.get("restart_required"), bool): + raise PyMCRepeaterApiError("Sensor save unconfirmed; reconcile before retrying") + return {"saved": True, "restart_required": data["restart_required"]} + + @staticmethod + def validate_json_object(value: Any, *, allow_redacted: bool = False) -> dict[str, Any]: + """Require real finite JSON, bounded depth/nodes and 256 KiB encoding.""" + import json + import math + if not isinstance(value, dict): + raise PyMCRepeaterApiError("Configuration must be a JSON object") + nodes = 0 + def visit(item: Any, depth: int) -> None: + nonlocal nodes + nodes += 1 + if depth > 32 or nodes > 10000: + raise PyMCRepeaterApiError("Configuration exceeds depth or item limit") + if isinstance(item, dict): + if any(not isinstance(key, str) for key in item): + raise PyMCRepeaterApiError("JSON object keys must be strings") + for child in item.values(): + visit(child, depth + 1) + elif isinstance(item, list): + for child in item: + visit(child, depth + 1) + elif isinstance(item, str): + if not allow_redacted and item == "[REDACTED]": + raise PyMCRepeaterApiError("Replace redacted values or explicitly request sensitive configuration before writing") + elif item is None or isinstance(item, bool) or isinstance(item, int): + pass + elif isinstance(item, float) and math.isfinite(item): + pass + else: + raise PyMCRepeaterApiError("Configuration must contain only finite JSON values") + visit(value, 0) + try: + encoded = json.dumps(value, allow_nan=False).encode("utf-8") + except (TypeError, ValueError, OverflowError, RecursionError) as err: + raise PyMCRepeaterApiError("Invalid JSON configuration") from err + if len(encoded) > 256 * 1024: + raise PyMCRepeaterApiError("Configuration exceeds 256 KiB") + return value + + @staticmethod + def _management_config_response(value: Any, *, include_sensitive: bool, sensor_masks: bool = False) -> Any: + """Redact common credential keys, preserving backend sensor password masks.""" + if isinstance(value, dict): + result = {} + for key, item in value.items(): + normalized = key.lower().replace("-", "_") + secret = any(part in normalized for part in ("password", "secret", "token", "private_key", "api_key", "transport_key")) + if secret and not include_sensitive and not (sensor_masks and normalized == "password" and item == "*****"): + result[key] = "[REDACTED]" if item else item + else: + result[key] = PyMCRepeaterApiClient._management_config_response( + item, include_sensitive=include_sensitive, sensor_masks=sensor_masks) + return result + if isinstance(value, list): + return [PyMCRepeaterApiClient._management_config_response( + item, include_sensitive=include_sensitive, sensor_masks=sensor_masks) for item in value] + return value + + async def async_get_plugin_settings( + self, *, plugin_id: str, include_sensitive: bool = False, + ) -> dict[str, Any]: + """Read explicit settings only; full sensitive configuration is opt-in.""" + plugin_id = self.validate_plugin_text(plugin_id) + if not isinstance(include_sensitive, bool): + raise PyMCRepeaterApiError("include_sensitive must be boolean") + data = await self._async_plugin_payload("GET", "/api/plugins/settings", params={"id": plugin_id}, max_response_bytes=1024 * 1024) + if data.get("success") is False or data.get("id") != plugin_id: + raise PyMCRepeaterApiError("Plugin settings unavailable") + config = self.validate_json_object(data.get("config"), allow_redacted=True) + return {"id": plugin_id, "config": self._management_config_response( + config, include_sensitive=include_sensitive)} + + async def async_update_plugin_settings( + self, *, plugin_id: str, config: dict[str, Any], restart: bool = False, + ) -> dict[str, Any]: + """Replace plugin config, optionally restarting; return only metadata.""" + plugin_id = self.validate_plugin_text(plugin_id) + config = self.validate_json_object(config) + if not isinstance(restart, bool): + raise PyMCRepeaterApiError("restart must be boolean") + return await self._async_plugin_write("settings", plugin_id, + {"id": plugin_id, "config": config, "restart": restart}) + + async def async_get_plugin_catalogue(self, *, force_refresh: bool = False) -> dict[str, Any]: + """List bounded catalogue metadata, without paths, URLs or backend errors.""" + if not isinstance(force_refresh, bool): + raise PyMCRepeaterApiError("force_refresh must be boolean") + data = await self._async_plugin_payload( + "GET", "/api/plugins/catalogue", params={"refresh": str(force_refresh).lower()}, max_response_bytes=1024 * 1024, + ) + rows = data.get("plugins") + if data.get("success") is False or not isinstance(rows, list) or len(rows) > PLUGIN_MAX_BATCH: + raise PyMCRepeaterApiError("Plugin catalogue unavailable or exceeds 100 entries") + result, seen = [], set() + for row in rows: + if not isinstance(row, dict): + raise PyMCRepeaterApiError("Invalid catalogue response") + plugin_id = self.validate_plugin_text(row.get("id")) + if plugin_id in seen: + raise PyMCRepeaterApiError("Duplicate catalogue identity") + seen.add(plugin_id) + item = {"id": plugin_id} + for key in ("name", "description"): + if isinstance(row.get(key), str): + item[key] = row[key][:1024] + for key in ("installed", "updateAvailable"): + if isinstance(row.get(key), bool): + item[key] = row[key] + for key in ("version", "installedVersion", "latestVersion"): + if row.get(key) is not None: + item[key] = self.validate_plugin_text(row[key]) + result.append(item) + return {"plugins": result} + + async def async_install_catalogue_plugin( + self, *, plugin_id: str, version: str | None = None, force_refresh: bool = False, + ) -> dict[str, Any]: + """Install an approved catalogue wheel using the shared write guard.""" + plugin_id = self.validate_plugin_text(plugin_id) + if not isinstance(force_refresh, bool): + raise PyMCRepeaterApiError("force_refresh must be boolean") + body: dict[str, Any] = {"id": plugin_id, "force_refresh": force_refresh} + if version is not None: + body["version"] = self.validate_plugin_text(version) + return await self._async_plugin_write("catalogue_install", plugin_id, body) + + async def async_plugin_lifecycle( + self, *, plugin_id: str, operation: str, delete_data: bool = False, + ) -> dict[str, Any]: + """Perform an explicit lifecycle write with shared fail-fast admission.""" + plugin_id = self.validate_plugin_text(plugin_id) + if operation not in {"enable", "disable", "start", "stop", "restart", "uninstall"}: + raise PyMCRepeaterApiError("Invalid plugin lifecycle operation") + if not isinstance(delete_data, bool): + raise PyMCRepeaterApiError("delete_data must be boolean") + body: dict[str, Any] = {"id": plugin_id} + if operation == "uninstall": + body["delete_data"] = delete_data + return await self._async_plugin_write(operation, plugin_id, body) + + async def _async_plugin_write( + self, operation: str, plugin_id: str, body: dict[str, Any], + ) -> dict[str, Any]: + """Never queue/retry remote writes; quarantine ambiguous completion.""" + if getattr(self, "_plugin_upgrade_active", False): + raise PyMCRepeaterApiError("Plugin write already in progress") + if getattr(self, "_plugin_upgrade_uncertain", False): + raise PyMCRepeaterApiError("Previous plugin write outcome uncertain; reconcile on Repeater before reloading entry") + self._plugin_upgrade_active = True + try: + data = await self._async_plugin_payload( + "POST", "/api/plugins/" + operation, json_body=body, max_response_bytes=1024 * 1024, + ) + if data.get("success") is False: + result = {"id": plugin_id, "outcome": data.get("outcome", "failure"), + "reason": data.get("reason", "backend_error")} + else: + plugin = data.get("plugin", data) + confirmed = isinstance(plugin, dict) and plugin.get("id") == plugin_id + if confirmed: + if operation == "enable": + confirmed = plugin.get("enabled") is True + elif operation == "disable": + confirmed = plugin.get("enabled") is False + elif operation in {"start", "stop", "restart"}: + confirmed = isinstance(plugin.get("state"), str) and plugin["state"] in {"RUNNING", "STOPPED", "DISABLED", "FAILED", "STARTING", "STOPPING"} + elif operation == "uninstall": + confirmed = plugin.get("uninstalled") is True and plugin.get("data_deleted") is body["delete_data"] + elif operation == "settings": + confirmed = isinstance(plugin.get("config"), dict) and isinstance(plugin.get("restarted"), bool) + elif operation == "catalogue_install": + try: + actual = self.validate_plugin_text(plugin.get("version")) + confirmed = "version" not in body or actual.removeprefix("v") == body["version"].removeprefix("v") + except PyMCRepeaterApiError: + confirmed = False + if not confirmed: + result = {"id": plugin_id, "outcome": "unknown", "reason": "invalid_write_response"} + else: + result = {"id": plugin_id, "outcome": "success"} + for key in ("enabled", "uninstalled", "data_deleted", "restarted", "exists"): + if isinstance(plugin.get(key), bool): + result[key] = plugin[key] + if isinstance(plugin.get("state"), str) and plugin["state"] in {"RUNNING", "STOPPED", "DISABLED", "FAILED", "STARTING", "STOPPING"}: + result["state"] = plugin["state"] + if isinstance(plugin.get("version"), str): + try: + result["version"] = self.validate_plugin_text(plugin["version"]) + except PyMCRepeaterApiError: + pass + if result["outcome"] == "unknown": + self._plugin_upgrade_uncertain = True + return result + except asyncio.CancelledError: + self._plugin_upgrade_uncertain = True + raise + finally: + self._plugin_upgrade_active = False + + async def async_update_plugin( + self, *, plugin_id: str, version: str | None = None, + force_refresh: bool = False, + ) -> dict[str, Any]: + """Upgrade one installed catalogue plugin, optionally targeting a version.""" + return await self._async_plugin_upgrade_batch( + plugin_id=plugin_id, version=version, force_refresh=force_refresh, + ) + + async def async_update_all_plugins(self, *, force_refresh: bool = False) -> dict[str, Any]: + """Sequentially upgrade eligible installed plugins; scheduling is operator-owned.""" + return await self._async_plugin_upgrade_batch(force_refresh=force_refresh) + async def async_get_stats(self) -> dict[str, Any]: """Return the base repeater stats payload.""" return await self._async_request_json("GET", "/api/stats", auth="api_token") @@ -226,15 +700,62 @@ async def async_get_packet_type_stats( params={"hours": hours}, ) + @staticmethod + def validate_query_integer(value: Any, minimum: int, maximum: int) -> int: + """Reject nonfinite, fractional and out-of-bounds query values.""" + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not minimum <= value <= maximum + or value != int(value) + ): + raise PyMCRepeaterApiError( + f"Query value must be an integer between {minimum} and {maximum}" + ) + return int(value) + + @staticmethod + def validate_radio_id(value: Any) -> str: + """Require an exact nonempty string; never coerce or trim identities.""" + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise PyMCRepeaterApiError("Radio ID must be an exact nonempty string") + return value + async def async_get_lbt_diagnostics( - self, *, hours: int = DEFAULT_PACKET_WINDOW_HOURS + self, *, hours: int = DEFAULT_PACKET_WINDOW_HOURS, + bucket_seconds: int | None = None, + severe_attempt_threshold: int | None = None, ) -> dict[str, Any]: - """Return listen-before-talk diagnostics.""" - return await self._async_request_wrapped( - "GET", - "/api/lbt_diagnostics", - params={"hours": hours}, - ) + """Return bounded LBT diagnostics (the backend has no radio filter).""" + params = {"hours": self.validate_query_integer(hours, 1, 168)} + if bucket_seconds is not None: + params["bucket_seconds"] = self.validate_query_integer(bucket_seconds, 60, 3600) + if severe_attempt_threshold is not None: + params["severe_attempt_threshold"] = self.validate_query_integer(severe_attempt_threshold, 2, 16) + return await self._async_request_wrapped("GET", "/api/lbt_diagnostics", params=params) + + async def async_get_radio_packet_rates( + self, *, hours: int = DEFAULT_PACKET_WINDOW_HOURS, + bucket_seconds: int | None = None, + ) -> dict[str, Any]: + """Return on-demand per-radio rate buckets, retaining backend defaults.""" + params = {"hours": self.validate_query_integer(hours, 1, 168)} + if bucket_seconds is not None: + params["bucket_seconds"] = self.validate_query_integer(bucket_seconds, 60, 86400) + return await self._async_request_wrapped("GET", "/api/radio_packet_rates", params=params) + + async def async_get_companion_stats( + self, *, type: str = "packets", companion_name: str | None = None, + ) -> Any: + """Return local companion diagnostics without requesting remote RF data.""" + if type not in ("core", "radio", "packets"): + raise PyMCRepeaterApiError("Companion stats type must be core, radio or packets") + params = {"type": type} + if companion_name is not None: + if not isinstance(companion_name, str) or not companion_name.strip(): + raise PyMCRepeaterApiError("Companion name must be a nonempty string") + params["companion_name"] = companion_name + return await self._async_request_wrapped("GET", "/api/companion/stats", params=params) async def async_get_route_stats(self) -> dict[str, Any]: """Return route stats.""" @@ -289,36 +810,51 @@ async def async_get_neighbor_link_history( hours: int = DEFAULT_PACKET_WINDOW_HOURS, limit: int = 1000, bucket_seconds: int | None = None, + radio_id: str | None = None, + by_radio: bool | None = None, ) -> dict[str, Any]: """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, + "path_hash_size": self.validate_query_integer(path_hash_size, 1, 3), + "hours": self.validate_query_integer(hours, 1, 168), + "limit": self.validate_query_integer(limit, 1, 5000), } if bucket_seconds is not None: - params["bucket_seconds"] = bucket_seconds + params["bucket_seconds"] = self.validate_query_integer(bucket_seconds, 60, 86400) + if radio_id is not None: + params["radio_id"] = self.validate_radio_id(radio_id) + if by_radio is not None: + if not isinstance(by_radio, bool): + raise PyMCRepeaterApiError("by_radio must be a boolean") + if by_radio and bucket_seconds is None: + raise PyMCRepeaterApiError("by_radio requires bucket_seconds") + params["by_radio"] = str(by_radio).lower() return await self._async_request_wrapped( "GET", "/api/neighbor_link_history", params=params ) - async def async_get_noise_floor_stats(self) -> dict[str, Any]: - """Return noise floor stats.""" - payload = await self._async_request_wrapped( - "GET", - "/api/noise_floor_stats", - params={"hours": DEFAULT_PACKET_WINDOW_HOURS}, - ) - return payload.get("stats", payload) - - async def async_get_crc_error_count(self) -> dict[str, Any]: - """Return CRC error count.""" - return await self._async_request_wrapped( - "GET", - "/api/crc_error_count", - params={"hours": DEFAULT_PACKET_WINDOW_HOURS}, - ) + async def async_get_noise_floor_stats( + self, *, hours: int = DEFAULT_PACKET_WINDOW_HOURS, radio_id: str | None = None, + ) -> dict[str, Any]: + """Return noise stats; a zero-sample mean is absent, not measured 0 dBm.""" + params: dict[str, Any] = {"hours": self.validate_query_integer(hours, 1, 168)} + if radio_id is not None: + params["radio_id"] = self.validate_radio_id(radio_id) + payload = await self._async_request_wrapped("GET", "/api/noise_floor_stats", params=params) + stats = payload.get("stats", payload) + if isinstance(stats, dict) and stats.get("measurement_count") == 0: + stats = dict(stats, avg_noise_floor=None) + return stats + + async def async_get_crc_error_count( + self, *, hours: int = DEFAULT_PACKET_WINDOW_HOURS, radio_id: str | None = None, + ) -> dict[str, Any]: + """Return aggregate or explicitly targeted CRC error count.""" + params: dict[str, Any] = {"hours": self.validate_query_integer(hours, 1, 168)} + if radio_id is not None: + params["radio_id"] = self.validate_radio_id(radio_id) + return await self._async_request_wrapped("GET", "/api/crc_error_count", params=params) async def async_get_advert_rate_limit_stats(self) -> dict[str, Any]: """Return advert rate limiting stats.""" @@ -539,14 +1075,42 @@ async def async_get_acl_clients( params["identity_name"] = identity_name return await self._async_request_wrapped("GET", "/api/acl_clients", params=params) + @staticmethod + def validate_acl_permissions(value: Any) -> int: + """Firmware role is the low two bits; retain the whole permissions byte.""" + value = PyMCRepeaterApiClient.validate_query_integer(value, 1, 255) + if value & 3 == 0: + raise PyMCRepeaterApiError("Guest role is not assignable; remove the ACL entry instead") + return value + + @staticmethod + def validate_acl_public_key(value: Any) -> str: + """Require an exact full public key without normalization.""" + if not isinstance(value, str) or len(value) != 64 or any(char not in "0123456789abcdefABCDEF" for char in value): + raise PyMCRepeaterApiError("client_pubkey must be exactly 64 hexadecimal characters") + return value + + async def async_set_acl_permissions( + self, *, identity_name: str, client_pubkey: str, permissions: int, + ) -> dict[str, Any]: + """Add/change an ACL role; room non-admin roles may be nonpersistent.""" + body = {"identity_name": self.validate_management_name(identity_name), + "client_pubkey": self.validate_acl_public_key(client_pubkey), + "permissions": self.validate_acl_permissions(permissions)} + data = await self._async_management_payload("POST", "/api/acl_set_permissions", json_body=body) + return {key: data[key] for key in ("identity_name", "identity_type", "client_pubkey", "permissions", "permissions_value", "persisted") if key in data} + async def async_remove_acl_client( self, *, public_key: str, identity_hash: str | None = None, + identity_name: str | None = None, ) -> dict[str, Any]: """Remove an authenticated client from one or more ACLs.""" payload: dict[str, Any] = {"public_key": public_key} + if identity_name is not None: + payload["identity_name"] = self.validate_management_name(identity_name) if identity_hash: payload["identity_hash"] = identity_hash return await self._async_request_wrapped( @@ -634,7 +1198,9 @@ async def async_publish_neighbors(self) -> Any: 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} + "POST", "/api/send_advert", json_body={"mode": mode}, + # Backend waits up to 10 seconds; allow 5 seconds for HTTP. + timeout_seconds=15, ) async def async_restart_service(self) -> dict[str, Any]: @@ -1103,6 +1669,20 @@ async def _async_open_stream( raise PyMCRepeaterApiError(f"HTTP {response.status} from {path}: {detail[:200]}") return response + @staticmethod + async def _async_read_bounded_json(response: ClientResponse, maximum: int) -> Any: + """Bound decoded HTTP bytes before parsing, including error responses.""" + import json + body = bytearray() + while True: + chunk = await response.content.read(min(65536, maximum + 1 - len(body))) + if not chunk: + break + body.extend(chunk) + if len(body) > maximum: + raise PyMCRepeaterApiError("Management response exceeds byte limit") + return json.loads(body) + async def _async_request_json( self, method: str, @@ -1112,6 +1692,8 @@ async def _async_request_json( json_body: dict[str, Any] | None = None, auth: str = "api_token", bearer_token: str | None = None, + max_response_bytes: int | None = None, + plugin_request: bool = False, timeout_seconds: float = REQUEST_TIMEOUT, ) -> dict[str, Any]: headers = {"Accept": "application/json"} @@ -1127,6 +1709,8 @@ async def _async_request_json( url = f"{self.base_url}{path}" + # Override the shared session's shorter default only for slow plugin IPC. + request_options: dict[str, Any] = {"timeout": ClientTimeout(total=timeout_seconds)} if plugin_request else {} try: async with asyncio.timeout(timeout_seconds): async with self._session.request( @@ -1135,17 +1719,29 @@ async def _async_request_json( params=params, json=json_body, headers=headers, + **request_options, ) as response: if response.status in (401, 403): raise PyMCRepeaterAuthenticationError( f"Authentication failed for {path}" ) if response.status >= 400: + if plugin_request: + # IPC 504 and conflict 409 must retain ambiguity, not + # become generic failures or expose installation logs. + try: + error_payload = (await self._async_read_bounded_json(response, max_response_bytes) + if max_response_bytes is not None else await response.json(content_type=None)) + except ValueError: + error_payload = {} + return {"success": False, "http_status": response.status, + "outcome": error_payload.get("outcome") if isinstance(error_payload, dict) else None} detail = await response.text() raise PyMCRepeaterApiError( f"HTTP {response.status} from {path}: {detail[:200]}" ) - payload = await response.json(content_type=None) + payload = (await self._async_read_bounded_json(response, max_response_bytes) + if max_response_bytes is not None else await response.json(content_type=None)) except PyMCRepeaterError: raise except TimeoutError as err: @@ -1156,7 +1752,7 @@ async def _async_request_json( raise PyMCRepeaterCannotConnect( f"Cannot connect to {self.host}:{self.port}" ) from err - except ValueError as err: + except (ValueError, RecursionError) as err: raise PyMCRepeaterApiError(f"Invalid JSON returned by {path}") from err if isinstance(payload, dict) and payload.get("success") is False: diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index 944b868..33dff25 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.2.1" + "version": "1.3.0" } diff --git a/custom_components/pymc_repeater/monitoring.py b/custom_components/pymc_repeater/monitoring.py index 5cc53d4..b50a506 100644 --- a/custom_components/pymc_repeater/monitoring.py +++ b/custom_components/pymc_repeater/monitoring.py @@ -236,6 +236,100 @@ def radio_inventory(data: dict, aliases: Any = _NO_RADIO_ALIASES) -> dict[str, d } +# Sliding windows and current shared channel budgets are measurements, not +# lifetime counters. Keep ledger values on each child; never sum them here. +RADIO_TELEMETRY: dict[str, tuple[str, str | None]] = { + "channel_utilization": ("Channel utilization", "%"), + "current_channel_airtime": ("Current channel airtime", "ms"), + "max_channel_airtime": ("Maximum channel airtime", "ms"), + "noise_floor": ("Cached noise floor", "dBm"), +} +for _window in ("1h", "24h"): + for _field, _name, _unit in ( + ("received", "Packets received", None), + ("duplicates", "Duplicate packets", None), + ("transmissions", "Physical transmissions", None), + ("avg_rssi", "Average RSSI", "dBm"), + ("avg_snr", "Average SNR", "dB"), + ): + RADIO_TELEMETRY[f"{_field}_{_window}"] = (f"{_name} ({_window})", _unit) +for _field, _name, _unit in ( + ("total_transmissions", "LBT transmissions", None), + ("retry_packets", "LBT retry packets", None), + ("retry_rate_pct", "LBT retry rate", "%"), + ("avg_attempts", "LBT average attempts", None), + ("p95_attempts", "LBT p95 attempts", None), + ("max_attempts", "LBT maximum attempts", None), + ("failed_transmissions", "LBT failed transmissions", None), + ("busy_channel_events", "LBT busy channel events", None), + ("severe_contention_count", "LBT severe contention count", None), + ("severe_contention_pct", "LBT severe contention rate", "%"), +): + RADIO_TELEMETRY[f"lbt_{_field}_24h"] = (f"{_name} (24h)", _unit) + +LBT_RADIO_FIELDS = ( + "total_transmissions", "retry_packets", "retry_rate_pct", "avg_attempts", + "p95_attempts", "max_attempts", "failed_transmissions", "busy_channel_events", + "severe_contention_count", "severe_contention_pct", +) + + +def radio_telemetry(data: dict, aliases: Any = _NO_RADIO_ALIASES) -> dict[str, dict]: + """Expose only finite child summary scalars from already-polled endpoints.""" + inventory = radio_inventory(data, aliases) + result: dict[str, dict] = {rid: {} for rid in inventory} + if not inventory: + return result + mapping = parse_radio_aliases({} if aliases is _NO_RADIO_ALIASES else aliases) + runtime = radio_inventory(data) + + def add_rows(payload: Any, source: str, fields: dict[str, str], *, summary: bool = False) -> None: + if not isinstance(payload, dict) or payload.get("error") or payload.get("success") is False: + return + rows = payload.get(source) + found: dict[str, dict] = {} + ambiguous: set[str] = set() + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + rid = row.get("radio_id") + if not isinstance(rid, str) or not rid: + continue + identity = mapping.get(rid, rid) + if identity in found or rid not in runtime or identity not in inventory: + ambiguous.add(identity) + found[identity] = row + for identity, row in found.items(): + if identity in ambiguous or row.get("error") or row.get("success") is False: + continue + metrics = row.get("summary") if summary else row + if (not isinstance(metrics, dict) or metrics.get("error") + or metrics.get("success") is False + or (summary and metrics.get("has_lbt_data") is not True)): + continue + for field, key in fields.items(): + value = finite_number(metrics.get(field)) + if value is not None: + result[identity][key] = value + + stats = data.get("stats") + add_rows(stats, "airtime_radios", { + "utilization_percent": "channel_utilization", + "current_airtime_ms": "current_channel_airtime", + "max_airtime_ms": "max_channel_airtime", + }) + add_rows(stats, "noise_floor_radios", {"noise_floor_dbm": "noise_floor"}) + for endpoint, window in (("packet_stats_1h", "1h"), ("packet_stats", "24h")): + add_rows(data.get(endpoint), "radios", { + field: f"{field}_{window}" + for field in ("received", "duplicates", "transmissions", "avg_rssi", "avg_snr") + }) + add_rows(data.get("lbt_diagnostics"), "radios", { + field: f"lbt_{field}_24h" for field in LBT_RADIO_FIELDS + }, summary=True) + return result + + def measurement_class(field: str) -> str | None: """Only classify measured fields with known physical units.""" if field in ("battery_percent", "battery_percentage"): diff --git a/custom_components/pymc_repeater/monitoring_entities.py b/custom_components/pymc_repeater/monitoring_entities.py index e19fb9e..c58741b 100644 --- a/custom_components/pymc_repeater/monitoring_entities.py +++ b/custom_components/pymc_repeater/monitoring_entities.py @@ -8,7 +8,10 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory 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 .monitoring import ( + RADIO_FIELDS, RADIO_TELEMETRY, charge_state, plugin_problem, radio_inventory, + radio_telemetry, reading_age, reading_stale, +) from .sensor import PyMCBaseEntity, _external_sensor_identity, _external_sensor_readings, _nested @@ -34,6 +37,9 @@ def _snapshot(coordinator, binary: bool) -> dict: for field in RADIO_FIELDS: if field in radio: values[("radio", radio_id, field)] = radio[field] + for radio_id, metrics in radio_telemetry(data, aliases).items(): + for field, value in metrics.items(): + values[("radio", radio_id, field)] = value 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): @@ -125,6 +131,16 @@ 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] in RADIO_TELEMETRY: + name, unit = RADIO_TELEMETRY[key[2]] + self._attr_name = name + self._attr_translation_key = f"radio_{key[2]}" + self._attr_native_unit_of_measurement = unit + self._attr_state_class = SensorStateClass.MEASUREMENT + if unit == "ms": + self._attr_device_class = SensorDeviceClass.DURATION + elif unit == "dBm": + self._attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH if key[2] == "charge_state": self._attr_icon = "mdi:battery-sync-outline" if key[2] == "age": diff --git a/custom_components/pymc_repeater/services.yaml b/custom_components/pymc_repeater/services.yaml index 8948714..2bdbc41 100644 --- a/custom_components/pymc_repeater/services.yaml +++ b/custom_components/pymc_repeater/services.yaml @@ -1,3 +1,305 @@ +enable_plugin: + name: Enable plugin + description: Enable persistently and start service plugins. Returns minimized outcome metadata. Shared write admission; uncertain completion requires reconciliation, not retries. HTTP budget 930 seconds. No immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + +disable_plugin: + name: Disable plugin + description: Stop and disable persistently. Returns minimized outcome metadata. Shared write admission; uncertain completion requires reconciliation, not retries. HTTP budget 930 seconds. No immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + +start_plugin: + name: Start plugin + description: Start an enabled service plugin without changing enabled state. Returns minimized outcome metadata. Shared write admission; uncertain completion requires reconciliation, not retries. HTTP budget 930 seconds. No immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + +stop_plugin: + name: Stop plugin + description: Stop without disabling; enabled plugins can start again after manager restart. Returns minimized outcome metadata. Shared write admission; uncertain completion requires reconciliation, not retries. HTTP budget 930 seconds. No immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + +restart_plugin: + name: Restart plugin + description: Restart an enabled service plugin. Returns minimized outcome metadata. Shared write admission; uncertain completion requires reconciliation, not retries. HTTP budget 930 seconds. No immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + +uninstall_plugin: + name: Uninstall plugin + description: Remove code; preserve plugin data unless delete_data is explicitly true. Returns minimized outcome metadata. Shared write admission; uncertain completion requires reconciliation, not retries. HTTP budget 930 seconds. No immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + delete_data: + description: Permanently delete plugin-owned data as well as release code. Defaults to data preservation. + default: false + selector: + boolean: + +get_plugin_catalogue: + name: Get plugin catalogue + description: Return public catalogue metadata for at most 100 entries, without settings, URLs or raw errors. Explicit network request; no polling or refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + force_refresh: + description: Refresh backend catalogue/release caches. + default: false + selector: + boolean: + +install_catalogue_plugin: + name: Install catalogue plugin + description: Install a backend-approved catalogue wheel. Can replace existing code; does not invent version approval. Returns metadata only. HTTP budget 930 seconds; no blind retries or immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + version: + description: Optional exact approved version token; omit for backend-selected version. + selector: + text: + force_refresh: + description: Refresh backend catalogue/release caches. + default: false + selector: + boolean: + +get_plugin_settings: + name: Get plugin settings + description: Read effective saved/default configuration on demand. Common credential keys are redacted by default; sensitive responses can enter HA action traces. No polling or refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + include_sensitive: + description: Return actual plugin credentials for a full replacement edit. Treat responses and action traces as sensitive; backend does not preserve placeholder masks. + default: false + selector: + boolean: + +update_plugin_settings: + name: Update plugin settings + description: Replace the entire plugin configuration JSON object, at most 256 KiB. Redacted placeholders are rejected. Returns metadata only, never configuration. No immediate refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact plugin ID, 1–128 ASCII token characters starting alphanumeric. + selector: + text: + config: + required: true + description: Complete JSON object, not a JSON string or partial patch. + selector: + object: + restart: + description: Restart an enabled service plugin after saving; UI-only or disabled plugins are not restarted. + default: false + selector: + boolean: + +get_sensor_types: + name: Get sensor types + description: Return available sensor types and settings schemas without hardware reads or refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + +get_sensor_configuration: + name: Get sensor configuration + description: Read the sensor section on demand. Preserve backend password masks and _original_name hints for full replacement edits. No hardware reads or refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + include_sensitive: + description: Return other unmasked credential fields if needed for an edit. Backend password masks remain masked. Responses and traces may be sensitive. + default: false + selector: + boolean: + +update_sensor_configuration: + name: Update sensor configuration + description: Replace the entire sensor section, not a partial patch. Saving requires a separate Repeater restart to apply. No automatic restart, forced read or refresh. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + config: + required: true + description: Complete JSON object with enabled, positive finite poll_interval_seconds, auto_install_packages and all definitions (at most 100). Preserve password ***** masks and _original_name origins. Maximum 256 KiB. + selector: + object: + +set_acl_permissions: + name: Set ACL permissions + description: Add or change a client role on an exact identity. Role is the low two bits (1 read-only, 2 read-write, 3 admin); higher flags are preserved. Guest role 0 is rejected. Room servers persist only admins; inspect persisted in the optional response. Refreshes cached telemetry after success. + fields: + config_entry_id: + description: Select the Repeater entry; required when multiple entries are configured. + selector: + config_entry: + integration: pymc_repeater + identity_name: + required: true + description: Exact repeater identity or room server name, at most 256 characters. + selector: + text: + client_pubkey: + required: true + description: Exact full 64-character hexadecimal client public key. + selector: + text: + permissions: + required: true + description: Integer 1–255 with low two bits 1, 2 or 3. Multiples of four are invalid. + selector: + number: + min: 1 + max: 255 + step: 1 + mode: box + +check_plugin_updates: + name: Check plugin updates + description: Check eligible installed catalogue plugins on demand and return sanitized per-plugin outcomes. Does not enable a schedule or change polling. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + plugin_id: + description: Optional exact installed plugin ID (1–128 ASCII letters, digits, dots, underscores, plus or hyphen; starts alphanumeric). Omit to check all. + selector: + text: + force_refresh: + description: Refresh backend catalogue/release caches instead of using cached metadata. + default: false + selector: + boolean: + +update_plugin: + name: Update plugin + description: Upgrade one eligible installed catalogue plugin and return success, skipped, failure or unknown. May take 930 seconds per request. Unknown outcomes require manual reconciliation before reloading the entry; never retry blindly. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + plugin_id: + required: true + description: Exact installed plugin ID (1–128 ASCII token characters; starts alphanumeric). + selector: + text: + version: + description: Optional exact version token (1–128 ASCII letters, digits, dots, underscores, plus or hyphen; starts alphanumeric). Backend catalogue approval still applies. + selector: + text: + force_refresh: + default: false + selector: + boolean: + +update_all_plugins: + name: Update all plugins + description: Sequentially upgrade all eligible installed catalogue plugins (maximum inventory 100). Continue ordinary errors but stop on unknown/conflict outcomes. Returns per-plugin outcomes and counts. No schedule is enabled automatically. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + force_refresh: + description: Refresh backend metadata. This may contact the catalogue or GitHub. + default: false + selector: + boolean: + ping_neighbor: name: Ping neighbor description: Send a ping to a neighbor by hash/path ID. @@ -614,13 +916,22 @@ get_neighbor_link_history: config_entry: integration: pymc_repeater bucket_seconds: - description: Optional bucket width in seconds (minimum 60). Omit for raw rows; supplying it returns buckets instead. + description: Optional bucket width in seconds (60–86400). Omit for raw rows; supplying it returns buckets instead. selector: number: min: 60 + max: 86400 step: 1 mode: box unit_of_measurement: s + radio_id: + description: Optional exact nonempty radio ID to filter observations. + selector: + text: + by_radio: + description: Split time buckets by receiving radio. Requires bucket_seconds when true; omit to retain legacy grouping. + selector: + boolean: peer_hash: required: true selector: @@ -716,7 +1027,7 @@ get_acl_clients: remove_acl_client: name: Remove ACL client - description: Remove an authenticated client from one or more ACLs. + description: Remove an authenticated client from one or more ACLs. Named identity takes precedence over hash; omission of both targets every ACL. Legacy public_key field is preserved. fields: config_entry_id: selector: @@ -729,6 +1040,10 @@ remove_acl_client: identity_hash: selector: text: + identity_name: + description: Optional exact identity or room name, preferred over identity_hash. + selector: + text: get_room_messages: name: Get room messages @@ -799,3 +1114,122 @@ delete_room_message: min: 1 max: 1000000000 mode: box + +get_radio_packet_rates: + name: Get radio packet rates + description: Return bounded per-radio reception and physical-transmission rate buckets. No coordinator refresh. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + hours: + default: 24 + selector: + number: + min: 1 + max: 168 + step: 1 + mode: box + bucket_seconds: + description: Optional bucket width; omit to retain the backend default. + selector: + number: + min: 60 + max: 86400 + step: 1 + mode: box + +get_noise_floor_stats: + name: Get noise floor stats + description: Return aggregate or named-radio noise statistics. Zero-measurement averages are unknown. No coordinator refresh. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + hours: + default: 24 + selector: + number: + min: 1 + max: 168 + step: 1 + mode: box + radio_id: + description: Optional exact nonempty radio ID. + selector: + text: + +get_crc_error_count: + name: Get CRC error count + description: Return aggregate or named-radio CRC errors for a bounded window. No coordinator refresh. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + hours: + default: 24 + selector: + number: + min: 1 + max: 168 + step: 1 + mode: box + radio_id: + description: Optional exact nonempty radio ID. + selector: + text: + +get_companion_stats: + name: Get companion stats + description: Return local companion diagnostics, not remote RF status or telemetry. No coordinator refresh. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + type: + default: packets + selector: + select: + options: + - core + - radio + - packets + companion_name: + selector: + text: + +get_lbt_diagnostics: + name: Get LBT diagnostics + description: Return bounded LBT diagnostics including per-radio results when supplied by the backend. There is no radio_id filter. No coordinator refresh. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + hours: + default: 24 + selector: + number: + min: 1 + max: 168 + step: 1 + mode: box + bucket_seconds: + description: Optional bucket width; omit to retain the backend default. + selector: + number: + min: 60 + max: 3600 + step: 1 + mode: box + severe_attempt_threshold: + selector: + number: + min: 2 + max: 16 + step: 1 + mode: box diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index c08319b..f41e855 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -51,6 +51,30 @@ }, "entity": { "sensor": { + "radio_channel_utilization": {"name": "Channel utilization"}, + "radio_current_channel_airtime": {"name": "Current channel airtime"}, + "radio_max_channel_airtime": {"name": "Maximum channel airtime"}, + "radio_noise_floor": {"name": "Cached noise floor"}, + "radio_received_1h": {"name": "Packets received (1h)"}, + "radio_duplicates_1h": {"name": "Duplicate packets (1h)"}, + "radio_transmissions_1h": {"name": "Physical transmissions (1h)"}, + "radio_avg_rssi_1h": {"name": "Average RSSI (1h)"}, + "radio_avg_snr_1h": {"name": "Average SNR (1h)"}, + "radio_received_24h": {"name": "Packets received (24h)"}, + "radio_duplicates_24h": {"name": "Duplicate packets (24h)"}, + "radio_transmissions_24h": {"name": "Physical transmissions (24h)"}, + "radio_avg_rssi_24h": {"name": "Average RSSI (24h)"}, + "radio_avg_snr_24h": {"name": "Average SNR (24h)"}, + "radio_lbt_total_transmissions_24h": {"name": "LBT transmissions (24h)"}, + "radio_lbt_retry_packets_24h": {"name": "LBT retry packets (24h)"}, + "radio_lbt_retry_rate_pct_24h": {"name": "LBT retry rate (24h)"}, + "radio_lbt_avg_attempts_24h": {"name": "LBT average attempts (24h)"}, + "radio_lbt_p95_attempts_24h": {"name": "LBT p95 attempts (24h)"}, + "radio_lbt_max_attempts_24h": {"name": "LBT maximum attempts (24h)"}, + "radio_lbt_failed_transmissions_24h": {"name": "LBT failed transmissions (24h)"}, + "radio_lbt_busy_channel_events_24h": {"name": "LBT busy channel events (24h)"}, + "radio_lbt_severe_contention_count_24h": {"name": "LBT severe contention count (24h)"}, + "radio_lbt_severe_contention_pct_24h": {"name": "LBT severe contention rate (24h)"}, "radio_status": { "name": "Radio status", "state": {"ok": "OK", "degraded": "Degraded", "disabled": "Disabled"} diff --git a/dashboards/openhop_operations.yaml b/dashboards/openhop_operations.yaml index 9d924b7..62cb1b1 100644 --- a/dashboards/openhop_operations.yaml +++ b/dashboards/openhop_operations.yaml @@ -28,6 +28,21 @@ cards: 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. + # Optional: install the companion scripts and replace their entry ID first. + - type: button + show_icon: false + name: Check plugin updates · read-only + icon: mdi:puzzle-check-outline + tap_action: + action: perform-action + perform_action: script.openhop_example_check_plugin_updates + - type: markdown + title: Explicit management + content: >- + No plugin upgrades or configuration replacements run automatically. + Use the comprehensive view and its companion script fields for lifecycle, + sensor configuration, ACL permissions and on-demand diagnostics. + [Setup and safety](https://github.com/openhop-dev/openHop-HA-Integration/blob/dev/docs/dashboard-management.md). - type: history-graph title: Resource trends hours_to_show: 12 diff --git a/dashboards/openhop_repeater_dashboard.yaml b/dashboards/openhop_repeater_dashboard.yaml index 67f75aa..54faebd 100644 --- a/dashboards/openhop_repeater_dashboard.yaml +++ b/dashboards/openhop_repeater_dashboard.yaml @@ -282,6 +282,42 @@ sections: {% else %} Status unavailable {% endif %} + # Install examples/openhop_dashboard_scripts.yaml before using these controls. + - type: button + show_icon: false + name: Check plugin catalogue · read-only + icon: mdi:puzzle-outline + tap_action: + action: perform-action + perform_action: script.openhop_example_get_plugin_catalogue + - type: button + show_icon: false + name: Check plugin updates · read-only + icon: mdi:puzzle-check-outline + tap_action: + action: perform-action + perform_action: script.openhop_example_check_plugin_updates + - type: button + show_icon: false + name: Upgrade eligible plugins · changes code + icon: mdi:gesture-tap-button + tap_action: + action: perform-action + perform_action: script.openhop_example_update_all_plugins + data: + confirmed: true + confirmation: + text: Upgrade every eligible catalogue plugin? This changes code and may restart plugins. Reconcile uncertain outcomes before retrying. + - type: markdown + title: Plugin management + content: |- + Checks do not install code (they can contact the catalogue/GitHub). + For one plugin, use **Developer tools → Actions** and the installed + `script.openhop_example_update_plugin` or `install_catalogue_plugin` wrapper + with a reviewed real plugin ID and `confirmed: true`. + Enable/disable persists state; start/stop does not change enabled state. + Restart requires an enabled service plugin. Uninstall preserves data. + [Lifecycle and settings workflow](https://github.com/openhop-dev/openHop-HA-Integration/blob/dev/docs/dashboard-management.md#plugins) - type: entities title: Update Management state_color: true @@ -319,6 +355,22 @@ sections: cards: - type: vertical-stack cards: + - type: button + show_icon: false + name: Inspect sensor types · read-only + icon: mdi:gesture-tap-button + tap_action: + action: perform-action + perform_action: script.openhop_example_get_sensor_types + - type: markdown + title: Sensor configuration workflow + content: |- + Read configuration privately in the action editor; edit the **entire** + section before calling `script.openhop_example_update_sensor_configuration`. + Supply the complete `config` object and `confirmed: true`; no dashboard + button replaces definitions. Preserve password masks and rename origins. + Saved configuration needs a separate Repeater restart when ready. + [Sensor workflow](https://github.com/openhop-dev/openHop-HA-Integration/blob/dev/docs/dashboard-management.md#sensors) - type: entities title: Repeater Controls state_color: true @@ -362,6 +414,16 @@ sections: cards: - type: vertical-stack cards: + - type: markdown + title: ACL permissions workflow + content: |- + Use the action editor wrappers `script.openhop_example_set_acl_permissions` + and `script.openhop_example_remove_acl_client` with an exact identity and + actual client public key. Review the role before setting `confirmed: true`. + Roles: **1 read-only · 2 read-write · 3 admin**; higher flags are retained. + Room servers persist only admins; inspect `persisted` privately. + Removal is identity-scoped here, never a one-click placeholder grant. + [ACL workflow](https://github.com/openhop-dev/openHop-HA-Integration/blob/dev/docs/dashboard-management.md#acl) - type: entities title: Routing state_color: true @@ -410,6 +472,13 @@ sections: cards: - type: vertical-stack cards: + - type: button + show_icon: false + name: Query noise · 24 h + icon: mdi:gesture-tap-button + tap_action: + action: perform-action + perform_action: script.openhop_example_get_noise_floor_stats - type: entities title: System Health state_color: true @@ -507,6 +576,66 @@ sections: name: Coding rate - entity: sensor.REPEATER_SLUG_radio_example_preamble_length name: Preamble length + # EXAMPLE_RADIO is an independent child-device prefix, NOT REPEATER_SLUG. + # Replace each full example ID with exact entity IDs from the same radio child. + # Repeat this card for other radios; remove unsupported rows on older backends. + - type: entities + title: Radio Telemetry · Example Radio + show_header_toggle: false + entities: + - entity: sensor.EXAMPLE_RADIO_channel_utilization + name: Shared channel utilization + - entity: sensor.EXAMPLE_RADIO_current_channel_airtime + name: Current channel airtime + - entity: sensor.EXAMPLE_RADIO_maximum_channel_airtime + name: Maximum channel airtime + - entity: sensor.EXAMPLE_RADIO_cached_noise_floor + name: Cached noise floor + - entity: sensor.EXAMPLE_RADIO_packets_received_1h + name: Received 1h + - entity: sensor.EXAMPLE_RADIO_duplicate_packets_1h + name: Duplicates 1h + - entity: sensor.EXAMPLE_RADIO_physical_transmissions_1h + name: Physical TX 1h + - entity: sensor.EXAMPLE_RADIO_average_rssi_1h + name: Average RSSI 1h + - entity: sensor.EXAMPLE_RADIO_average_snr_1h + name: Average SNR 1h + - entity: sensor.EXAMPLE_RADIO_packets_received_24h + name: Received 24h + - entity: sensor.EXAMPLE_RADIO_duplicate_packets_24h + name: Duplicates 24h + - entity: sensor.EXAMPLE_RADIO_physical_transmissions_24h + name: Physical TX 24h + - entity: sensor.EXAMPLE_RADIO_average_rssi_24h + name: Average RSSI 24h + - entity: sensor.EXAMPLE_RADIO_average_snr_24h + name: Average SNR 24h + - entity: sensor.EXAMPLE_RADIO_lbt_transmissions_24h + name: LBT transmissions 24h + - entity: sensor.EXAMPLE_RADIO_lbt_retry_packets_24h + name: LBT retry packets 24h + - entity: sensor.EXAMPLE_RADIO_lbt_retry_rate_24h + name: LBT retry rate 24h + - entity: sensor.EXAMPLE_RADIO_lbt_average_attempts_24h + name: LBT average attempts 24h + - entity: sensor.EXAMPLE_RADIO_lbt_p95_attempts_24h + name: LBT p95 attempts 24h + - entity: sensor.EXAMPLE_RADIO_lbt_maximum_attempts_24h + name: LBT maximum attempts 24h + - entity: sensor.EXAMPLE_RADIO_lbt_failed_transmissions_24h + name: LBT failed transmissions 24h + - entity: sensor.EXAMPLE_RADIO_lbt_busy_channel_events_24h + name: LBT busy channel events 24h + - entity: sensor.EXAMPLE_RADIO_lbt_severe_contention_count_24h + name: LBT severe contention 24h + - entity: sensor.EXAMPLE_RADIO_lbt_severe_contention_rate_24h + name: LBT severe contention rate 24h + - type: markdown + content: |- + Radio rows require explicit per-radio backend telemetry. Shared-channel + airtime budgets must not be summed. Packet counts are sliding windows; + physical transmissions include fan-out egress. Unknown is not zero RF activity. - type: entities title: Source Freshness show_header_toggle: false @@ -536,6 +665,20 @@ sections: cards: - type: vertical-stack cards: + - type: button + show_icon: false + name: Query packet rates · 24 h + icon: mdi:gesture-tap-button + tap_action: + action: perform-action + perform_action: script.openhop_example_get_radio_packet_rates + - type: button + show_icon: false + name: Query CRC errors · 24 h + icon: mdi:gesture-tap-button + tap_action: + action: perform-action + perform_action: script.openhop_example_get_crc_error_count - type: entities title: Packet Flow state_color: true @@ -619,6 +762,13 @@ sections: cards: - type: vertical-stack cards: + - type: button + show_icon: false + name: Query LBT · 24 h + icon: mdi:gesture-tap-button + tap_action: + action: perform-action + perform_action: script.openhop_example_get_lbt_diagnostics - type: entities title: Listen Before Talk Diagnostics state_color: true @@ -701,6 +851,13 @@ sections: cards: - type: vertical-stack cards: + - type: button + show_icon: false + name: Query local companion packets + icon: mdi:gesture-tap-button + tap_action: + action: perform-action + perform_action: script.openhop_example_get_companion_stats - type: entities title: Companions state_color: true diff --git a/docs/AGENTS.md b/docs/AGENTS.md index b46f807..7835436 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -36,10 +36,10 @@ There is no standalone Python/JavaScript package manifest, requirements file, Ma ## Runtime and dependencies - Language: Python plus JSON, YAML, Markdown, and PNG assets. -- Runtime target verified from CI: **Python 3.12**. +- Runtime source-contract CI covers **Python 3.11–3.14**; this is not live testing of four HA versions. - 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`). +- Integration model: Home Assistant config-entry integration with `iot_class: local_polling` and seven forwarded platforms: update, sensor, binary sensor, button, select, switch, and number. +- `manifest.json` declares no third-party requirements. Home Assistant owns `aiohttp`, `yarl`, and schema validation; newer HA supplies the `voluptuous` import through its `probatio` compatibility alias. Do not override HA pins with standalone requirements. - 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. diff --git a/docs/dashboard-management.md b/docs/dashboard-management.md new file mode 100644 index 0000000..a6ed8ab --- /dev/null +++ b/docs/dashboard-management.md @@ -0,0 +1,130 @@ +# Dashboard management workflows (1.3.0) + +The [single-view dashboard](../dashboards/openhop_repeater_dashboard.yaml) uses +built-in cards and [companion scripts](../examples/openhop_dashboard_scripts.yaml). +It does not create fictional control entities. The compact view adds only a +read-only update check and this guide. No schedule is enabled. + +## Install and select the target + +1. Merge the example's **top-level script mappings** into your existing + `scripts.yaml`, normally loaded by `script: !include scripts.yaml`. Do not + overwrite existing scripts or add another `script:` wrapper inside that file. + With packages or split includes, adapt the surrounding include structure. +2. Replace **every `EXAMPLE_CONFIG_ENTRY_ID`** with the actual openHop integration + entry ID. Find it in the integration's entry page URL (`config_entry=...`), or + select the intended entry in Developer tools → Actions and inspect its YAML + `config_entry_id`. `REPEATER_SLUG` is an **entity prefix, not the entry ID**. +3. For multiple Repeaters, duplicate scripts with distinct `openhop_example_...` + IDs, entry IDs and aliases; rename **all matching dashboard script targets**. + Keep radio-child and dynamic entity replacement separate from this mapping. +4. Check YAML configuration, reload scripts using Home Assistant's supported + script reload, and confirm the expected `script.*` entities exist. Install the + view through the view YAML editor, not as a `views:` dashboard. If you do not + install scripts, remove the script buttons; monitoring remains usable. +5. Read-only buttons never change plugin code, but catalogue/update checks can + contact external servers. The separately labeled bulk upgrade requires a + Lovelace confirmation and passes `confirmed: true` to the wrapper. + +Every integration call in these scripts captures a sibling `response_variable`. +Lovelace cannot directly consume response-only actions. Notifications deliberately +show only bounded public counts, fixed outcome codes, save/restart flags, or a +completion notice. Detailed responses are available in private script traces, +not sensor attributes. Action failures stop the script before its notification; +a completion notice is not proof of RF delivery or a persisted ACL grant. + +## Plugins + +Use **Developer tools → Actions** to select the installed wrapper by full script +ID. Supply fields there; ordinary Run buttons do not prompt for required fields. +All write wrappers require an explicit `confirmed: true` after reviewing the +actual target and consequences. This is an operator review gate, not security +access control. Use the real plugin ID from the catalogue or installed inventory. + +| Wrapper suffix (`script.openhop_example_…`) | Review | +| --- | --- | +| `get_plugin_catalogue` | Public catalogue count; inspect response for approved IDs | +| `check_plugin_updates` | Counts and update-available total; failures/unknown are not up to date | +| `install_catalogue_plugin` | `plugin_id`; can replace installed code | +| `update_plugin` | `plugin_id`; upgrades only eligible installed catalogue plugins | +| `update_all_plugins` | Sequential eligible upgrades, can restart plugins; no schedule | +| `enable_plugin` / `disable_plugin` | `plugin_id`; persist enabled/disabled state | +| `start_plugin` / `stop_plugin` | `plugin_id`; start requires enabled; stop does not disable | +| `restart_plugin` | `plugin_id`; enabled service plugins only | +| `uninstall_plugin` | `plugin_id`; explicitly sends **`delete_data: false`** | +| `update_plugin_settings` | `plugin_id`, complete `config` object, `restart` (false by default) | + +Version-pinned install/upgrade, individual update checks and force-refresh options +remain available through the native action editor. Omit `version` for backend +selection rather than supplying an empty token. The wrappers use cached metadata +(`force_refresh: false`). UI-only plugins need not run a process. + +**Settings read stays editor-only:** call `pymc_repeater.get_plugin_settings` with +your `config_entry_id`, `plugin_id`, `include_sensitive: false`, and sibling +`response_variable: settings`. Inspect privately; credential redaction is a +heuristic. Request `include_sensitive: true` only for a necessary complete edit. +Pass the actual entire `settings.config` mapping (not the response envelope or a +JSON string) into the write wrapper. Preserve unrelated fields; `[REDACTED]` +placeholders are rejected. Plugin settings have no `*****` mask-preservation +protocol. No dashboard button reads or replaces settings. + +Plugin writes share fail-fast admission across lifecycle/settings/upgrades. +Allow up to 930 seconds per request; a bulk operation can take much longer. +Timeout/cancellation does not cancel remote work. Inspect/reconcile actual Repeater +state after unknown completion or partial failure, then reload the integration +entry to clear quarantine. Do not blindly retry; reload alone is not reconciliation. +Manual wheel upload remains in the Repeater UI, not a HA filesystem action. + +## Sensors + +The `get_sensor_types` wrapper reads supported types/schema without forced hardware +reads. For configuration, call **`pymc_repeater.get_sensor_configuration` in the +native action editor**, supplying the actual entry ID, `include_sensitive: false` +and sibling `response_variable: sensors`. Keep responses private. Read with +sensitive opt-in only if required for an edit; backend password masks stay masked. + +Use `script.openhop_example_update_sensor_configuration` with a complete `config` +object and `confirmed: true`. It has deliberately **no default/example payload**: +include `enabled`, positive finite `poll_interval_seconds`, +`auto_install_packages`, and **all `definitions`**, including unchanged ones. +An empty definitions list deletes all configured definitions. Preserve backend +`settings.password: "*****"` masks and `_original_name` rename hints by name/type, +not list position. Never replace credentials with `[REDACTED]`. + +Saving reports `saved` and `restart_required`; it never restarts automatically. +Review package-install/hardware effects, then restart the Repeater separately +when ready. This does not change HA's integration polling interval. + +## ACL + +Select `script.openhop_example_set_acl_permissions` in the action editor and supply +an exact nonempty `identity_name`, actual **64-hex `client_pubkey`**, integer +`permissions`, and `confirmed: true`. No fake public key, identity or role default +is bundled. Role is the low two bits: **1 read-only, 2 read-write, 3 admin**; higher +flags remain. Values 1–255 must have nonzero low bits; multiples of four are invalid. +Room servers persist only admins. Inspect optional `persisted` in the private +response; a returned action does not prove persistence. + +`script.openhop_example_remove_acl_client` requires an explicit identity name and +uses the preserved native **`public_key`** field (not `client_pubkey`). The wrapper +rejects an empty identity to avoid every-ACL removal. The native action retains +legacy hash-only/every-ACL scope; name wins over hash. No one-click grants or +removals are placed in the dashboard. Notifications never show public keys. + +## On-demand diagnostics + +Buttons capture 24-hour packet-rate buckets (300 seconds), noise statistics, CRC +count, and LBT (300-second buckets, severe-attempt threshold 3), plus local +companion packet diagnostics. They neither refresh the coordinator nor force +sensor hardware reads. Inspect the private trace for results, not notifications. +For named-radio noise/CRC or alternate hours, use native action editor fields; +LBT has **no `radio_id`**. Companion statistics are local, not remote RF telemetry. +Older backends may return unsupported-action errors. + +All configuration/ACL inputs and responses can enter HA action/script traces; +restrict access. Never copy them into logs, notifications or sensor attributes. +See [management contracts](management-actions.md), [plugin upgrades](plugin-upgrades.md) +and [1.3.0 API notes](dev-api-1.3.0.md) for exact bounds and failure semantics. + +Local parsed-YAML/source contracts verify wiring, fields and guards; they do not +prove HA schema validation, rendered layout, authenticated API or hardware behavior. diff --git a/docs/dev-api-1.3.0.md b/docs/dev-api-1.3.0.md new file mode 100644 index 0000000..d0cce0a --- /dev/null +++ b/docs/dev-api-1.3.0.md @@ -0,0 +1,118 @@ +# Repeater dev compatibility for 1.3.0 + +## Source authority + +This release was compared against freshly fetched Repeater `origin/dev` at +`9c958e3111604aca5ab664c9feead8c8bc5e4240`, using an isolated source archive. +The implementation and storage helpers, not endpoint names alone, determine the +response contracts. This replaces neither the historical +[installed-source audit](installed-api-alignment.md) nor verification of a +particular appliance's installed version. + +The review inventoried 154 documented paths and 169 method entries in that +snapshot's bundled OpenAPI, including duplicated authentication aliases. This is +scope triage, not a claim of exhaustive runtime testing or complete HA API parity. + +The management expansion additionally traced selected endpoint, IPC, manager, +storage, runtime and ACL helper files at immutable Repeater `origin/dev` +`3c4bf3a9586d1e0b3871091649bc3fd09da3b662` via `git show`. The checkout's older +`feat/sensor-manager` working files were not authority. The earlier OpenAPI counts +above remain the original snapshot inventory, not a new whole-tree audit. + +## Monitoring contract + +- `stats.radios` and `radio_profiles` describe configuration; they do not prove + applied RF settings, modem connectivity, or successful transmission +- `stats.airtime_radios` contains named channel budgets. Radios sharing a channel + report the same ledger; do not sum child budgets to obtain node totals. The + existing parent utilization is the maximum channel utilization on newer builds +- `stats.noise_floor_radios` supplies cached, explicitly attributed noise readings +- `packet_stats.radios` supplies per-radio window counts and RSSI/SNR averages; + the existing one-hour and 24-hour polls already retrieve these summaries +- Physical `transmissions` count successful fan-out egress operations, unlike + legacy parent packet-row totals. Sliding-window values are not lifetime counters +- `lbt_diagnostics.radios` supplies explicitly attributed summary statistics; + timelines and raw buckets do not become entity attributes +- Missing newer fields on older builds remain unavailable. Missing or malformed + inventory, duplicate IDs, alias collisions, null averages and nonfinite values + must not become healthy zeroes or copied parent metrics +- Neighbor history supports optional `radio_id` and bucket grouping `by_radio`. + Raw rows remain the default; historical IDs in returned actions stay backend IDs, + not Home Assistant aliases + +## Existing capabilities retained + +The integration already supports aggregate radio status/problems, RRD fallback, +MQTT neighbor-publication phase and broker reconnecting attributes, direct advert +schedules, targeted raw radio configuration, companion reply budgets and optional +status/telemetry responses. These are not new 1.3.0 capabilities. + +The duty-cycle switch already uses the persistent `update_duty_cycle_config` +endpoint. The separate client wrapper for `set_duty_cycle` is live-only; it is not +used by that switch. Raw configuration results may require a Repeater restart; +the integration does not silently restart it or promise atomic backend validation. + +## Sensor cadence limitation + +The inspected upstream sensor manager schedules individual plugin intervals but +does not attach its resolved interval to each reading envelope. The integration +prefers `poll_interval_seconds` when supplied and retains a legacy global-summary +fallback only when it is absent. That fallback cannot prove a plugin's effective +cadence: for example, a modem plugin can sample less frequently than the global +manager interval. Updating HA alone cannot fix this backend metadata limitation. +No interval is guessed from sensor type, and no backend changes were made here. + +## Explicit plugin upgrade actions + +[Plugin upgrades](plugin-upgrades.md) add check, single optional-version upgrade, +and sequential update-all actions using the local backend plugin endpoint/IPC/ +manager/runtime/catalogue source authority. They return minimized structured +outcomes, guard overlapping writes, stop on uncertain outcomes without retries, +and leave daily scheduling to an optional operator-owned 03:00 automation. +Plugin inventory/check/install results are not added to coordinator polling; +existing aggregate plugin health polling is unchanged. + +## Deliberately excluded + +[Explicit management actions](management-actions.md) add plugin catalogue installation, +lifecycle and settings, complete sensor configuration replacement, and ACL role +assignment/named removal. These remain explicit and bounded, not automatic polls. + +No blanket generated API or new automatic history polling is added. Authentication +and token administration, first-run setup, forced sensor hardware reads, +policy/group editing, identity/key exports and mutations, arbitrary-path wheel +installation, plugin logs/progress/runtime documents, bulk packet and graph datasets, +and additional continuous streams remain outside this release. Existing explicit +destructive actions are retained, not exercised for testing. + +## Dependency ownership + +The manifest deliberately has no third-party requirements. Home Assistant owns +`aiohttp`, `yarl`, schema validation and its own APIs. At audit time, stable HA +2026.9.4 requires Python >=3.14.2 and pins aiohttp 3.14.3 and yarl 1.24.5; PyPI's +newer yarl is not a reason to override that pin. Current HA supplies the +`voluptuous` import through its `probatio` compatibility alias. HA 2024.1 supplies +the original library and remains the integration's advertised minimum; the +Sections dashboard requires a newer frontend. + +CI source checks cover Python 3.11–3.14; this is not testing four HA installations. +Checkout/setup-python v7 are already current. HACS/Hassfest retain upstream moving +refs. Dependabot version updates target dev once that configuration reaches the +default branch; default-branch security update policy is separate. + +First-party references: + +- [HA stable dependencies](https://github.com/home-assistant/core/blob/2026.9.4/pyproject.toml) +- [HA schema compatibility setup](https://github.com/home-assistant/core/blob/2026.9.4/homeassistant/__init__.py) +- [HA minimum release dependencies](https://github.com/home-assistant/core/blob/2024.1.0/pyproject.toml) +- [Checkout releases](https://github.com/actions/checkout/releases) +- [Setup Python releases](https://github.com/actions/setup-python/releases) + +## Verification boundary + +Tests execute actual checked-in client, schema/registration and entity logic with +fake HTTP/HA boundaries, supplemented by source-contract assertions, compilation, +JSON/YAML checks and workflow linting. Sanitized synthetic fixtures follow the +inspected backend response structure. They do not prove live HA registration, +probatio compatibility, authenticated Repeater HTTP, RF delivery, dashboard +rendering or hosted HACS/Hassfest checks. No deployment or release was performed. diff --git a/docs/management-actions.md b/docs/management-actions.md new file mode 100644 index 0000000..86ce21e --- /dev/null +++ b/docs/management-actions.md @@ -0,0 +1,222 @@ +# Explicit management actions (1.3.0) + +These actions were traced through Repeater endpoints, IPC, manager, storage and +runtime helpers at immutable `origin/dev` +`3c4bf3a9586d1e0b3871091649bc3fd09da3b662`. Older installations may not implement +these contracts. Unsupported endpoints produce errors, not invented support. +The backend checkout's older working branch was not used as authority. + +All actions use the existing `pymc_repeater` domain and accept `config_entry_id`. +Select it when more than one Repeater is configured. Nothing here adds polling, +background progress listeners, schedules, forced hardware reads or automatic +Repeater restarts. Calls execute only when requested. Use a trusted LAN/VPN: +the integration's HTTP connection is not encrypted. + +## Plugin lifecycle + +| Action | Fields besides `config_entry_id` | Behavior | +| --- | --- | --- | +| `get_plugin_catalogue` | `force_refresh: false` | Public catalogue metadata, at most 100 entries; can initiate external catalogue/release network requests | +| `install_catalogue_plugin` | required `plugin_id`; optional `version`; `force_refresh: false` | Downloads and installs a backend-approved wheel; can replace existing plugin code | +| `enable_plugin` | required `plugin_id` | Persists enabled state and starts a service plugin | +| `disable_plugin` | required `plugin_id` | Stops and persists disabled state | +| `start_plugin` | required `plugin_id` | Starts an enabled plugin; does not enable a disabled plugin | +| `stop_plugin` | required `plugin_id` | Stops without disabling; enabled plugins may start after a manager restart | +| `restart_plugin` | required `plugin_id` | Restarts an enabled service plugin | +| `uninstall_plugin` | required `plugin_id`; `delete_data: false` | Removes release code, preserving plugin data by default; explicit true permanently deletes data | +| `get_plugin_settings` | required `plugin_id`; `include_sensitive: false` | Reads effective saved/default configuration on demand, without filesystem paths | +| `update_plugin_settings` | required `plugin_id`, complete `config` object; `restart: false` | Replaces the entire configuration, optionally restarting an enabled service plugin | + +All plugin actions require a response (`response_variable` in automations). +Writes return bounded identity/version/state/boolean metadata and fixed +`outcome`/`reason` codes, never settings, filesystem paths or backend logs. +UI-only plugins need not have a running process. A reported backend failure may +still have side effects: for example, enable can persist before start fails, +and settings can save before restart fails. Inspect the Repeater before retrying. + +Lifecycle, catalogue install, settings writes and the existing single/bulk upgrades +share one **per-client fail-fast write admission**. They do not queue. Unknown +completion, conflict (HTTP 409), HTTP 502/504, lost responses or cancellation during +a write quarantine further plugin writes. Requests are never automatically retried. +Inspect/reconcile actual Repeater state, then reload the HA entry to clear this +local quarantine; reloading alone does not reconcile or stop the remote operation. +Other clients can still change the Repeater; this is not a global transaction lock. + +Plugin requests allow a finite 930-second HTTP budget (900-second IPC completion +plus margin), overriding both the outer asyncio deadline and aiohttp session +budget. Cancellation/timeout does not cancel an accepted remote IPC operation. +New management responses are capped at 1 MiB decoded HTTP bytes before JSON +parsing. Catalogue descriptions are limited to 1024 characters. Writes and explicit +settings/config documents accept finite JSON objects up to 256 KiB, 32 nested +levels and 10,000 visited values. Plugin ID/version tokens are exact ASCII tokens +of 1–128 characters, start alphanumeric, and otherwise allow letters/digits, +`.`, `_`, `+` and `-`; they are never trimmed or repaired. + +No immediate coordinator refresh is triggered by plugin writes. Existing plugin +health polling eventually reflects the manager's reported state, without retaining +these action results in coordinator data or normal entity attributes. + +### Settings safety + +`config` is a mapping, not a JSON-encoded string. It replaces the complete saved +plugin configuration, not a patch. Preserve unrelated fields. By default reads +replace common credential-key values with `[REDACTED]` (case-insensitive key +matching for password, secret, token, private/API/transport keys). This is a +heuristic, not a guarantee for arbitrary plugin-defined credentials or secret +values under innocent field names. Treat all explicit configuration responses as +potentially sensitive. The updater rejects `[REDACTED]` anywhere rather than +silently replacing a real credential with a placeholder. + +For a necessary full edit, explicitly use `include_sensitive: true` or supply the +real credential fields yourself. Plugin settings have **no backend mask-preservation +protocol**; do not submit sensor `*****` masks to a plugin. Only effective `config` +is returned, not duplicate `saved`/`defaults` copies. Write responses never echo +settings. HA action/automation traces can retain both inputs and response variables; +limit access and do not copy sensitive responses into notifications or logs. + +```yaml +service: pymc_repeater.get_plugin_catalogue +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID +response_variable: catalogue +``` + +```yaml +service: pymc_repeater.install_catalogue_plugin +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID + plugin_id: openhop.example # replace with a real catalogue ID +response_variable: installation +``` + +```yaml +service: pymc_repeater.uninstall_plugin +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID + plugin_id: openhop.example + delete_data: false # preserve plugin-owned data +response_variable: removal +``` + +### Wheel upload and diagnostics limits + +The audited backend accepts **multipart field `wheel`** at +`POST /api/plugins/install`, or a JSON `wheel_path` on the **Repeater's filesystem**. +A HA `/config/...` path is not a Repeater path. No arbitrary-path wheel action is +exposed here: the integration has no confined upload-directory/attachment contract +or bounded, symlink-safe HA file reader. Use the Repeater UI for manual wheel upload +and the catalogue action for HA installation. Multipart staging cleanup is backend +owned; unacknowledged uploads may intentionally be retained after uncertain IPC. + +Plugin logs, progress SSE and runtime JSON are not exposed by these actions. +The backend can return arbitrary plugin-written secrets and raw installation logs; +there is no universal safe schema for those documents. This omission does not +remove existing Repeater log actions or plugin health monitoring. Use the Repeater +UI for detailed diagnosis. No logs/runtime/settings are added to normal outcomes. + +## Sensor configuration + +| Action | Fields | Behavior | +| --- | --- | --- | +| `get_sensor_types` | `config_entry_id` | Available types and settings schemas; no read of hardware | +| `get_sensor_configuration` | `config_entry_id`; `include_sensitive: false` | Reads current configuration, preserving backend password masks and rename origins | +| `update_sensor_configuration` | `config_entry_id`; required complete `config` object | Persists the entire sensor section; returns `saved` and `restart_required` | + +These actions require response variables and never trigger a refresh. Actual API +routes are `/api/sensors_types`, `/api/sensors_config`, and +`/api/sensors_config_update`; nested `/api/sensors/config` comments in the backend +are not the dispatcher contract. Non-plugin management calls have a finite +30-second outer/aiohttp request budget and the same response cap. A timeout does +not prove that persistence failed; inspect before retrying. + +**Full replacement:** include `enabled`, finite positive `poll_interval_seconds`, +`auto_install_packages`, and **all** `definitions` (including unchanged definitions, +maximum 100). Omitted definitions are removed; an empty list intentionally clears +them. Partial top-level patches are rejected rather than resetting omitted values +to backend defaults. Sensor names must be unique. `settings` must be a JSON object. +Names/origin/type strings are bounded to 256 characters without control characters; +boolean flags must be actual booleans. Package-install choices and hardware settings +remain usable; review their effects before applying/restarting. + +Preserve lowercase `settings.password: "*****"` and `_original_name` from the +backend read when renaming a sensor. The backend resolves masks by name/type and +origin, **not list position**, and rejects ambiguous matches. Never substitute +`[REDACTED]` for a sensor password: that placeholder has no backend preservation +meaning. Common other credentials are redacted by default; request sensitive data +explicitly only when needed for an edit. `include_sensitive: true` cannot recover +passwords already masked by the backend. Responses and write inputs may enter HA +traces, so keep them private. + +```yaml +service: pymc_repeater.get_sensor_configuration +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID +response_variable: sensors +``` + +Example complete replacement (adapt the entire section to your installation): + +```yaml +service: pymc_repeater.update_sensor_configuration +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID + config: + enabled: true + poll_interval_seconds: 30 + auto_install_packages: false + definitions: + - name: Example temperature + type: bme280 + enabled: true + auto_install_packages: false + settings: + i2c_address: "0x76" + bus_number: 1 +response_variable: sensor_save +``` + +Saving does not apply a new live sensor manager: the audited backend returns +`restart_required: true`. Restart the Repeater separately when ready. Sensor +configuration writes are not changes to HA's integration-wide polling interval. +There is no forced-read action: the backend creates a new manager and calls all +configured hardware plugins synchronously, with potentially package/network/hardware +side effects and no universal completion bound. Existing cached readings remain +available through normal monitoring. + +## ACL permissions + +`set_acl_permissions` accepts required `identity_name`, `client_pubkey` (exact full +64-character hexadecimal public key), and integer `permissions` in 1–255. Role is +`permissions & 3`: **1 read-only, 2 read-write, 3 admin**. Higher bits are retained; +2 is not admin. Multiples of four/guest role 0 are rejected; use removal instead. +The backend validates the public key and ACL capacity, and determines persistence. +A Repeater's non-guest grants are stored for passwordless future login. Room servers +persist **only admins**; other roles can be accepted until restart. Request and +inspect `persisted`, not just HTTP success. Permissions do not administer identity +private keys, policy groups or token ownership. + +```yaml +service: pymc_repeater.set_acl_permissions +data: + config_entry_id: YOUR_CONFIG_ENTRY_ID + identity_name: repeater + client_pubkey: "REPLACE_WITH_EXACT_64_HEX_PUBLIC_KEY" + permissions: 1 +response_variable: permission_result +``` + +`set_acl_permissions` supports optional responses and refreshes cached telemetry +once after success. Existing `remove_acl_client` retains its legacy `public_key` +field and optional `identity_hash`, adding optional exact `identity_name`. Name +wins over hash; omission of both retains the legacy **every ACL** removal scope. +Removal also supports optional responses and one refresh. Both actions change +real access permissions; fake tests below did not exercise live writes. + +## Verification boundary + +Stdlib tests execute the actual checked-in API/client transport and HA registration +bodies with fake HTTP/HA/schema boundaries: paths, JSON objects, mask preservation, +validation, shared guards, cancellation, byte/request budgets, response behavior and +refresh policy. These are not live Home Assistant/Voluptuous tests, authenticated +Repeater API tests, installer tests, hardware reads, or proof of RF/access outcomes. +No commit, push, release, deployment or live management write was performed. diff --git a/docs/operational-monitoring.md b/docs/operational-monitoring.md index 71cfed1..433c531 100644 --- a/docs/operational-monitoring.md +++ b/docs/operational-monitoring.md @@ -3,27 +3,43 @@ ## 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. 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 +Repeater device. Inventory is discovered on each shared poll; absent radios become +unavailable. `stats.radios` and `radio_profiles` describe configuration, not live +RF health. Read-only settings expose frequency, bandwidth, TX power, spreading +factor, coding rate and preamble only when supplied for that radio. They do not +prove successful hardware application. + +### Named radio telemetry in 1.3.0 + +Newer Repeater dev builds also supply explicitly attributed telemetry. Child +sensors use these named rows only, never a copy or division of parent aggregates: + +- Channel airtime utilization, current airtime and maximum budget +- Cached noise floor in dBm +- One-hour and 24-hour received, duplicate and transmission counts, average RSSI + and average SNR +- 24-hour LBT summary transmission, retry-rate, average-attempt and contention data + +These values arrive through existing full-poll requests, with no per-radio request +loop. Missing newer fields, malformed or ambiguous IDs, removed sources and +nonfinite values remain unavailable. Existing radio aliases map runtime rows to +the established child identity before discovery. Histories remain explicit +response-returning actions, not entity attributes. + +Shared-channel radios report the same airtime ledger; do not add their child +budgets together. Parent utilization on the newer backend is the maximum channel +utilization. Child packet counts are sliding windows, not `total_increasing` +counters. Physical transmissions count fan-out egress, whereas older aggregate +packet counts count packet rows. Null signal averages are not zero-valued RF +measurements. No-sample noise statistics are unavailable, not a measured 0 dBm. +Quiet RF alone is not a failure signal. + +Parent `radio_status` and `radio_problem` still describe aggregate runtime health. +Only error presence is exposed, not exception text. Neither API reachability nor +a configured radio proves successful RF delivery. Single/global configuration +fallback is limited to a sole named default in explicit `single` or +`single_fabric` mode. Alias validation and reversible lifecycle safeguards remain +unchanged. See [the dev API audit](dev-api-1.3.0.md) for provenance and limitations. ## Preserving radio names after a backend ID change diff --git a/docs/plugin-upgrades.md b/docs/plugin-upgrades.md new file mode 100644 index 0000000..0a92141 --- /dev/null +++ b/docs/plugin-upgrades.md @@ -0,0 +1,89 @@ +# Plugin upgrade actions (1.3.0) + +These are explicit actions, not a plugin management API or a new background +scheduler. No automation is created or enabled by installing/updating the integration. +They use the configured API token over the existing trusted-local-network HTTP +connection. They do not install missing plugins, upload wheels, change settings, +or expose runtime documents, repository URLs, release notes, paths, or raw logs. + +| HA action (`pymc_repeater.` prefix) | Fields besides `config_entry_id` | Behavior | +| --- | --- | --- | +| `check_plugin_updates` | optional `plugin_id`, `force_refresh` (default false) | Check one or all installed eligible plugins without writing | +| `update_plugin` | required `plugin_id`, optional `version`, `force_refresh` | Upgrade one installed catalogue plugin; omitted version uses checked latest | +| `update_all_plugins` | `force_refresh` | Check then upgrade each eligible installed plugin sequentially | + +All three are response-only actions: include `response_variable` as a **sibling +of `data`**, not inside it, in a script/automation. Specify `config_entry_id` when +multiple Repeaters are loaded. Results are a dictionary with `results`, `counts` +(`success`, `skipped`, `failure`, `unknown`) and `stopped`. Rows contain bounded +identity/version tokens, outcomes, optional update availability/updated flags and +fixed reason codes, never backend exception text. Check `success` means the check +completed, not that an upgrade happened. Update `success` means the returned plugin +identity and installed target version were confirmed; it does not prove plugin +runtime health. A backend `updated: false` is skipped, not upgraded. + +Eligibility requires an installed inventory row with `source: catalogue` and +repository metadata. Local wheels (even if an ID matches a catalogue entry), +missing repositories, absent plugins and no newer versions are skipped. Missing +catalogue entries/release lookup errors are per-plugin failures. Inventory is +validated before any writes and bounded to 100 unique plugins. Plugin IDs and +optional versions are exact 1–128-character ASCII tokens starting alphanumeric, +then letters/digits/`.`/`_`/`+`/`-`; no whitespace trimming or type coercion. +The backend remains the authority for catalogue approval/version selection: +schema 2 only accepts the approved version; legacy schema 1 resolves releases. +Explicit versions follow backend semantics and are not a general downgrade guarantee. + +Each request has a finite **930-second** HTTP/aiohttp session budget, covering +the plugin IPC's 900-second completion budget plus a margin. A whole batch may +therefore run much longer; no progress stream or installation-log polling is used. +Ordinary reported plugin failures do not prevent subsequent upgrades. Lost +connections, timeouts, malformed/unconfirmed completion, backend HTTP 409 conflict, +502 or 504, or explicit unknown outcomes stop the batch; remaining rows are +`skipped` with `batch_stopped`. **No write is automatically retried.** + +Single/bulk upgrades share a fail-fast per-client/entry admission guard, rather +than queueing. `finally` releases active admission, and cancellation propagates. +An unknown outcome (or cancellation while an operation could be submitted) +quarantines further upgrades on that client. Read-only checks still work. +First inspect/reconcile the installed version and active operation on the Repeater; +only after confirming completion should an operator reload this integration entry +(to clear the client guard) and decide whether to retry. Reloading alone is not +proof the remote operation stopped. The guard cannot arbitrate other HA instances +or external UI/API callers; backend serialization still applies. + +## Optional daily 03:00 automation + +[Complete anonymized YAML](../examples/daily-plugin-upgrades.yaml) can be copied +into `automations.yaml` and explicitly enabled after replacing the entry placeholder. +It uses HA's local timezone and `mode: single`, captures the response correctly, +and notifies on failures/unknowns. It makes fresh metadata checks by explicitly +setting `force_refresh: true` (catalogue/GitHub network access may occur). Scheduling +is owned by that automation, not the Repeater or this integration. It is separate +from existing Repeater firmware update checks. No daily plugin schedule is enabled +by default. A failed admission/preflight action raises an HA error and stops the +script; inspect its trace. Per-plugin failures are instead in the response. + +## Backend trace and verification boundary + +Authority for this addition is the local `openhop_repeater/repeater/web/plugin_endpoints.py` +and `repeater/plugins/{ipc,manager,runtime,catalogue}.py` checkout, not the old +`updates` endpoint comment alone: + +- `GET /api/plugins/` returns top-level `plugins`, with runtime status/provenance +- `GET /api/plugins/updates?id=...&refresh=true|false` returns `id`, + `installedVersion`, `latestVersion`, `updateAvailable`; manager schema-2 + catalogue checks use approved metadata, not arbitrary latest GitHub releases +- `POST /api/plugins/update` takes `id`, optional `version`, `force_refresh`; + successful envelopes contain `plugin`. Enabled updates return `enable()` status + without an `updated` flag; disabled updates include `updated: true`; no-op updates + include `updated: false` +- IPC timeout is 900 seconds and is not cancellation of remote work. The endpoint + reports IPC unknown as HTTP 504 with `outcome: unknown`; manager overlap is 409 +- Nested plugins inherit `/api` authentication in `http_server.py`; the auth tool + accepts the existing `X-API-Key` token. No new credentials/scope are invented + +Tests execute extracted actual client/transport/service/schema bodies using fake +HTTP/HA boundaries. Compilation and JSON/YAML validation supplement them. These +are not live HA schema/registration, authenticated HTTP, catalogue network, +installation/runtime-health, clock/DST or HACS/Hassfest tests. No live plugin writes, +deployment, commit or release are part of this implementation. diff --git a/examples/daily-plugin-upgrades.yaml b/examples/daily-plugin-upgrades.yaml new file mode 100644 index 0000000..555bcbb --- /dev/null +++ b/examples/daily-plugin-upgrades.yaml @@ -0,0 +1,29 @@ +# Optional example for automations.yaml. Nothing installs/enables this for you. +# Replace EXAMPLE_CONFIG_ENTRY_ID with the desired loaded integration entry ID. +# Uses service: for HA 2024.1 compatibility (newer editors label this an action). +- id: openhop_example_daily_plugin_upgrades + alias: "openHop example: update catalogue plugins at 03:00" + description: "Operator-owned schedule in Home Assistant's local timezone" + mode: single + trigger: + - platform: time + at: "03:00:00" + action: + - service: pymc_repeater.update_all_plugins + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + force_refresh: true + response_variable: plugin_upgrade_result + - condition: template + value_template: >- + {{ plugin_upgrade_result.stopped or + plugin_upgrade_result.counts.failure > 0 or + plugin_upgrade_result.counts.unknown > 0 }} + - service: persistent_notification.create + data: + title: "openHop plugin upgrades need review" + message: >- + Plugin outcomes: {{ plugin_upgrade_result.counts }}. + Stopped: {{ plugin_upgrade_result.stopped }}. + Inspect the automation trace and Repeater before retrying an unknown + outcome. Do not blindly reload or retry while an operation may run. diff --git a/examples/openhop_dashboard_scripts.yaml b/examples/openhop_dashboard_scripts.yaml new file mode 100644 index 0000000..5ca720e --- /dev/null +++ b/examples/openhop_dashboard_scripts.yaml @@ -0,0 +1,520 @@ +# Merge these top-level script IDs into scripts.yaml (not under another script: key). +# Replace EXAMPLE_CONFIG_ENTRY_ID with the actual integration entry ID, not REPEATER_SLUG. +# Rename openhop_example_ IDs and matching dashboard targets for each Repeater. +# No schedule is installed. Responses stay in script traces, never sensor attributes. +openhop_example_get_plugin_catalogue: + alias: "openHop example: catalogue check (read-only)" + mode: single + sequence: + - service: pymc_repeater.get_plugin_catalogue + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + force_refresh: false + response_variable: result + - service: persistent_notification.create + data: + title: openHop catalogue + message: "Public catalogue entries returned: {{ [result.get('plugins', []) | count, 100] | min }}. Inspect the action response for approved IDs; no installation was requested." +openhop_example_check_plugin_updates: + alias: "openHop example: plugin update check (read-only)" + mode: single + sequence: + - service: pymc_repeater.check_plugin_updates + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + force_refresh: false + response_variable: result + - service: persistent_notification.create + data: + title: openHop plugin update check + message: >- + Checked: {{ result.get('counts', {}).get('success', 0) | int }}; + updates available: {{ result.get('results', []) | selectattr('update_available', 'defined') | selectattr('update_available', 'eq', true) | list | count }}; + skipped: {{ result.get('counts', {}).get('skipped', 0) | int }}; + failed: {{ result.get('counts', {}).get('failure', 0) | int }}; + unknown: {{ result.get('counts', {}).get('unknown', 0) | int }}. + No upgrade requested. Unknown is not up to date. + +openhop_example_install_catalogue_plugin: + alias: "openHop example: install catalogue plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.install_catalogue_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + force_refresh: false + response_variable: result + - service: persistent_notification.create + data: + title: "openHop install catalogue plugin" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_update_plugin: + alias: "openHop example: update plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.update_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + force_refresh: false + response_variable: result + - service: persistent_notification.create + data: + title: "openHop update plugin" + message: "Success: {{ result.get('counts', {}).get('success', 0) | int }}; skipped: {{ result.get('counts', {}).get('skipped', 0) | int }}; failed: {{ result.get('counts', {}).get('failure', 0) | int }}; unknown: {{ result.get('counts', {}).get('unknown', 0) | int }}; stopped: {{ result.get('stopped', false) == true }}. Reconcile unknown outcomes before further writes; never retry blindly." + +openhop_example_update_all_plugins: + alias: "openHop example: update all plugins (review required)" + mode: single + fields: + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.update_all_plugins + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + force_refresh: false + response_variable: result + - service: persistent_notification.create + data: + title: "openHop update all plugins" + message: "Success: {{ result.get('counts', {}).get('success', 0) | int }}; skipped: {{ result.get('counts', {}).get('skipped', 0) | int }}; failed: {{ result.get('counts', {}).get('failure', 0) | int }}; unknown: {{ result.get('counts', {}).get('unknown', 0) | int }}; stopped: {{ result.get('stopped', false) == true }}. Reconcile unknown outcomes before further writes; never retry blindly." + +openhop_example_enable_plugin: + alias: "openHop example: enable plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.enable_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop enable plugin" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_disable_plugin: + alias: "openHop example: disable plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.disable_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop disable plugin" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_start_plugin: + alias: "openHop example: start plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.start_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop start plugin" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_stop_plugin: + alias: "openHop example: stop plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.stop_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop stop plugin" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_restart_plugin: + alias: "openHop example: restart plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.restart_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop restart plugin" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_uninstall_plugin: + alias: "openHop example: uninstall plugin (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.uninstall_plugin + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + delete_data: false + response_variable: result + - service: persistent_notification.create + data: + title: "openHop uninstall plugin" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_update_plugin_settings: + alias: "openHop example: update plugin settings (review required)" + mode: single + fields: + plugin_id: + name: Plugin id + required: true + selector: + text: {} + config: + name: Config + required: true + selector: + object: {} + restart: + name: Restart + required: true + default: false + selector: + boolean: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.update_plugin_settings + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + plugin_id: "{{ plugin_id }}" + config: "{{ config }}" + restart: "{{ restart }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop update plugin settings" + message: "Outcome: {{ result.get('outcome', 'not reported') if result.get('outcome') in ['success', 'failure', 'skipped', 'unknown'] else 'not reported' }}. Reconcile actual Repeater state before retrying failures or unknown outcomes." + +openhop_example_update_sensor_configuration: + alias: "openHop example: update sensor configuration (review required)" + mode: single + fields: + config: + name: Config + required: true + selector: + object: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.update_sensor_configuration + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + config: "{{ config }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop update sensor configuration" + message: "Saved: {{ result.get('saved', false) == true }}; restart required: {{ result.get('restart_required', false) == true }}. No restart was requested." + +openhop_example_set_acl_permissions: + alias: "openHop example: set acl permissions (review required)" + mode: single + fields: + identity_name: + name: Identity name + required: true + selector: + text: {} + client_pubkey: + name: Client pubkey + required: true + selector: + text: {} + permissions: + name: Permissions + required: true + selector: + number: + min: 1 + max: 255 + step: 1 + mode: box + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true }}" + - service: pymc_repeater.set_acl_permissions + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + identity_name: "{{ identity_name }}" + client_pubkey: "{{ client_pubkey }}" + permissions: "{{ permissions }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop set acl permissions" + message: "Action returned. Persistence: {{ 'yes' if result.get('persisted') == true else ('no' if result.get('persisted') == false else 'not reported') }}. Verify the target ACL in the Repeater; no client key is shown here." + +# Identity-scoped only: the native service also supports every-ACL removal. +openhop_example_remove_acl_client: + alias: "openHop example: remove acl client (review required)" + mode: single + fields: + public_key: + name: Public key + required: true + selector: + text: {} + identity_name: + name: Identity name + required: true + selector: + text: {} + confirmed: + name: I reviewed the target and consequences + required: true + default: false + selector: + boolean: {} + sequence: + - condition: template + value_template: "{{ confirmed | default(false) == true and identity_name is defined and identity_name is string and identity_name | trim | length > 0 }}" + - service: pymc_repeater.remove_acl_client + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + public_key: "{{ public_key }}" + identity_name: "{{ identity_name }}" + response_variable: result + - service: persistent_notification.create + data: + title: "openHop remove acl client" + message: "Action returned. Persistence: {{ 'yes' if result.get('persisted') == true else ('no' if result.get('persisted') == false else 'not reported') }}. Verify the target ACL in the Repeater; no client key is shown here." + +openhop_example_get_sensor_types: + alias: "openHop example: get sensor types (read-only)" + mode: single + sequence: + - service: pymc_repeater.get_sensor_types + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + response_variable: result + - service: persistent_notification.create + data: + title: "openHop get sensor types" + message: "Read-only query completed. Inspect the private script trace for the response; no hardware read or refresh was forced. This notification does not publish raw diagnostic data." + +openhop_example_get_radio_packet_rates: + alias: "openHop example: get radio packet rates (read-only)" + mode: single + sequence: + - service: pymc_repeater.get_radio_packet_rates + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + hours: 24 + bucket_seconds: 300 + response_variable: result + - service: persistent_notification.create + data: + title: "openHop get radio packet rates" + message: "Read-only query completed. Inspect the private script trace for the response; no hardware read or refresh was forced. This notification does not publish raw diagnostic data." + +openhop_example_get_noise_floor_stats: + alias: "openHop example: get noise floor stats (read-only)" + mode: single + sequence: + - service: pymc_repeater.get_noise_floor_stats + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + hours: 24 + response_variable: result + - service: persistent_notification.create + data: + title: "openHop get noise floor stats" + message: "Read-only query completed. Inspect the private script trace for the response; no hardware read or refresh was forced. This notification does not publish raw diagnostic data." + +openhop_example_get_crc_error_count: + alias: "openHop example: get crc error count (read-only)" + mode: single + sequence: + - service: pymc_repeater.get_crc_error_count + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + hours: 24 + response_variable: result + - service: persistent_notification.create + data: + title: "openHop get crc error count" + message: "Read-only query completed. Inspect the private script trace for the response; no hardware read or refresh was forced. This notification does not publish raw diagnostic data." + +openhop_example_get_companion_stats: + alias: "openHop example: get companion stats (read-only)" + mode: single + sequence: + - service: pymc_repeater.get_companion_stats + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + type: packets + response_variable: result + - service: persistent_notification.create + data: + title: "openHop get companion stats" + message: "Read-only query completed. Inspect the private script trace for the response; no hardware read or refresh was forced. This notification does not publish raw diagnostic data." + +openhop_example_get_lbt_diagnostics: + alias: "openHop example: get lbt diagnostics (read-only)" + mode: single + sequence: + - service: pymc_repeater.get_lbt_diagnostics + data: + config_entry_id: EXAMPLE_CONFIG_ENTRY_ID + hours: 24 + bucket_seconds: 300 + severe_attempt_threshold: 3 + response_variable: result + - service: persistent_notification.create + data: + title: "openHop get lbt diagnostics" + message: "Read-only query completed. Inspect the private script trace for the response; no hardware read or refresh was forced. This notification does not publish raw diagnostic data." diff --git a/tests/test_comprehensive_dashboard.py b/tests/test_comprehensive_dashboard.py index a5c01a5..e003b6a 100644 --- a/tests/test_comprehensive_dashboard.py +++ b/tests/test_comprehensive_dashboard.py @@ -17,6 +17,29 @@ def test_monitoring_features_are_in_comprehensive_view(self): self.assertIn('REPEATER_SLUG', text) self.assertIn('exact entity IDs', text) + def test_radio_130_rows_use_explicit_independent_child_examples(self): + text = (ROOT / 'dashboards/openhop_repeater_dashboard.yaml').read_text() + self.assertIn('title: Radio Telemetry · Example Radio', text) + self.assertIn('Replace each full example ID with exact entity IDs', text) + suffixes = [ + 'channel_utilization', 'current_channel_airtime', 'maximum_channel_airtime', + 'cached_noise_floor', + *[f'{field}_{window}' for window in ('1h', '24h') + for field in ('packets_received', 'duplicate_packets', 'physical_transmissions', + 'average_rssi', 'average_snr')], + *[f'lbt_{field}_24h' for field in ( + 'transmissions', 'retry_packets', 'retry_rate', 'average_attempts', + 'p95_attempts', 'maximum_attempts', 'failed_transmissions', + 'busy_channel_events', 'severe_contention_count', 'severe_contention_rate')], + ] + for suffix in suffixes: + self.assertIn('sensor.EXAMPLE_RADIO_' + suffix, text) + self.assertIn('airtime budgets must not be summed', text) + self.assertIn('Packet counts are sliding windows', text) + overview = text.split('heading: Recent trends')[0] + self.assertNotIn('EXAMPLE_RADIO', overview) + self.assertNotIn('custom:', 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) diff --git a/tests/test_dashboard_management.py b/tests/test_dashboard_management.py new file mode 100644 index 0000000..35e8f79 --- /dev/null +++ b/tests/test_dashboard_management.py @@ -0,0 +1,153 @@ +"""Bundled management workflow contracts; no HA or network access.""" +from pathlib import Path +import unittest + +try: + import yaml +except ImportError: + yaml = None + +try: + from jinja2 import Environment +except ImportError: + Environment = None + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "examples/openhop_dashboard_scripts.yaml" + + +def walk(value): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from walk(child) + elif isinstance(value, list): + for child in value: + yield from walk(child) + + +class DashboardManagementTests(unittest.TestCase): + def test_action_buttons_use_compact_text_layout(self): + for name in ('openhop_repeater_dashboard.yaml', 'openhop_operations.yaml'): + text = (ROOT / 'dashboards' / name).read_text() + with self.subTest(view=name): + self.assertGreater(text.count('type: button'), 0) + self.assertEqual(text.count('type: button'), text.count('show_icon: false')) + + def test_read_only_checks_have_companion_scripts(self): + self.assertTrue(SCRIPTS.exists(), "response-only dashboard calls need companion scripts") + text = SCRIPTS.read_text() + dashboard = (ROOT / "dashboards/openhop_repeater_dashboard.yaml").read_text() + for action in ("get_plugin_catalogue", "check_plugin_updates"): + self.assertIn("pymc_repeater." + action, text) + self.assertIn("script.openhop_example_" + action, dashboard) + self.assertIn("EXAMPLE_CONFIG_ENTRY_ID", text) + self.assertIn("response_variable:", text) + + @unittest.skipIf(yaml is None or Environment is None, 'PyYAML/Jinja optional') + def test_acl_removal_fails_closed_without_a_named_identity(self): + assert yaml is not None and Environment is not None + scripts = yaml.safe_load(SCRIPTS.read_text()) + guard = scripts['openhop_example_remove_acl_client']['sequence'][0]['value_template'] + template = Environment().from_string(guard) + for identity in ('', None, ' '): + self.assertEqual(template.render(confirmed=True, identity_name=identity).strip(), 'False') + self.assertEqual(template.render(confirmed=True, identity_name='Example room').strip(), 'True') + self.assertEqual(template.render(confirmed=False, identity_name='Example room').strip(), 'False') + + def test_private_reads_and_installation_are_documented_not_control_entities(self): + guide = (ROOT / 'docs/dashboard-management.md').read_text() + readme = (ROOT / 'README.md').read_text() + for token in ('get_plugin_settings', 'get_sensor_configuration', 'include_sensitive', + 'response_variable', '_original_name', '*****', 'persisted', + 'EXAMPLE_CONFIG_ENTRY_ID', 'REPEATER_SLUG', 'scripts.yaml'): + self.assertIn(token, guide) + self.assertIn('entity prefix, not an entry ID', readme) + self.assertIn('examples/openhop_dashboard_scripts.yaml', readme) + scripts = SCRIPTS.read_text() + for forbidden in ('input_text.', 'input_select.', 'sensor.set', 'pymc_repeater.get_logs'): + self.assertNotIn(forbidden, scripts) + + @unittest.skipIf(yaml is None or Environment is None, 'PyYAML/Jinja optional') + def test_notifications_render_bounded_summaries_not_private_responses(self): + assert yaml is not None and Environment is not None + scripts = yaml.safe_load(SCRIPTS.read_text()) + result = {'config': {'password': 'NEVER_EXPOSE'}, 'client_pubkey': 'NEVER_EXPOSE', + 'logs': 'NEVER_EXPOSE', 'plugins': [], 'results': [], + 'counts': {'success': 1, 'failure': 2, 'unknown': 3, 'skipped': 4}, + 'stopped': True} + for node in walk(scripts): + if node.get('service') == 'persistent_notification.create': + message = Environment().from_string(node['data']['message']).render(result=result) + self.assertNotIn('NEVER_EXPOSE', message) + self.assertLess(len(message), 600) + + @unittest.skipIf(yaml is None, "PyYAML optional; run /usr/bin/python3 for parsed contracts") + def test_editor_workflows_and_dashboard_calls_follow_service_contracts(self): + assert yaml is not None + scripts = yaml.safe_load(SCRIPTS.read_text()) + services = yaml.safe_load((ROOT / 'custom_components/pymc_repeater/services.yaml').read_text()) + actions = {'install_catalogue_plugin', 'update_plugin', 'update_all_plugins', + 'enable_plugin', 'disable_plugin', 'start_plugin', 'stop_plugin', + 'restart_plugin', 'uninstall_plugin', 'update_plugin_settings', + 'update_sensor_configuration', 'set_acl_permissions', 'remove_acl_client', + 'get_sensor_types', 'get_radio_packet_rates', 'get_noise_floor_stats', + 'get_crc_error_count', 'get_companion_stats', 'get_lbt_diagnostics'} + calls = {n['service'].split('.')[1]: n for n in walk(scripts) + if str(n.get('service', '')).startswith('pymc_repeater.')} + self.assertTrue(actions <= calls.keys(), f'missing usable wrappers: {actions - calls.keys()}') + for name, call in calls.items(): + with self.subTest(action=name): + self.assertEqual(call['response_variable'], 'result') + self.assertNotIn('response_variable', call['data']) + self.assertEqual(call['data']['config_entry_id'], 'EXAMPLE_CONFIG_ENTRY_ID') + fields = services[name]['fields'] + self.assertTrue(call['data'].keys() <= fields.keys()) + self.assertTrue({k for k, v in fields.items() if v.get('required')} <= call['data'].keys()) + for field, value in call['data'].items(): + if field == 'config_entry_id' or isinstance(value, str) and '{{' in value: + continue + metadata = fields[field] + if 'default' in metadata: + self.assertEqual(value, metadata['default']) + self.assertIs(type(value), type(metadata['default'])) + selector = metadata.get('selector', {}) + if 'number' in selector: + self.assertIs(type(value), int) + self.assertGreaterEqual(value, selector['number']['min']) + self.assertLessEqual(value, selector['number']['max']) + self.assertEqual(scripts['openhop_example_set_acl_permissions']['fields']['permissions']['selector'], + services['set_acl_permissions']['fields']['permissions']['selector']) + self.assertNotIn('radio_id', calls['get_lbt_diagnostics']['data']) + self.assertIs(calls['uninstall_plugin']['data']['delete_data'], False) + self.assertIn('identity_name', calls['remove_acl_client']['data']) + for name in actions: + if not name.startswith('get_'): + script = scripts['openhop_example_' + name] + self.assertEqual(script['fields']['confirmed']['default'], False) + self.assertIn('confirmed', script['sequence'][0]['value_template']) + for name in ('update_plugin_settings', 'update_sensor_configuration'): + script = scripts['openhop_example_' + name] + self.assertTrue(script['fields']['config']['required']) + self.assertIn('object', script['fields']['config']['selector']) + self.assertNotIn('default', script['fields']['config']) + for path in ('dashboards/openhop_repeater_dashboard.yaml', 'dashboards/openhop_operations.yaml'): + view = yaml.safe_load((ROOT / path).read_text()) + self.assertNotIn('views', view) + for n in walk(view): + action = n.get('perform_action', '') + self.assertFalse(action.startswith('pymc_repeater.'), action) + if action.startswith('script.'): + target = action.split('.', 1)[1] + self.assertIn(target, scripts) + if 'confirmed' in scripts[target].get('fields', {}): + self.assertIn('confirmation', n) + self.assertIs(n['data']['confirmed'], True) + self.assertNotIn('config', n.get('data', {})) + self.assertNotIn('client_pubkey', n.get('data', {})) + self.assertNotIn('pymc_repeater.get_plugin_settings', SCRIPTS.read_text()) + self.assertNotIn('pymc_repeater.get_sensor_configuration', SCRIPTS.read_text()) + for n in walk(scripts): + if n.get('service') == 'persistent_notification.create': + for unsafe in ('tojson', 'result }}', 'client_pubkey', "get('config'", "get('types'"): + self.assertNotIn(unsafe, n['data']['message']) diff --git a/tests/test_dev_130_actions.py b/tests/test_dev_130_actions.py new file mode 100644 index 0000000..647af8b --- /dev/null +++ b/tests/test_dev_130_actions.py @@ -0,0 +1,345 @@ +"""Execute 1.3.0 client/actions with fake HTTP and HA boundaries. + +The small schema boundary executes validators/defaults; it is not a substitute +for real Voluptuous/Home Assistant registration or authenticated RF tests. +""" +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" +MISSING = object() + + +def load(filename, ns, predicate): + tree = ast.parse((COMPONENT / filename).read_text()) + nodes: list[ast.stmt] = [ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0)] + nodes += [node for node in tree.body if predicate(node)] + exec(compile(ast.fix_missing_locations(ast.Module(body=nodes, type_ignores=[])), filename, "exec"), ns) + return ns + + +class Invalid(ValueError): + pass + + +class Field: + def __init__(self, key, default=MISSING, required=False): + self.key, self.default, self.required = key, default, required + + +class SchemaBoundary: + Invalid = Invalid + + def Optional(self, key, default=MISSING): + return Field(key, default) + + def Required(self, key): + return Field(key, required=True) + + def Schema(self, fields): + def validate(data): + result = {} + if set(data) - {field.key for field in fields}: + raise Invalid("Unknown field") + for field, validator in fields.items(): + value = data.get(field.key, field.default) + if value is MISSING: + if field.required: + raise Invalid("Required field") + continue + if isinstance(validator, type): + if not isinstance(value, validator): + raise Invalid("Incorrect type") + result[field.key] = value + else: + result[field.key] = validator(value) + return result + return validate + + def All(self, *validators): + def validate(value): + for validator in validators: + if isinstance(validator, type): + if not isinstance(value, validator): + raise Invalid("Incorrect type") + else: + value = validator(value) + return value + return validate + + def Coerce(self, kind): + def validate(value): + try: + return kind(value) + except (ValueError, TypeError, OverflowError) as err: + raise Invalid(str(err)) from err + return validate + + def Range(self, min=None, max=None): + def validate(value): + if (min is not None and not value >= min) or (max is not None and not value <= max): + raise Invalid("Out of range") + return value + return validate + + def In(self, choices): + def validate(value): + if value not in choices: + raise Invalid("Invalid choice") + return value + return validate + + def Length(self, min=0): + def validate(value): + if len(value) < min: + raise Invalid("Too short") + return value + return validate + + # Unrelated action validators need construction, not execution here. + def Any(self, *args): + return lambda value: value + + def Match(self, *args): + return lambda value: value + + +class ActionTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.ns = load("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")) + self.api = object.__new__(self.ns["PyMCRepeaterApiClient"]) + self.request = self.api._async_request_json = AsyncMock(return_value={"success": True, "data": {}}) + self.error = self.ns["PyMCRepeaterApiError"] + self.ns.update(vol=SchemaBoundary(), DOMAIN="pymc_repeater", HomeAssistantError=RuntimeError, + SupportsResponse=SimpleNamespace(ONLY="only", OPTIONAL="optional")) + load("__init__.py", self.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"}) + self.registry = {} + self.refresh = AsyncMock() + self.hass = SimpleNamespace(data={"pymc_repeater": {"example": {"api": self.api, + "coordinator": SimpleNamespace(async_request_refresh=self.refresh)}}}, + services=SimpleNamespace(has_service=lambda *a: False, + async_register=lambda domain, name, handler, **kw: + self.registry.update({name: (handler, kw)}))) + await self.ns["_async_register_services"](self.hass) + + async def action(self, name, **data): + handler, metadata = self.registry[name] + data = metadata["schema"](data) + return await handler(SimpleNamespace(data=data, return_response=True)) + + async def test_default_queries_and_response_shapes_no_refresh(self): + cases = [ + ("get_radio_packet_rates", "/api/radio_packet_rates", {"hours": 24}), + ("get_noise_floor_stats", "/api/noise_floor_stats", {"hours": 24}), + ("get_crc_error_count", "/api/crc_error_count", {"hours": 24}), + ("get_companion_stats", "/api/companion/stats", {"type": "packets"}), + ("get_lbt_diagnostics", "/api/lbt_diagnostics", {"hours": 24}), + ] + for name, path, params in cases: + self.assertEqual(self.registry[name][1]["supports_response"], "only") + for data in ({"example": 1}, [1, 2], None): + # Noise retains its established stats dictionary contract. + if name == "get_noise_floor_stats" and not isinstance(data, dict): + continue + for wrapped in (False, True): + self.request.return_value = {"success": True, "data": data} if wrapped else data + if not wrapped and not isinstance(data, dict): + continue + result = await self.action(name) + self.assertEqual(result, data if isinstance(data, dict) else {"result": data}) + self.assertEqual(self.request.call_args.args, ("GET", path)) + self.assertEqual(self.request.call_args.kwargs["params"], params) + self.assertEqual(self.request.call_args.kwargs["auth"], "api_token") + self.refresh.assert_not_called() + + async def test_explicit_queries_and_limits(self): + cases = [ + ("get_radio_packet_rates", dict(hours=168, bucket_seconds=86400)), + ("get_noise_floor_stats", dict(hours=1, radio_id="radio:west")), + ("get_crc_error_count", dict(hours=168, radio_id="radio:west")), + ("get_lbt_diagnostics", dict(hours=1, bucket_seconds=3600, severe_attempt_threshold=16)), + ] + for name, data in cases: + await self.action(name, **data) + self.assertEqual(self.request.call_args.kwargs["params"], data) + for kind in ("core", "radio", "packets"): + data = dict(type=kind, companion_name="Example Companion") + await self.action("get_companion_stats", **data) + self.assertEqual(self.request.call_args.kwargs["params"], data) + self.refresh.assert_not_called() + + async def test_invalid_queries_rejected_before_http_client_and_schema(self): + for name, key, minimum, maximum in [ + ("get_radio_packet_rates", "hours", 1, 168), + ("get_radio_packet_rates", "bucket_seconds", 60, 86400), + ("get_noise_floor_stats", "hours", 1, 168), + ("get_crc_error_count", "hours", 1, 168), + ("get_lbt_diagnostics", "hours", 1, 168), + ("get_lbt_diagnostics", "bucket_seconds", 60, 3600), + ("get_lbt_diagnostics", "severe_attempt_threshold", 2, 16), + ]: + for value in (minimum - 1, maximum + 1, float("nan"), float("inf"), -float("inf"), True, minimum + .5): + with self.subTest(name=name, key=key, value=value): + with self.assertRaises(Invalid): + await self.action(name, **{key: value}) + with self.assertRaises(self.error): + await getattr(self.api, "async_" + name)(**{key: value}) + for name in ("get_noise_floor_stats", "get_crc_error_count"): + for value in ("", " ", " radio0", "radio0 ", 1, False): + with self.assertRaises(Invalid): + await self.action(name, radio_id=value) + with self.assertRaises(self.error): + await getattr(self.api, "async_" + name)(radio_id=value) + with self.assertRaises(Invalid): + await self.action("get_lbt_diagnostics", radio_id="radio0") + with self.assertRaises(Invalid): + await self.action("get_companion_stats", type="wrong") + with self.assertRaises(self.error): + await self.api.async_get_companion_stats(type="wrong") + self.request.assert_not_called() + self.refresh.assert_not_called() + + async def test_neighbor_defaults_and_split_filter_preserve_payload(self): + for wrapped in (False, True): + for data, query in [({"rows": [], "count": 0}, {}), + ({"buckets": [{"radio_id": "r:1", "n": 2}], "by_radio": True}, + dict(bucket_seconds=300, radio_id="r:1", by_radio=True)), + ({"rows": []}, dict(by_radio=False))]: + self.request.return_value = {"success": True, "data": data} if wrapped else data + self.assertEqual(await self.action("get_neighbor_link_history", peer_hash="AB", path_hash_size=1, **query), data) + expected = dict(peer_hash="AB", path_hash_size=1, hours=24, limit=1000, **query) + if "by_radio" in expected: + expected["by_radio"] = str(expected["by_radio"]).lower() + self.assertEqual(self.request.call_args.kwargs["params"], expected) + self.request.reset_mock() + for query in (dict(by_radio=True), dict(by_radio="true"), dict(radio_id=""), dict(bucket_seconds=86401)): + with self.assertRaises((Invalid, RuntimeError)): + await self.action("get_neighbor_link_history", peer_hash="AB", path_hash_size=1, **query) + self.request.assert_not_called() + self.refresh.assert_not_called() + + async def test_neighbor_schema_does_not_truncate_or_coerce_invalid_numbers(self): + for field, value in ( + ("hours", 168.9), ("path_hash_size", True), ("limit", 5000.9), + ("hours", float("nan")), ("limit", float("inf")), + ): + with self.subTest(field=field, value=value): + data = {"peer_hash": "AB", "path_hash_size": 1, field: value} + with self.assertRaises(Invalid): + await self.action("get_neighbor_link_history", **data) + self.request.assert_not_called() + self.refresh.assert_not_called() + + async def test_noise_empty_positive_and_legacy_preserve_other_fields(self): + for stats in ({"measurement_count": 0, "avg_noise_floor": 0, "min_noise_floor": 0}, + {"measurement_count": 2, "avg_noise_floor": -112}, + {"avg_noise_floor": -110}, {"avg_noise_floor": 0}): + original = dict(stats) + for wrapped in (False, True): + payload = {"stats": stats, "hours": 24} + self.request.return_value = {"success": True, "data": payload} if wrapped else payload + expected = dict(stats) + if stats.get("measurement_count") == 0: + expected["avg_noise_floor"] = None + self.assertEqual(await self.action("get_noise_floor_stats"), expected) + self.assertEqual(stats, original) + + async def test_backend_and_transport_errors_become_ha_errors_without_refresh(self): + for name in ("get_radio_packet_rates", "get_noise_floor_stats", "get_crc_error_count", + "get_companion_stats", "get_lbt_diagnostics", "send_advert"): + self.request.return_value = {"success": False, "error": "Example backend failure"} + with self.assertRaisesRegex(RuntimeError, "Example backend failure"): + await self.action(name) + for error_name in ("PyMCRepeaterCannotConnect", "PyMCRepeaterAuthenticationError"): + self.request.side_effect = self.ns[error_name]("Example failure") + with self.assertRaisesRegex(RuntimeError, "Example failure"): + await self.action(name) + self.request.side_effect = None + self.refresh.assert_not_called() + + async def test_advert_budget_body_and_no_refresh(self): + for mode in ("flood", "direct"): + await self.action("send_advert", mode=mode) + self.assertEqual(self.request.call_args.args, ("POST", "/api/send_advert")) + self.assertEqual(self.request.call_args.kwargs["json_body"], {"mode": mode}) + self.assertEqual(self.request.call_args.kwargs["timeout_seconds"], 15) + self.refresh.assert_not_called() + + async def test_real_http_transport_budget_auth_and_errors(self): + 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, calls = [], [] + response = SimpleNamespace(status=200, payload={"success": True, "data": {"example": 1}}) + + class Response: + @property + def status(self): + return response.status + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def json(self, **kwargs): + return response.payload + + async def text(self): + return "Example HTTP error" + + def request(*args, **kwargs): + 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) + for name in ("get_radio_packet_rates", "get_noise_floor_stats", "get_crc_error_count", + "get_companion_stats", "get_lbt_diagnostics", "send_advert"): + self.assertEqual(await self.action(name), None if name == "send_advert" else {"example": 1}) + self.assertEqual(budgets[-1], 15 if name == "send_advert" else 10) + self.assertEqual(calls[-1][1]["headers"]["X-API-Key"], "example-token") + for status in (401, 403, 500): + response.status = status + with self.assertRaises(RuntimeError): + await self.action(name) + response.status = 200 + self.refresh.assert_not_called() + + async def test_polling_endpoint_set_unchanged_and_fatal_distinction(self): + calls = [] + for name in dir(self.api): + if name.startswith("async_get_"): + async def empty(*args, _name=name, **kwargs): + calls.append((_name, kwargs)) + return {} + setattr(self.api, name, empty) + result = await self.api.async_fetch_all() + self.assertNotIn("radio_packet_rates", result) + self.assertNotIn("companion_stats", result) + self.assertTrue(all(not kwargs.get("radio_id") for _, kwargs in calls)) + for error_name in ("PyMCRepeaterApiError", "PyMCRepeaterCannotConnect", "PyMCRepeaterAuthenticationError"): + self.api.async_get_noise_floor_stats = AsyncMock(side_effect=self.ns[error_name]("Example failure")) + if error_name == "PyMCRepeaterApiError": + self.assertEqual((await self.api.async_fetch_all())["noise_floor_stats"], {"error": "Example failure"}) + else: + with self.assertRaises(self.ns[error_name]): + await self.api.async_fetch_all() diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py index d0f5b5a..5e654a8 100644 --- a/tests/test_dev_api_alignment.py +++ b/tests/test_dev_api_alignment.py @@ -121,8 +121,8 @@ def test_home_assistant_services_cover_new_routes_and_bounds(self) -> None: 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("_bounded_query_integer(1, 3)", setup) + self.assertIn("_bounded_query_integer(1, 168)", setup) self.assertIn("vol.Range(min=1, max=5000)", setup) def test_new_diagnostics_are_exposed_as_sensors(self) -> None: @@ -365,8 +365,8 @@ def test_release_metadata_and_changelog_are_v1_2_1(self) -> None: encoding="utf-8" ) - self.assertEqual(manifest["version"], "1.2.1") - self.assertIn("## 1.2.1", changelog) + self.assertEqual(manifest["version"], "1.3.0") + self.assertIn("## 1.3.0", changelog) self.assertIn("[#16](https://github.com/openhop-dev/openHop-HA-Integration/issues/16)", changelog) self.assertIn("## 1.1.6", changelog) self.assertIn("## Unreleased", changelog) diff --git a/tests/test_installed_api_behavior.py b/tests/test_installed_api_behavior.py index 35bc79d..76b11f9 100644 --- a/tests/test_installed_api_behavior.py +++ b/tests/test_installed_api_behavior.py @@ -138,14 +138,23 @@ def test_metadata_matches_new_entities_and_history_bounds(self): 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()) + registration = next( + node for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "async_register" + and len(node.args) > 1 + and isinstance(node.args[1], ast.Name) + and node.args[1].id == "SERVICE_GET_NEIGHBOR_LINK_HISTORY" + ) bucket_schema = None - for node in ast.walk(tree): + for node in ast.walk(registration): 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))") + self.assertEqual(bucket_schema, "_bounded_query_integer(60, 86400)") 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) diff --git a/tests/test_management_actions.py b/tests/test_management_actions.py new file mode 100644 index 0000000..1c445de --- /dev/null +++ b/tests/test_management_actions.py @@ -0,0 +1,320 @@ +"""Execute checked-in management actions with fake HTTP/HA boundaries only.""" +import asyncio +import json +import unittest +import test_dev_130_actions as boundary + + +class ManagementTests(unittest.IsolatedAsyncioTestCase): + asyncSetUp = boundary.ActionTests.asyncSetUp + action = boundary.ActionTests.action + + async def test_lifecycle_paths_metadata_and_preserved_uninstall_default(self): + self.assertIn("enable_plugin", self.registry) + for operation in ("enable", "disable", "start", "stop", "restart", "uninstall"): + reply = {"id": "openhop.example", "version": "1.0", "enabled": True, + "state": "RUNNING", "uninstalled": True, "data_deleted": False, + "config": {"token": "SECRET"}, "logs": "SECRET"} + if operation == "disable": + reply["enabled"] = False + if operation in ("disable", "stop"): + reply["state"] = "STOPPED" + self.request.return_value = {"success": True, "plugin": reply} if operation != "uninstall" else {"success": True, **reply} + result = await self.action(operation + "_plugin", plugin_id="openhop.example") + self.assertEqual(result["outcome"], "success") + self.assertNotIn("SECRET", str(result)) + self.assertEqual(self.request.call_args.args, ("POST", "/api/plugins/" + operation)) + expected = {"id": "openhop.example"} + if operation == "uninstall": + expected["delete_data"] = False + self.assertEqual(self.request.call_args.kwargs["json_body"], expected) + self.assertEqual(self.request.call_args.kwargs["timeout_seconds"], 930) + self.refresh.assert_not_called() + + async def test_catalogue_list_and_install_are_explicit_bounded_actions(self): + self.assertIn("get_plugin_catalogue", self.registry) + self.request.return_value = {"success": True, "schema": 2, "plugins": [ + {"id": "openhop.example", "name": "Example", "description": "Example service", + "latestVersion": "2.0", "installed": False, "updateAvailable": False, + "repository": "SECRET", "releasesError": "SECRET", "settings": {"token": "SECRET"}}]} + result = await self.action("get_plugin_catalogue", force_refresh=True) + self.assertEqual(result["plugins"][0]["id"], "openhop.example") + self.assertNotIn("SECRET", str(result)) + self.assertEqual(self.request.call_args.kwargs["params"], {"refresh": "true"}) + self.request.return_value = {"success": True, "plugin": {"id": "openhop.example", "version": "2.0"}} + self.assertEqual((await self.action("install_catalogue_plugin", plugin_id="openhop.example", version="2.0"))["outcome"], "success") + self.assertEqual(self.request.call_args.args, ("POST", "/api/plugins/catalogue_install")) + self.assertEqual(self.request.call_args.kwargs["json_body"], {"id": "openhop.example", "version": "2.0", "force_refresh": False}) + self.refresh.assert_not_called() + + async def test_settings_json_and_sensitive_read_are_not_normal_metadata(self): + self.assertIn("get_plugin_settings", self.registry) + self.ns["json"] = json + self.request.return_value = {"success": True, "id": "openhop.example", "path": "SECRET path", + "config": {"nested": {"api_token": "SECRET"}, "port": 8090}} + result = await self.action("get_plugin_settings", plugin_id="openhop.example") + self.assertEqual(result["config"]["nested"]["api_token"], "[REDACTED]") + self.assertNotIn("SECRET", str(result)) + result = await self.action("get_plugin_settings", plugin_id="openhop.example", include_sensitive=True) + self.assertEqual(result["config"]["nested"]["api_token"], "SECRET") + self.assertNotIn("path", result) + self.request.return_value = {"success": True, "id": "openhop.example", "config": {"token": "SECRET"}, "restarted": True} + result = await self.action("update_plugin_settings", plugin_id="openhop.example", config={"token": "new"}, restart=True) + self.assertNotIn("SECRET", str(result)) + self.assertTrue(result["restarted"]) + self.assertEqual(self.request.call_args.kwargs["json_body"], {"id": "openhop.example", "config": {"token": "new"}, "restart": True}) + self.request.reset_mock() + for value in ([], "{}", {1: "x"}, {"x": float("nan")}, {"x": object()}, {"x": "[REDACTED]"}, {"x": "a" * (256 * 1024)}): + with self.subTest(value=type(value)): + with self.assertRaises(boundary.Invalid): + await self.action("update_plugin_settings", plugin_id="openhop.example", config=value) + with self.assertRaises(self.error): + await self.api.async_update_plugin_settings(plugin_id="openhop.example", config=value) + self.request.assert_not_called() + self.refresh.assert_not_called() + + async def test_sensor_full_replacement_preserves_masks_and_origins(self): + self.assertIn("get_sensor_configuration", self.registry) + config = {"enabled": True, "poll_interval_seconds": 30.0, "auto_install_packages": False, + "definitions": [{"name": "Modem", "type": "openhop_modem", "_original_name": "Old modem", + "settings": {"password": "*****", "host": "example.invalid"}}]} + self.request.return_value = {"success": True, "data": config} + self.assertEqual(await self.action("get_sensor_configuration"), config) + self.assertEqual(self.request.call_args.args, ("GET", "/api/sensors_config")) + self.request.return_value = {"success": True, "data": {"saved": True, "restart_required": True, "message": "SECRET"}} + result = await self.action("update_sensor_configuration", config=config) + self.assertEqual(result, {"saved": True, "restart_required": True}) + self.assertEqual(self.request.call_args.args, ("POST", "/api/sensors_config_update")) + self.assertEqual(self.request.call_args.kwargs["json_body"], config) + self.assertEqual(config["definitions"][0]["settings"]["password"], "*****") + self.request.return_value = {"success": True, "data": {"types": [{"type": "bme280", "settings": []}]}} + self.assertEqual((await self.action("get_sensor_types"))["types"][0]["type"], "bme280") + self.request.reset_mock() + for malformed in ({}, {**config, "definitions": "bad"}, {**config, "poll_interval_seconds": float("inf")}, + {**config, "definitions": config["definitions"] * 2}): + with self.assertRaises(boundary.Invalid): + await self.action("update_sensor_configuration", config=malformed) + with self.assertRaises(self.error): + await self.api.async_update_sensor_configuration(config=malformed) + self.request.assert_not_called() + self.refresh.assert_not_called() + + async def test_acl_permissions_bounds_role_bytes_and_legacy_named_removal(self): + self.assertIn("set_acl_permissions", self.registry) + key = "AB" * 32 + self.request.return_value = {"success": True, "data": {"permissions_value": 255, "persisted": False}} + result = await self.action("set_acl_permissions", identity_name="Example room", client_pubkey=key, permissions=255) + self.assertFalse(result["persisted"]) + self.assertEqual(self.request.call_args.args, ("POST", "/api/acl_set_permissions")) + self.assertEqual(self.request.call_args.kwargs["json_body"], {"identity_name": "Example room", "client_pubkey": key, "permissions": 255}) + self.assertEqual(self.refresh.call_count, 1) + for permission in (1, 2, 3, 5, 254, 255): + await self.action("set_acl_permissions", identity_name="repeater", client_pubkey=key, permissions=permission) + await self.action("remove_acl_client", public_key=key, identity_name="Example room", identity_hash="0x42") + self.assertEqual(self.request.call_args.kwargs["json_body"], {"public_key": key, "identity_name": "Example room", "identity_hash": "0x42"}) + self.request.reset_mock() + for permission in (0, 4, 252, 256, -1, True, 1.5, "3"): + with self.assertRaises(boundary.Invalid): + await self.action("set_acl_permissions", identity_name="repeater", client_pubkey=key, permissions=permission) + with self.assertRaises(self.error): + await self.api.async_set_acl_permissions(identity_name="repeater", client_pubkey=key, permissions=permission) + for invalid in ("A" * 63, "G" * 64, " " + key, "A B" * 32, True): + with self.assertRaises(boundary.Invalid): + await self.action("set_acl_permissions", identity_name="repeater", client_pubkey=invalid, permissions=3) + self.request.assert_not_called() + + async def test_plugin_write_guard_unknown_and_cancellation_cover_lifecycle(self): + self.request.return_value = {"success": True, "plugin": {"id": "a"}} + result = await self.action("enable_plugin", plugin_id="a") + self.assertEqual(result["outcome"], "unknown") + with self.assertRaisesRegex(RuntimeError, "uncertain"): + await self.action("uninstall_plugin", plugin_id="a") + self.assertEqual(self.request.call_count, 1) + self.api._plugin_upgrade_uncertain = False + reached = asyncio.Event() + async def pending(*args, **kwargs): + reached.set() + await asyncio.Event().wait() + self.request.side_effect = pending + task = asyncio.create_task(self.action("restart_plugin", plugin_id="a")) + await reached.wait() + for name, data in [("update_all_plugins", {}), ("install_catalogue_plugin", {"plugin_id": "b"}), + ("update_plugin_settings", {"plugin_id": "b", "config": {}})]: + with self.assertRaisesRegex(RuntimeError, "in progress"): + await self.action(name, **data) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertFalse(self.api._plugin_upgrade_active) + self.assertTrue(self.api._plugin_upgrade_uncertain) + self.refresh.assert_not_called() + + async def test_bounded_management_http_overrides_session_without_reading_logs(self): + del self.api._async_request_json + from types import SimpleNamespace + 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,), {}) + self.ns["ClientTimeout"] = lambda **kw: SimpleNamespace(**kw) + self.ns["json"] = json + calls = [] + class Content: + def __init__(self, body): + self.body, self.read_bytes = body, 0 + async def read(self, maximum): + chunk, self.body = self.body[:maximum], self.body[maximum:] + self.read_bytes += len(chunk) + return chunk + class Response: + status = 200 + def __init__(self, body): + self.content = Content(body) + async def __aenter__(self): + return self + async def __aexit__(self, *args): + self.closed = True + async def json(self, **kwargs): + return json.loads(self.content.body) + reply = Response(json.dumps({"success": True, "data": {"types": []}}).encode()) + def request(*args, **kwargs): + calls.append(kwargs) + return reply + self.api._session = SimpleNamespace(request=request) + await self.action("get_sensor_types") + self.assertEqual(calls[-1]["timeout"].total, 30) + self.assertGreater(reply.content.read_bytes, 0) + reply = Response(b"x" * (1024 * 1024 + 100)) + with self.assertRaisesRegex(RuntimeError, "Management"): + await self.action("get_sensor_types") + self.assertLessEqual(reply.content.read_bytes, 1024 * 1024 + 1) + self.assertTrue(reply.closed) + reply = Response(b"[" * 2000 + b"0" + b"]" * 2000) + with self.assertRaisesRegex(RuntimeError, "Management"): + await self.action("get_sensor_types") + self.assertTrue(reply.closed) + reply = Response(json.dumps({"success": True, "plugin": {"id": "a", "enabled": True, "state": "RUNNING"}}).encode()) + result = await self.action("enable_plugin", plugin_id="a") + self.assertEqual(result["outcome"], "success") + self.assertEqual(calls[-1]["timeout"].total, 930) + for status in (409, 502, 504): + self.api._plugin_upgrade_uncertain = False + reply = Response(json.dumps({"success": False, "error": "SECRET settings and logs"}).encode()) + reply.status = status + result = await self.action("restart_plugin", plugin_id="a") + self.assertEqual(result["outcome"], "unknown") + self.assertNotIn("SECRET", str(result)) + self.assertTrue(reply.closed) + self.assertTrue(self.api._plugin_upgrade_uncertain) + self.api._plugin_upgrade_uncertain = False + reply = Response(b'{"success":true,"plugin":{"id":"a","state":"RUNNING"},"extra":' + + b"[" * 100000 + b"0" + b"]" * 100000 + b"}") + self.assertLess(len(reply.content.body), 1024 * 1024) + count_before = len(calls) + result = await self.action("start_plugin", plugin_id="a") + self.assertEqual(result["outcome"], "unknown") + self.assertTrue(self.api._plugin_upgrade_uncertain) + self.assertFalse(self.api._plugin_upgrade_active) + self.assertTrue(reply.closed) + with self.assertRaisesRegex(RuntimeError, "uncertain"): + await self.action("restart_plugin", plugin_id="a") + self.assertEqual(len(calls), count_before + 1) + self.refresh.assert_not_called() + + async def test_registered_handlers_are_native_async_functions(self): + import inspect + for name, (handler, _) in self.registry.items(): + with self.subTest(action=name): + self.assertTrue(inspect.iscoroutinefunction(handler)) + + async def test_new_actions_select_entries_and_optional_acl_response(self): + self.assertEqual(self.registry["set_acl_permissions"][1]["supports_response"], "optional") + only = ("get_plugin_catalogue", "install_catalogue_plugin", "get_plugin_settings", "update_plugin_settings", + "enable_plugin", "disable_plugin", "start_plugin", "stop_plugin", "restart_plugin", "uninstall_plugin", + "get_sensor_types", "get_sensor_configuration", "update_sensor_configuration") + for name in only: + self.assertEqual(self.registry[name][1]["supports_response"], "only") + self.hass.data["pymc_repeater"]["another"] = self.hass.data["pymc_repeater"]["example"] + with self.assertRaisesRegex(RuntimeError, "Multiple"): + await self.action("get_sensor_types") + self.request.return_value = {"success": True, "data": {"types": []}} + await self.action("get_sensor_types", config_entry_id="example") + with self.assertRaisesRegex(RuntimeError, "Unknown"): + await self.action("get_sensor_types", config_entry_id="absent") + self.request.return_value = {"success": True, "data": {"permissions_value": 3, "persisted": True}} + handler, metadata = self.registry["set_acl_permissions"] + from types import SimpleNamespace + self.assertIsNone(await handler(SimpleNamespace(return_response=False, data=metadata["schema"]({ + "config_entry_id": "example", "identity_name": "repeater", "client_pubkey": "AA" * 32, "permissions": 3})))) + self.assertEqual(self.refresh.call_count, 1) + + async def test_plugin_known_failure_unknown_timeout_and_malformed_success_no_retry(self): + for payload in ({"success": False, "error": "SECRET"}, + {"success": False, "http_status": 409, "error": "SECRET"}, + {"success": False, "http_status": 504, "outcome": "unknown"}, + {"success": True, "plugin": {"id": "wrong", "enabled": True}}, + {"success": True, "plugin": {"id": "a", "state": []}}): + self.api._plugin_upgrade_uncertain = False + self.request.return_value = payload + self.request.reset_mock() + result = await self.action("start_plugin", plugin_id="a") + self.assertNotIn("SECRET", str(result)) + self.assertEqual(result["outcome"], "failure" if payload.get("error") == "SECRET" and "http_status" not in payload else "unknown") + self.assertFalse(self.api._plugin_upgrade_active) + self.assertEqual(self.request.call_count, 1) + self.api._plugin_upgrade_uncertain = False + self.request.side_effect = self.ns["PyMCRepeaterCannotConnect"]("SECRET timeout") + self.assertEqual((await self.action("enable_plugin", plugin_id="a"))["outcome"], "unknown") + self.assertTrue(self.api._plugin_upgrade_uncertain) + self.refresh.assert_not_called() + + async def test_sensitive_management_errors_are_fixed_and_no_refresh(self): + for reply in ({"success": False, "error": "SECRET"}, + {"success": True, "data": {"success": False, "error": "SECRET"}}, + {"success": True, "data": []}): + self.request.return_value = reply + for name, data in (("get_sensor_types", {}), ("get_plugin_settings", {"plugin_id": "a"}), + ("set_acl_permissions", {"identity_name": "repeater", "client_pubkey": "AA" * 32, "permissions": 3})): + with self.assertRaises(RuntimeError) as ctx: + await self.action(name, **data) + self.assertNotIn("SECRET", str(ctx.exception)) + self.refresh.assert_not_called() + + async def test_sensor_credentials_redaction_preserves_backend_mask_not_secret(self): + config = {"enabled": True, "poll_interval_seconds": 30, "auto_install_packages": False, + "definitions": [{"name": "Example", "type": "openhop_modem", "_original_name": "Old example", + "settings": {"password": "*****", "token": "SECRET"}}]} + for wrapped in (True, False): + self.request.return_value = {"success": True, "data": config} if wrapped else config + result = await self.action("get_sensor_configuration") + self.assertEqual(result["definitions"][0]["settings"]["password"], "*****") + self.assertEqual(result["definitions"][0]["_original_name"], "Old example") + self.assertNotIn("SECRET", str(result)) + with self.assertRaises(boundary.Invalid): + await self.action("update_sensor_configuration", config=result) + sensitive = await self.action("get_sensor_configuration", include_sensitive=True) + self.assertEqual(sensitive["definitions"][0]["settings"]["token"], "SECRET") + self.refresh.assert_not_called() + + async def test_input_bounds_and_delete_data_true_survive_exact_payload(self): + self.request.return_value = {"success": True, "id": "a", "uninstalled": True, "data_deleted": True} + self.assertTrue((await self.action("uninstall_plugin", plugin_id="a", delete_data=True))["data_deleted"]) + self.assertTrue(self.request.call_args.kwargs["json_body"]["delete_data"]) + self.request.reset_mock() + for name in ("enable_plugin", "install_catalogue_plugin", "get_plugin_settings", "update_plugin_settings"): + for plugin_id in ("../a", "a/b", "a?b", "a ", "", "a" * 129): + data = {"plugin_id": plugin_id} + if name == "update_plugin_settings": + data["config"] = {} + with self.assertRaises(boundary.Invalid): + await self.action(name, **data) + deep = {}; cursor = deep + for _ in range(34): + cursor["child"] = {}; cursor = cursor["child"] + for config in (deep, {"items": [0] * 10001}, {"value": float("inf")}): + with self.assertRaises(self.error): + await self.api.async_update_plugin_settings(plugin_id="a", config=config) + self.request.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_upgrades.py b/tests/test_plugin_upgrades.py new file mode 100644 index 0000000..8a7c895 --- /dev/null +++ b/tests/test_plugin_upgrades.py @@ -0,0 +1,248 @@ +"""Plugin actions execute extracted production bodies, not a live HA instance.""" +import asyncio +import unittest +import test_dev_130_actions as boundary + + +class PluginUpgradeTests(unittest.IsolatedAsyncioTestCase): + asyncSetUp = boundary.ActionTests.asyncSetUp + action = boundary.ActionTests.action + + def inventory(self, *ids): + return {"success": True, "plugins": [ + {"id": ident, "version": "1.0", "source": "catalogue", "repository": "example/repo"} + for ident in ids]} + + def check(self, ident, available=True): + return {"success": True, "id": ident, "installedVersion": "1.0", "latestVersion": "2.0", "updateAvailable": available} + + async def test_update_all_continues_failure_and_verifies_enabled_reply_version(self): + self.assertIn("update_all_plugins", self.registry) + self.request.side_effect = [self.inventory("a", "b", "c"), + self.check("a"), {"success": False, "error": "SECRET pip log"}, + self.check("b"), {"success": True, "plugin": {"id": "b", "version": "2.0", "enabled": True, "config": "SECRET"}}, + self.check("c", False)] + result = await self.action("update_all_plugins") + self.assertEqual([r["outcome"] for r in result["results"]], ["failure", "success", "skipped"]) + self.assertFalse(result["stopped"]) + self.assertEqual(result["counts"], dict(success=1, skipped=1, failure=1, unknown=0)) + self.assertNotIn("SECRET", str(result)) + posts = [call for call in self.request.call_args_list if call.args[0] == "POST"] + self.assertEqual([c.kwargs["json_body"] for c in posts], [ + {"id": "a", "version": "2.0", "force_refresh": False}, + {"id": "b", "version": "2.0", "force_refresh": False}]) + self.assertTrue(all(c.kwargs["timeout_seconds"] == 930 for c in posts)) + self.assertFalse(self.api._plugin_upgrade_active) + self.refresh.assert_not_called() + + async def test_timeout_stops_batch_without_retry_and_quarantines_client(self): + self.assertIn("update_all_plugins", self.registry) + self.request.side_effect = [self.inventory("a", "b"), self.check("a"), + self.ns["PyMCRepeaterCannotConnect"]("SECRET timeout")] + result = await self.action("update_all_plugins") + self.assertEqual([r["outcome"] for r in result["results"]], ["unknown", "skipped"]) + self.assertTrue(result["stopped"]) + self.assertEqual(self.request.call_count, 3) + self.assertNotIn("SECRET", str(result)) + self.assertFalse(self.api._plugin_upgrade_active) + with self.assertRaisesRegex(RuntimeError, "uncertain"): + await self.action("update_all_plugins") + self.assertEqual(self.request.call_count, 3) + + async def test_malformed_version_metadata_is_not_returned_as_raw_text(self): + inventory = self.inventory("a") + inventory["plugins"][0]["version"] = "SECRET raw installation log\n" + self.request.side_effect = [inventory, self.check("a", False)] + result = await self.action("check_plugin_updates") + self.assertIsNone(result["results"][0]["installed_version"]) + self.assertNotIn("SECRET", str(result)) + + async def test_conflict_and_ipc_unknown_stop_sequential_upgrades(self): + for error in ({"success": False, "http_status": 409, "error": "SECRET"}, + {"success": False, "http_status": 504, "outcome": "unknown"}): + self.api._plugin_upgrade_uncertain = False + self.request.side_effect = [self.inventory("a", "b"), self.check("a"), error] + result = await self.action("update_all_plugins") + self.assertEqual([r["outcome"] for r in result["results"]], ["unknown", "skipped"]) + self.assertEqual(self.request.call_count % 3, 0) + self.assertNotIn("SECRET", str(result)) + self.assertFalse(self.api._plugin_upgrade_active) + + async def test_successful_overlap_rejection_releases_admission_for_next_call(self): + reached, release = asyncio.Event(), asyncio.Event() + async def request(method, path, **kwargs): + if path == "/api/plugins/": + return self.inventory("a") + if path == "/api/plugins/updates": + return self.check("a") + reached.set() + await release.wait() + return {"success": True, "plugin": {"id": "a", "version": "2.0", "updated": True}} + self.request.side_effect = request + task = asyncio.create_task(self.action("update_all_plugins")) + await reached.wait() + with self.assertRaisesRegex(RuntimeError, "in progress"): + await self.action("update_plugin", plugin_id="a") + release.set() + self.assertEqual((await task)["counts"]["success"], 1) + self.assertFalse(self.api._plugin_upgrade_active) + self.assertFalse(getattr(self.api, "_plugin_upgrade_uncertain", False)) + self.request.side_effect = [self.inventory("a"), self.check("a", False)] + self.assertEqual((await self.action("update_all_plugins"))["counts"]["skipped"], 1) + + async def test_wrapped_failures_are_sanitized_and_stop_ambiguous_batch(self): + for payload in ({"success": True, "data": {"success": False, "error": "SECRET"}}, + {"success": False, "outcome": "unknown", "error": "SECRET"}): + self.api._plugin_upgrade_uncertain = False + self.request.side_effect = [self.inventory("a", "b"), self.check("a"), payload] + if payload.get("outcome") != "unknown": + self.request.side_effect = [self.inventory("a", "b"), self.check("a"), payload, + self.check("b", False)] + result = await self.action("update_all_plugins") + self.assertNotIn("SECRET", str(result)) + self.assertEqual(result["results"][0]["outcome"], "unknown" if payload.get("outcome") == "unknown" else "failure") + + async def test_missing_check_metadata_stops_before_writes(self): + self.request.side_effect = [self.inventory("a", "b"), {"success": True, "id": "a"}] + result = await self.action("update_all_plugins") + self.assertEqual([r["outcome"] for r in result["results"]], ["unknown", "skipped"]) + self.assertEqual(self.request.call_count, 2) + + async def test_multiple_entry_selection_and_only_response_registration(self): + for name in ("check_plugin_updates", "update_plugin", "update_all_plugins"): + self.assertEqual(self.registry[name][1]["supports_response"], "only") + self.hass.data["pymc_repeater"]["another"] = self.hass.data["pymc_repeater"]["example"] + with self.assertRaisesRegex(RuntimeError, "Multiple"): + await self.action("update_all_plugins") + self.request.return_value = self.inventory() + await self.action("update_all_plugins", config_entry_id="example") + self.assertEqual(self.request.call_count, 1) + + async def test_plugin_http_budget_overrides_shared_session_and_redacts_errors(self): + del self.api._async_request_json + from types import SimpleNamespace + 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,), {}) + self.ns["ClientTimeout"] = lambda **kw: SimpleNamespace(**kw) + status, payload, calls = 200, {"success": True, "plugins": []}, [] + + class Response: + @property + def status(self): + return status + async def __aenter__(self): + return self + async def __aexit__(self, *args): + pass + async def json(self, **kwargs): + return payload + async def text(self): + return "SECRET backend settings and logs" + + def request(*args, **kwargs): + calls.append((args, kwargs)) + return Response() + + self.api._session = SimpleNamespace(request=request) + self.assertEqual((await self.action("update_all_plugins"))["results"], []) + self.assertEqual(calls[0][1]["timeout"].total, 930) + self.assertEqual(calls[0][1]["headers"]["X-API-Key"], "example-token") + for code in (400, 409, 500, 502, 504): + status = code + payload = {"success": False, "error": "SECRET", "outcome": "unknown" if code == 504 else "failure"} + result = await self.api._async_plugin_payload("POST", "/api/plugins/update") + self.assertNotIn("SECRET", str(result)) + self.assertEqual(result["outcome"], "unknown" if code in (409, 502, 504) else "failure") + + async def test_version_and_identity_inputs_are_bounded_before_http(self): + from test_dev_130_actions import Invalid + for field in ("plugin_id", "version"): + for value in ("", " ", " a", "a ", "../a", "a/b", "a?b", "a\nb", "é", 12, True, "a" * 129): + with self.subTest(field=field, value=value): + data = {"plugin_id": "a", field: value} + with self.assertRaises(Invalid): + await self.action("update_plugin", **data) + with self.assertRaises(self.error): + await self.api.async_update_plugin(**data) + self.request.assert_not_called() + + async def test_no_updates_empty_local_unavailable_and_uninstalled(self): + for plugins in ([], [{"id": "local", "version": "1.0", "source": "local", "repository": "example/repo"}], + [{"id": "missing", "version": "1.0", "source": "catalogue"}]): + self.request.side_effect = None + self.request.return_value = {"success": True, "plugins": plugins} + result = await self.action("update_all_plugins") + self.assertEqual(result["counts"]["success"], 0) + self.assertEqual(result["counts"]["skipped"], len(plugins)) + self.request.return_value = self.inventory() + result = await self.action("update_plugin", plugin_id="absent") + self.assertEqual(result["results"][0]["reason"], "not_installed") + self.assertTrue(all(c.args[0] == "GET" for c in self.request.call_args_list)) + + async def test_overlap_rejected_cancellation_preserved_and_guard_cleaned(self): + reached, release = asyncio.Event(), asyncio.Event() + async def request(method, path, **kwargs): + if path == "/api/plugins/": + return self.inventory("a") + if path == "/api/plugins/updates": + return self.check("a") + reached.set() + await release.wait() + return {"success": True, "plugin": {"id": "a", "version": "2.0"}} + self.request.side_effect = request + task = asyncio.create_task(self.action("update_plugin", plugin_id="a")) + await reached.wait() + with self.assertRaisesRegex(RuntimeError, "in progress"): + await self.action("update_all_plugins") + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertFalse(self.api._plugin_upgrade_active) + self.assertTrue(self.api._plugin_upgrade_uncertain) + self.assertEqual(self.request.call_count, 3) + + async def test_explicit_version_and_updated_false(self): + self.request.side_effect = [self.inventory("a"), self.check("a", False), + {"success": True, "plugin": {"id": "a", "version": "1.0", "updated": False}}] + result = await self.action("update_plugin", plugin_id="a", version="v2.0", force_refresh=True) + self.assertEqual(result["results"][0]["outcome"], "skipped") + self.assertEqual(self.request.call_args.kwargs["json_body"], {"id": "a", "version": "v2.0", "force_refresh": True}) + + async def test_invalid_success_envelope_or_wrong_target_is_unknown(self): + for reply in ({"success": True}, {"success": True, "plugin": {"id": "b", "version": "2.0"}}, + {"success": True, "plugin": {"id": "a", "version": "1.0"}}): + self.api._plugin_upgrade_uncertain = False + self.request.side_effect = [self.inventory("a", "b"), self.check("a"), reply] + result = await self.action("update_all_plugins") + self.assertEqual([r["outcome"] for r in result["results"]], ["unknown", "skipped"]) + self.assertTrue(result["stopped"]) + + async def test_inventory_error_or_bounds_cleanup_without_write(self): + for payload in ({"success": False, "error": "SECRET"}, {"plugins": "bad"}, + self.inventory("a", "a"), self.inventory(*(f"p{i}" for i in range(101)))): + self.request.return_value = payload + with self.assertRaises(RuntimeError): + await self.action("update_all_plugins") + self.assertFalse(self.api._plugin_upgrade_active) + self.assertTrue(all(c.args[0] == "GET" for c in self.request.call_args_list)) + + async def test_check_eligible_and_local_inventory_is_minimized(self): + self.assertIn("check_plugin_updates", self.registry) + self.request.side_effect = [ + {"success": True, "plugins": [ + {"id": "openhop.example", "version": "1.0", "source": "catalogue", "repository": "example/repo", "config": {"token": "SECRET"}}, + {"id": "local.example", "version": "1.0", "source": "local"}]}, + {"success": True, "id": "openhop.example", "installedVersion": "1.0", "latestVersion": "2.0", "updateAvailable": True, "releaseNotes": "SECRET"}, + ] + result = await self.action("check_plugin_updates") + self.assertEqual([r["outcome"] for r in result["results"]], ["success", "skipped"]) + self.assertTrue(result["results"][0]["update_available"]) + self.assertNotIn("SECRET", str(result)) + self.assertEqual(self.request.call_args.args, ("GET", "/api/plugins/updates")) + self.assertEqual(self.request.call_args.kwargs["params"], {"id": "openhop.example", "refresh": "false"}) + self.refresh.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_radio_telemetry.py b/tests/test_radio_telemetry.py new file mode 100644 index 0000000..1ae8e73 --- /dev/null +++ b/tests/test_radio_telemetry.py @@ -0,0 +1,283 @@ +"""Execute checked-in radio normalization/entities with only HA boundaries stubbed. + +No live Home Assistant registration, HTTP, hardware or RF operations are used. +""" +import ast +import copy +import importlib.util +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_monitoring(): + spec = importlib.util.spec_from_file_location("radio_monitoring", COMPONENT / "monitoring.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def entity_namespace(): + class CoordinatorEntity: + def __init__(self, coordinator): + self.coordinator = coordinator + + @property + def available(self): + return self.coordinator.last_update_success + + ns = dict(vars(load_monitoring()), json=json, CoordinatorEntity=CoordinatorEntity, + SensorEntity=type("Sensor", (), {}), BinarySensorEntity=type("Binary", (), {}), + DeviceInfo=dict, DOMAIN="pymc_repeater", MANUFACTURER="openHop", + CONF_RADIO_ID_ALIASES="radio_id_aliases", + EntityCategory=SimpleNamespace(DIAGNOSTIC="diagnostic"), + SensorDeviceClass=SimpleNamespace(DURATION="duration", TIMESTAMP="timestamp", SIGNAL_STRENGTH="signal_strength"), + SensorStateClass=SimpleNamespace(MEASUREMENT="measurement"), + BinarySensorDeviceClass=SimpleNamespace(PROBLEM="problem", CONNECTIVITY="connectivity")) + wanted = {"PyMCBaseEntity", "_nested", "_external_sensor_readings", "_external_sensor_identity"} + for filename in ("sensor.py", "monitoring_entities.py"): + tree = ast.parse((COMPONENT / filename).read_text()) + nodes = [node for node in tree.body + if (getattr(node, "name", None) in wanted if filename == "sensor.py" + else not isinstance(node, (ast.Import, ast.ImportFrom)))] + 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"), ns) + return ns + + +def fixture(ids=("a", "b")): + return {"stats": {"radio_stack": {"mode": "multi", "radio_ids": list(ids)}, + "radios": [{"id": rid} for rid in ids], + "airtime_radios": [{"radio_id": rid, "utilization_percent": 20, + "current_airtime_ms": 200, "max_airtime_ms": 1000, + "total_airtime_ms": 9000} for rid in ids], + "noise_floor_radios": [{"radio_id": rid, "noise_floor_dbm": -110} for rid in ids]}, + "packet_stats_1h": {"radios": [{"radio_id": rid, "received": 5, "duplicates": 2, + "transmissions": 8, "avg_rssi": -90, "avg_snr": 6} for rid in ids]}, + "packet_stats": {"radios": [{"radio_id": rid, "received": 50, "duplicates": 20, + "transmissions": 80, "avg_rssi": -95, "avg_snr": 4} for rid in ids]}, + "lbt_diagnostics": {"radios": [{"radio_id": rid, + "summary": {"has_lbt_data": True, "total_transmissions": 80, + "retry_packets": 10, "retry_rate_pct": 12.5, + "avg_attempts": 1.25, "p95_attempts": 2, + "max_attempts": 4, "failed_transmissions": 1, + "busy_channel_events": 20, + "severe_contention_count": 2, + "severe_contention_pct": 2.5, + "worst_bucket": {"timestamp": 123}}, + "buckets": [{"timestamp": 123}]} for rid in ids]}} + + +class RadioTelemetryTests(unittest.TestCase): + def setUp(self): + self.m = load_monitoring() + + def test_existing_polled_summaries_supply_child_measurements(self): + self.assertTrue(callable(getattr(self.m, "radio_telemetry", None)), + "child radio telemetry normalization is missing") + data = fixture() + before = copy.deepcopy(data) + result = self.m.radio_telemetry(data) + self.assertEqual(result["a"]["channel_utilization"], 20) + self.assertEqual(result["a"]["current_channel_airtime"], 200) + self.assertEqual(result["a"]["max_channel_airtime"], 1000) + self.assertEqual(result["a"]["noise_floor"], -110) + for window, received in (("1h", 5), ("24h", 50)): + self.assertEqual(result["a"][f"received_{window}"], received) + for field in ("duplicates", "transmissions", "avg_rssi", "avg_snr"): + self.assertIn(f"{field}_{window}", result["a"]) + self.assertEqual(result["a"]["lbt_retry_rate_pct_24h"], 12.5) + self.assertEqual(result["a"]["lbt_busy_channel_events_24h"], 20) + self.assertEqual(data, before) + self.assertTrue(all(isinstance(value, (int, float)) for metrics in result.values() for value in metrics.values())) + self.assertNotIn("total_airtime_ms", result["a"]) + self.assertNotIn("buckets", result["a"]) + self.assertNotIn("worst_bucket", result["a"]) + + + def test_alias_source_and_canonical_round_trip_keep_identity(self): + for runtime_id in ("local", "radio0", "local"): + with self.subTest(runtime_id=runtime_id): + data = fixture((runtime_id, "b")) + result = self.m.radio_telemetry(data, {"local": "radio0"}) + self.assertEqual(set(result), {"radio0", "b"}) + self.assertEqual(result["radio0"]["received_1h"], 5) + self.assertEqual(result["radio0"]["noise_floor"], -110) + self.assertEqual(result["radio0"]["lbt_retry_packets_24h"], 10) + collision = fixture(("local", "radio0", "b")) + self.assertEqual(set(self.m.radio_telemetry(collision, {"local": "radio0"})), {"b"}) + for aliases in ('{"local":"radio0","local":"old"}', {"local": "local"}, None): + self.assertEqual(self.m.radio_telemetry(fixture(), aliases), {}) + + def test_duplicate_unknown_and_alias_telemetry_ids_fail_closed(self): + for endpoint, field in (("stats", "airtime_radios"), ("stats", "noise_floor_radios"), + ("packet_stats_1h", "radios"), ("packet_stats", "radios"), + ("lbt_diagnostics", "radios")): + data = fixture(("local", "b")) + rows = data[endpoint][field] + rows.extend([copy.deepcopy(rows[0]), dict(rows[0], radio_id="unknown")]) + result = self.m.radio_telemetry(data, {"local": "radio0"}) + expected_absent = {("stats", "airtime_radios"): "channel_utilization", + ("stats", "noise_floor_radios"): "noise_floor", + ("packet_stats_1h", "radios"): "received_1h", + ("packet_stats", "radios"): "received_24h", + ("lbt_diagnostics", "radios"): "lbt_retry_packets_24h"}[(endpoint, field)] + self.assertNotIn(expected_absent, result["radio0"]) + self.assertIn(expected_absent, result["b"]) + self.assertNotIn("unknown", result) + data = fixture(("local", "b")) + data["packet_stats"]["radios"].append(dict(data["packet_stats"]["radios"][0], radio_id="radio0")) + self.assertNotIn("received_24h", self.m.radio_telemetry(data, {"local": "radio0"})["radio0"]) + data = fixture() + data["stats"]["radios"].append({"id": "a"}) + self.assertNotIn("a", self.m.radio_telemetry(data)) + data = fixture() + data["packet_stats"]["radios"].extend([{}, None, {"radio_id": ["a"]}]) + self.assertEqual(self.m.radio_telemetry(data)["a"]["received_24h"], 50) + + + def test_absence_nonfinite_errors_empty_windows_and_no_lbt_samples(self): + self.assertEqual(self.m.radio_telemetry({}), {}) + legacy = {"stats": {"radio_stack": {"radio_ids": ["a"]}, "packets_received": 99, + "noise_floor": -99, "utilization_percent": 42}, + "packet_stats": {"total_packets": 44}, "lbt_diagnostics": {"summary": {"retry_packets": 12}}} + self.assertEqual(self.m.radio_telemetry(legacy), {"a": {}}) + for invalid in (None, True, "nan", "inf", float("-inf"), {}, []): + data = fixture() + data["stats"]["noise_floor_radios"][0]["noise_floor_dbm"] = invalid + data["packet_stats"]["radios"][0]["avg_snr"] = invalid + self.assertNotIn("noise_floor", self.m.radio_telemetry(data)["a"]) + self.assertNotIn("avg_snr_24h", self.m.radio_telemetry(data)["a"]) + for error in ({"error": "failed"}, {"success": False}): + data = fixture() + data["packet_stats"].update(error) + self.assertNotIn("received_24h", self.m.radio_telemetry(data)["a"]) + data = fixture() + data["stats"]["noise_floor_radios"][0].update(error) + self.assertNotIn("noise_floor", self.m.radio_telemetry(data)["a"]) + data = fixture() + data["lbt_diagnostics"]["radios"][0]["summary"].update(error) + self.assertNotIn("lbt_retry_packets_24h", self.m.radio_telemetry(data)["a"]) + data = fixture() + data["packet_stats"]["radios"][0].update(received=0, duplicates=0, transmissions=0, avg_rssi=None, avg_snr=None) + data["lbt_diagnostics"]["radios"][0]["summary"].update(has_lbt_data=False, retry_packets=0) + result = self.m.radio_telemetry(data)["a"] + self.assertEqual(result["received_24h"], 0) + self.assertNotIn("avg_rssi_24h", result) + self.assertNotIn("lbt_retry_packets_24h", result) + + + def test_real_entities_discover_once_keep_alias_ids_and_become_unavailable(self): + ns = entity_namespace() + listeners, unload, entities = [], [], [] + def listen(callback): + listeners.append(callback) + return lambda: listeners.remove(callback) + entry = SimpleNamespace(unique_id="entry", entry_id="fallback", options={"radio_id_aliases": {"local": "radio0"}}, + async_on_unload=unload.append) + coordinator = SimpleNamespace(data=fixture(("local", "b")), config_entry=entry, + last_update_success=True, async_add_listener=listen) + ns["setup_monitoring_sensors"](entry, coordinator, entities.extend) + key = ("radio", "radio0", "received_1h") + matches = [entity for entity in entities if entity._key == key] + self.assertEqual(len(matches), 1, "child telemetry must be discovered") + entity = matches[0] + self.assertEqual(entity._attr_unique_id, 'entry_monitor_["radio","radio0","received_1h"]') + self.assertEqual(entity.device_info["identifiers"], {("pymc_repeater", "entry_radio_radio0")}) + self.assertEqual(entity.device_info["via_device"], ("pymc_repeater", "entry")) + self.assertTrue(entity.available) + self.assertEqual(entity.native_value, 5) + initial_ids = [item._attr_unique_id for item in entities] + listeners[0]() + self.assertEqual([item._attr_unique_id for item in entities], initial_ids) + coordinator.data = fixture(("radio0", "b")) + listeners[0]() + self.assertTrue(entity.available) + self.assertEqual([item._attr_unique_id for item in entities], initial_ids) + coordinator.data = fixture(("b",)) + listeners[0]() + self.assertFalse(entity.available) + self.assertIsNone(entity.native_value) + coordinator.data = fixture(("local", "b")) + coordinator.data["packet_stats_1h"]["radios"][0]["received"] = float("nan") + self.assertFalse(entity.available) + coordinator.data = fixture(("local", "b")) + coordinator.last_update_success = False + self.assertFalse(entity.available) + coordinator.last_update_success = True + self.assertTrue(entity.available) + self.assertEqual(len(unload), 1) + unload[0]() + self.assertEqual(listeners, []) + + + def test_window_metadata_translations_shared_channel_no_summing(self): + ns = entity_namespace() + entry = SimpleNamespace(unique_id="entry", entry_id="fallback", options={}) + data = fixture() + data["stats"]["utilization_percent"] = 20 + data["stats"]["current_airtime_ms"] = 200 + coordinator = SimpleNamespace(data=data, config_entry=entry, last_update_success=True) + snapshot = ns["_snapshot"](coordinator, False) + self.assertEqual(snapshot[("radio", "a", "current_channel_airtime")], 200) + self.assertEqual(snapshot[("radio", "b", "current_channel_airtime")], 200) + self.assertEqual(coordinator.data["stats"]["current_airtime_ms"], 200) + translations = json.loads((COMPONENT / "translations/en.json").read_text())["entity"]["sensor"] + self.assertIn("RADIO_TELEMETRY", ns, "telemetry metadata table is missing") + self.assertEqual(set(ns["RADIO_TELEMETRY"]), set(self.m.radio_telemetry(data)["a"])) + for field, (name, unit) in ns["RADIO_TELEMETRY"].items(): + with self.subTest(field=field): + entity = ns["MonitoringSensor"](entry, coordinator, ("radio", "a", field)) + self.assertEqual(entity._attr_state_class, "measurement") + self.assertEqual(entity._attr_name, name) + self.assertEqual(entity._attr_native_unit_of_measurement, unit) + self.assertEqual(entity._attr_translation_key, f"radio_{field}") + self.assertEqual(translations[f"radio_{field}"]["name"], name) + self.assertEqual(ns["MonitoringSensor"](entry, coordinator, ("radio", "a", "current_channel_airtime"))._attr_device_class, "duration") + self.assertEqual(ns["MonitoringSensor"](entry, coordinator, ("radio", "a", "noise_floor"))._attr_device_class, "signal_strength") + # Existing child configuration and unique IDs are not renamed. + existing = ns["MonitoringSensor"](entry, coordinator, ("radio", "a", "frequency")) + self.assertEqual(existing._attr_unique_id, 'entry_monitor_["radio","a","frequency"]') + self.assertEqual(existing._attr_name, "Radio a frequency") + + + def test_every_measurement_rejects_nonfinite_values_and_late_sources_discover(self): + ns = entity_namespace() + entry = SimpleNamespace(unique_id="entry", entry_id="fallback", options={}, async_on_unload=lambda callback: None) + listeners, entities = [], [] + coordinator = SimpleNamespace(data={"stats": {"radio_stack": {"radio_ids": ["a", "b"]}}}, + config_entry=entry, last_update_success=True, + async_add_listener=listeners.append) + ns["setup_monitoring_sensors"](entry, coordinator, entities.extend) + self.assertFalse(any(item._key[2] == "received_1h" for item in entities)) + coordinator.data = fixture() + listeners[0]() + received = next(item for item in entities if item._key == ("radio", "a", "received_1h")) + self.assertTrue(received.available) + coordinator.data["packet_stats_1h"]["radios"][0]["received"] = 0 + self.assertEqual(received.native_value, 0, "a falling window is a measurement, not a lifetime counter") + for invalid in (True, "nan", "inf", None): + data = fixture() + for endpoint, source in (("stats", "airtime_radios"), ("stats", "noise_floor_radios"), + ("packet_stats", "radios"), ("packet_stats_1h", "radios"), + ("lbt_diagnostics", "radios")): + row = data[endpoint][source][0] + payload = row["summary"] if endpoint == "lbt_diagnostics" else row + for field in list(payload): + if field not in ("radio_id", "has_lbt_data", "worst_bucket"): + payload[field] = invalid + self.assertEqual(self.m.radio_telemetry(data)["a"], {}) + coordinator.data = data + self.assertFalse(received.available) + coordinator.data = fixture() + coordinator.data["packet_stats_1h"]["radios"].append(copy.deepcopy(coordinator.data["packet_stats_1h"]["radios"][0])) + self.assertFalse(received.available) + + +if __name__ == "__main__": + unittest.main() From c157134a51874c7d35e6a3227f25a8a55a1fda92 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:12:48 -0400 Subject: [PATCH 2/4] test: make JSON decoder failure coverage portable --- tests/test_management_actions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_management_actions.py b/tests/test_management_actions.py index 1c445de..454ae13 100644 --- a/tests/test_management_actions.py +++ b/tests/test_management_actions.py @@ -210,7 +210,12 @@ def request(*args, **kwargs): + b"[" * 100000 + b"0" + b"]" * 100000 + b"}") self.assertLess(len(reply.content.body), 1024 * 1024) count_before = len(calls) - result = await self.action("start_plugin", plugin_id="a") + # Decoder nesting limits vary by Python build and thread stack size. + # Inject the decoder exception to verify transport/quarantine behavior + # independently of whether this runtime accepts the nested envelope. + from unittest.mock import patch + with patch("json.loads", side_effect=RecursionError("decoder nesting limit")): + result = await self.action("start_plugin", plugin_id="a") self.assertEqual(result["outcome"], "unknown") self.assertTrue(self.api._plugin_upgrade_uncertain) self.assertFalse(self.api._plugin_upgrade_active) From d58ec88869b6b8540004f98ccad512ed246fa44d Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:34:14 -0400 Subject: [PATCH 3/4] docs: remove empty unreleased changelog heading --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3a486..cbbdb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ # Changelog -## Unreleased - ## 1.3.0 - fixed advanced action dispatch by registering native async handlers so home assistant awaits operations and receives actual responses From 43afc0b5c6a3d00e027bca196a0df09f1f21e1cb Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:35:59 -0400 Subject: [PATCH 4/4] test: align changelog checks with versioned release notes --- tests/test_dev_api_alignment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dev_api_alignment.py b/tests/test_dev_api_alignment.py index 5e654a8..ac0f741 100644 --- a/tests/test_dev_api_alignment.py +++ b/tests/test_dev_api_alignment.py @@ -358,7 +358,7 @@ 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_2_1(self) -> None: + def test_release_metadata_and_changelog_are_v1_3_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( @@ -369,7 +369,7 @@ def test_release_metadata_and_changelog_are_v1_2_1(self) -> None: self.assertIn("## 1.3.0", changelog) self.assertIn("[#16](https://github.com/openhop-dev/openHop-HA-Integration/issues/16)", changelog) self.assertIn("## 1.1.6", changelog) - self.assertIn("## Unreleased", changelog) + self.assertNotIn("## Unreleased", changelog) self.assertIn("## 1.2.0", changelog) for expected in ( "15 seconds",