diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index dc47a6b..7a660f2 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -25,7 +25,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Validate with hassfest uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/python-smoke.yaml b/.github/workflows/python-smoke.yaml index 40f36e5..9642183 100644 --- a/.github/workflows/python-smoke.yaml +++ b/.github/workflows/python-smoke.yaml @@ -25,7 +25,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5c08a..3e72dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.4 + +- added Home Assistant entities for the Repeater dev sensor-manager summary and configured sensor plug-in readings, including UPS/power/environment values and boolean flags exposed under `stats.sensors.readings` +- made sensor-manager entities appear when readings arrive after integration setup instead of requiring the readings to be present during the first Home Assistant entity setup pass +- rebranded the Home Assistant integration, HACS name, dashboard title, diagnostics text, and bundled icons from pyMC Repeater to openHop Repeater while keeping the existing `pymc_repeater` integration domain for compatibility + ## 1.1.3 - added a Home Assistant action for the new repeater `broker_presets` API endpoint so bundled MC2MQTT broker templates can be queried from HA @@ -33,7 +39,7 @@ ## 1.0.0 -Initial stable release of the pyMC Repeater Home Assistant integration. +Initial stable release of the openHop Repeater Home Assistant integration. - config flow setup using repeater host, port, and admin password - dedicated API token bootstrap and token-based polling diff --git a/LICENSE b/LICENSE index 3c7b696..b31801d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 pyMC.dev +Copyright (c) 2026 openHop Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d4c0600..9ad5597 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ -# pyMC Repeater for Home Assistant +# openHop Repeater for Home Assistant -Custom Home Assistant integration for [pyMC_Repeater](https://github.com/rightup/pyMC_Repeater). +Custom Home Assistant integration for [openHop Repeater](https://github.com/openhop-dev/openhop_repeater). This integration connects directly to the repeater's local HTTP API, signs in once with the admin password, creates a dedicated API token for Home Assistant, and then uses that token for ongoing polling and history-friendly logging inside HA. +The Home Assistant integration domain and folder remain `pymc_repeater` for compatibility with existing installations while the user-facing brand is openHop Repeater. + ## Features - UI config flow inside Home Assistant - Prompts for repeater IP or hostname, port, and admin password - Automatically creates a dedicated API token for Home Assistant - Stores the API token instead of the admin password after setup -- Polls repeater telemetry, packet stats, radio metrics, hardware stats, database stats, MQTT status, ACL stats, and identity totals +- Polls repeater telemetry, packet stats, radio metrics, hardware stats, database stats, MQTT status, ACL stats, identity totals, and configured Repeater sensor-manager readings - Exposes Home Assistant sensors and binary sensors for easy dashboards, history graphs, and automations ## Requirements -- A running [pyMC_Repeater](https://github.com/rightup/pyMC_Repeater) instance +- A running [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) instance - The repeater web/API port reachable from Home Assistant - The repeater admin password - A trusted local network, VPN, or other secure path between Home Assistant and the repeater @@ -32,12 +34,12 @@ This integration is intended to be installed in HACS as a custom repository. 4. Paste: ```text - https://github.com/pyMC-dev/pyMC-HA-Integration + https://github.com/openhop-dev/openHop-HA-Integration ``` 5. Choose `Integration` as the category. 6. Add the repository. -7. Find `pyMC Repeater` in HACS and install it. +7. Find `openHop Repeater` in HACS and install it. 8. Restart Home Assistant. ### Manual installation @@ -65,7 +67,7 @@ After installation and restart: 1. Open `Settings` -> `Devices & Services`. 2. Click `Add Integration`. -3. Search for `pyMC Repeater`. +3. Search for `openHop Repeater`. 4. Enter: - Repeater IP address or hostname - Repeater HTTP API port @@ -81,20 +83,6 @@ During setup the integration will: 5. Discard the admin password after the setup flow finishes 6. Start polling repeater data automatically -## What gets added to Home Assistant - -Version `1.0.0` includes entities for: - -- repeater version and build info -- total, transmitted, and dropped packets -- average RSSI, SNR, and TX delay -- average noise floor -- MQTT broker connection state -- ACL client totals -- registered identity totals -- database size -- CPU, memory, disk usage, and uptime - ## Dashboard template A native Lovelace dashboard template is included at: @@ -144,7 +132,7 @@ README.md - The config flow follows current Home Assistant custom integration patterns with `manifest.json`, `config_flow.py`, and `translations/en.json` - The integration uses coordinated polling rather than per-entity API calls -- The client implementation matches the current [pyMC_Repeater](https://github.com/rightup/pyMC_Repeater) auth flow: +- The client implementation matches the current [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) auth flow: - `POST /auth/login` - `POST /api/auth/tokens` - ongoing reads with `X-API-Key` diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index d4c2514..d35aa3d 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -1,4 +1,4 @@ -"""The pyMC Repeater integration.""" +"""The openHop Repeater integration.""" from __future__ import annotations @@ -70,7 +70,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up pyMC Repeater from a config entry.""" + """Set up openHop Repeater from a config entry.""" session = async_get_clientsession(hass) api = PyMCRepeaterApiClient( session=session, @@ -116,19 +116,19 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non def _resolve_entry_id(hass: HomeAssistant, service_data: dict) -> str: entries = hass.data.get(DOMAIN, {}) if not entries: - raise HomeAssistantError("No pyMC Repeater entries are loaded") + raise HomeAssistantError("No openHop Repeater entries are loaded") requested = service_data.get(CONF_ENTRY_ID) or service_data.get(LEGACY_CONF_ENTRY_ID) if requested: if requested not in entries: - raise HomeAssistantError(f"Unknown pyMC Repeater entry_id: {requested}") + raise HomeAssistantError(f"Unknown openHop Repeater entry_id: {requested}") return requested if len(entries) == 1: return next(iter(entries)) raise HomeAssistantError( - "Multiple pyMC Repeater entries are configured; provide entry_id" + "Multiple openHop Repeater entries are configured; provide entry_id" ) diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index 461acdf..1ae8144 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -1,4 +1,4 @@ -"""Async API client for pyMC Repeater.""" +"""Async API client for openHop Repeater.""" from __future__ import annotations @@ -18,7 +18,7 @@ class PyMCRepeaterError(Exception): - """Base error for the pyMC Repeater client.""" + """Base error for the openHop Repeater client.""" class PyMCRepeaterCannotConnect(PyMCRepeaterError): @@ -78,13 +78,13 @@ def normalize_host(value: str) -> str: def build_home_assistant_token_name(home_assistant_hostname: str | None = None) -> str: - """Build the pyMC API token label for this Home Assistant instance.""" + """Build the openHop API token label for this Home Assistant instance.""" hostname = (home_assistant_hostname or "").strip() or socket.gethostname() return f"Home Assistant ({hostname})" class PyMCRepeaterApiClient: - """HTTP client for pyMC Repeater.""" + """HTTP client for openHop Repeater.""" def __init__( self, diff --git a/custom_components/pymc_repeater/binary_sensor.py b/custom_components/pymc_repeater/binary_sensor.py index 471f827..8eb9f90 100644 --- a/custom_components/pymc_repeater/binary_sensor.py +++ b/custom_components/pymc_repeater/binary_sensor.py @@ -1,4 +1,4 @@ -"""Binary sensors for pyMC Repeater.""" +"""Binary sensors for openHop Repeater.""" from __future__ import annotations @@ -18,7 +18,14 @@ from homeassistant.util import slugify from .const import DOMAIN -from .sensor import PyMCBaseEntity, _companion_items, _nested, _room_items +from .sensor import ( + PyMCBaseEntity, + _companion_items, + _external_sensor_identity, + _external_sensor_readings, + _nested, + _room_items, +) @dataclass(frozen=True, kw_only=True) @@ -126,7 +133,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up pyMC Repeater binary sensors.""" + """Set up openHop Repeater binary sensors.""" coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] entities: list[BinarySensorEntity] = [ PyMCBinarySensorEntity(entry, coordinator, description) @@ -151,11 +158,43 @@ async def async_setup_entry( if isinstance(companion, dict) and companion.get("companion_name"): entities.append(PyMCCompanionBridgeBinarySensor(entry, coordinator, companion)) + external_sensor_unique_ids: set[str] = set() + + def add_external_sensor_entities() -> None: + new_entities: list[BinarySensorEntity] = [] + for reading in _external_sensor_readings(coordinator.data): + if not isinstance(reading, dict) or not reading.get("name"): + continue + ok_entity = PyMCExternalSensorOkBinarySensor(entry, coordinator, reading) + ok_unique_id = str(ok_entity.unique_id) + if ok_unique_id not in external_sensor_unique_ids: + external_sensor_unique_ids.add(ok_unique_id) + new_entities.append(ok_entity) + + payload = reading.get("data") or {} + if not isinstance(payload, dict): + continue + for field, value in payload.items(): + if not isinstance(value, bool): + continue + flag_entity = PyMCExternalSensorFlagBinarySensor( + entry, coordinator, reading, str(field) + ) + flag_unique_id = str(flag_entity.unique_id) + if flag_unique_id in external_sensor_unique_ids: + continue + external_sensor_unique_ids.add(flag_unique_id) + new_entities.append(flag_entity) + if new_entities: + async_add_entities(new_entities) + async_add_entities(entities) + add_external_sensor_entities() + entry.async_on_unload(coordinator.async_add_listener(add_external_sensor_entities)) class PyMCBinarySensorEntity(PyMCBaseEntity, BinarySensorEntity): - """Representation of a pyMC Repeater binary sensor.""" + """Representation of an openHop Repeater binary sensor.""" entity_description: PyMCBinarySensorDescription @@ -359,3 +398,77 @@ def extra_state_attributes(self) -> dict[str, Any]: "contacts_count": companion.get("contacts_count"), "channels_count": companion.get("channels_count"), } + + +class PyMCExternalSensorOkBinarySensor(PyMCBaseEntity, BinarySensorEntity): + """Binary sensor showing whether a configured repeater sensor read succeeded.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_has_entity_name = True + _attr_icon = "mdi:check-network-outline" + + def __init__(self, entry: ConfigEntry, coordinator, reading: dict[str, Any]) -> None: + super().__init__(entry, coordinator) + self._identity = _external_sensor_identity(reading) + self._attr_name = f"Sensor {reading.get('name') or 'sensor'} OK" + self._attr_unique_id = ( + f"{entry.unique_id or entry.entry_id}_external_sensor_{self._identity}_ok" + ) + + def _get_reading(self) -> dict[str, Any] | None: + for reading in _external_sensor_readings(self.coordinator.data): + if isinstance(reading, dict) and _external_sensor_identity(reading) == self._identity: + return reading + return None + + @property + def available(self) -> bool: + return super().available and self._get_reading() is not None + + @property + def is_on(self) -> bool: + reading = self._get_reading() or {} + return bool(reading.get("ok")) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + reading = self._get_reading() or {} + return { + "sensor_name": reading.get("name"), + "sensor_type": reading.get("type"), + "timestamp": reading.get("timestamp"), + "error": reading.get("error"), + } + + +class PyMCExternalSensorFlagBinarySensor(PyMCExternalSensorOkBinarySensor): + """Binary sensor for boolean fields returned by repeater sensor plug-ins.""" + + _attr_icon = "mdi:toggle-switch-outline" + + def __init__( + self, + entry: ConfigEntry, + coordinator, + reading: dict[str, Any], + field: str, + ) -> None: + super().__init__(entry, coordinator, reading) + self._field = field + self._attr_name = f"Sensor {reading.get('name') or 'sensor'} {field.replace('_', ' ')}" + self._attr_unique_id = ( + f"{entry.unique_id or entry.entry_id}_external_sensor_{self._identity}_" + f"{slugify(field) or 'flag'}" + ) + + @property + def available(self) -> bool: + reading = self._get_reading() + payload = reading.get("data") if isinstance(reading, dict) else None + return super().available and isinstance(payload, dict) and self._field in payload + + @property + def is_on(self) -> bool: + reading = self._get_reading() or {} + payload = reading.get("data") or {} + return bool(payload.get(self._field)) if isinstance(payload, dict) else False diff --git a/custom_components/pymc_repeater/brand/icon.png b/custom_components/pymc_repeater/brand/icon.png index a465751..40bbaf2 100644 Binary files a/custom_components/pymc_repeater/brand/icon.png and b/custom_components/pymc_repeater/brand/icon.png differ diff --git a/custom_components/pymc_repeater/brand/icon@2x.png b/custom_components/pymc_repeater/brand/icon@2x.png index 0a8bff2..86ea965 100644 Binary files a/custom_components/pymc_repeater/brand/icon@2x.png and b/custom_components/pymc_repeater/brand/icon@2x.png differ diff --git a/custom_components/pymc_repeater/button.py b/custom_components/pymc_repeater/button.py index e2c9796..64e80a4 100644 --- a/custom_components/pymc_repeater/button.py +++ b/custom_components/pymc_repeater/button.py @@ -1,4 +1,4 @@ -"""Button entities for pyMC Repeater.""" +"""Button entities for openHop Repeater.""" from __future__ import annotations @@ -17,7 +17,7 @@ @dataclass(frozen=True, kw_only=True) class PyMCButtonDescription(ButtonEntityDescription): - """Description for a pyMC button.""" + """Description for an openHop button.""" press_fn: Callable[[object], Awaitable[object]] refresh_after: bool = True @@ -69,14 +69,14 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up pyMC Repeater buttons.""" + """Set up openHop Repeater buttons.""" coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] api = hass.data[DOMAIN][entry.entry_id]["api"] async_add_entities(PyMCButtonEntity(entry, coordinator, api, desc) for desc in BUTTONS) class PyMCButtonEntity(PyMCBaseEntity, ButtonEntity): - """A pyMC button entity.""" + """An openHop button entity.""" entity_description: PyMCButtonDescription diff --git a/custom_components/pymc_repeater/config_flow.py b/custom_components/pymc_repeater/config_flow.py index 16f2bff..a59fa4d 100644 --- a/custom_components/pymc_repeater/config_flow.py +++ b/custom_components/pymc_repeater/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for pyMC Repeater.""" +"""Config flow for openHop Repeater.""" from __future__ import annotations @@ -87,7 +87,7 @@ async def _async_validate_and_bootstrap( class PyMCRepeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for pyMC Repeater.""" + """Handle a config flow for openHop Repeater.""" VERSION = 1 @@ -188,7 +188,7 @@ def async_get_options_flow( class PyMCRepeaterOptionsFlow(config_entries.OptionsFlow): - """Handle pyMC Repeater options.""" + """Handle openHop Repeater options.""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: self.config_entry = config_entry diff --git a/custom_components/pymc_repeater/const.py b/custom_components/pymc_repeater/const.py index a56c1ff..c990f90 100644 --- a/custom_components/pymc_repeater/const.py +++ b/custom_components/pymc_repeater/const.py @@ -1,4 +1,4 @@ -"""Constants for the pyMC Repeater integration.""" +"""Constants for the openHop Repeater integration.""" from datetime import timedelta @@ -17,8 +17,8 @@ DEFAULT_PACKET_WINDOW_HOURS = 24 DEFAULT_UPTIME_UNIT = "hours" -MANUFACTURER = "pyMC" -MODEL = "pyMC Repeater" +MANUFACTURER = "openHop" +MODEL = "openHop Repeater" CLIENT_ID_PREFIX = "home-assistant" diff --git a/custom_components/pymc_repeater/coordinator.py b/custom_components/pymc_repeater/coordinator.py index 432f792..53c63c3 100644 --- a/custom_components/pymc_repeater/coordinator.py +++ b/custom_components/pymc_repeater/coordinator.py @@ -1,4 +1,4 @@ -"""Data coordinator for pyMC Repeater.""" +"""Data coordinator for openHop Repeater.""" from __future__ import annotations @@ -24,7 +24,7 @@ class PyMCRepeaterDataUpdateCoordinator(DataUpdateCoordinator[dict]): - """Coordinate pyMC Repeater polling.""" + """Coordinate openHop Repeater polling.""" def __init__( self, diff --git a/custom_components/pymc_repeater/diagnostics.py b/custom_components/pymc_repeater/diagnostics.py index ef3b5ee..17676ce 100644 --- a/custom_components/pymc_repeater/diagnostics.py +++ b/custom_components/pymc_repeater/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for pyMC Repeater.""" +"""Diagnostics support for openHop Repeater.""" from __future__ import annotations diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index fc73ad1..b1abb56 100644 --- a/custom_components/pymc_repeater/manifest.json +++ b/custom_components/pymc_repeater/manifest.json @@ -1,14 +1,14 @@ { "domain": "pymc_repeater", - "name": "pyMC Repeater", + "name": "openHop Repeater", "codeowners": [ - "@pyMC-dev" + "@openHop-dev" ], "config_flow": true, - "documentation": "https://github.com/pyMC-dev/pyMC-HA-Integration", + "documentation": "https://github.com/openhop-dev/openHop-HA-Integration", "integration_type": "device", "iot_class": "local_polling", - "issue_tracker": "https://github.com/pyMC-dev/pyMC-HA-Integration/issues", + "issue_tracker": "https://github.com/openhop-dev/openHop-HA-Integration/issues", "requirements": [], - "version": "1.1.3" + "version": "1.1.4" } diff --git a/custom_components/pymc_repeater/number.py b/custom_components/pymc_repeater/number.py index 34c814e..2b0bbe3 100644 --- a/custom_components/pymc_repeater/number.py +++ b/custom_components/pymc_repeater/number.py @@ -1,4 +1,4 @@ -"""Number entities for pyMC Repeater.""" +"""Number entities for openHop Repeater.""" from __future__ import annotations @@ -18,7 +18,7 @@ @dataclass(frozen=True, kw_only=True) class PyMCNumberDescription: - """Description for a pyMC number.""" + """Description for an openHop number.""" key: str name: str @@ -282,14 +282,14 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up pyMC Repeater numbers.""" + """Set up openHop Repeater numbers.""" coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] api = hass.data[DOMAIN][entry.entry_id]["api"] async_add_entities(PyMCNumberEntity(entry, coordinator, api, desc) for desc in NUMBERS) class PyMCNumberEntity(PyMCBaseEntity, NumberEntity): - """A pyMC number entity.""" + """An openHop number entity.""" _attr_entity_category = EntityCategory.CONFIG _attr_has_entity_name = True diff --git a/custom_components/pymc_repeater/select.py b/custom_components/pymc_repeater/select.py index 2cb86cc..590eee6 100644 --- a/custom_components/pymc_repeater/select.py +++ b/custom_components/pymc_repeater/select.py @@ -1,4 +1,4 @@ -"""Select entities for pyMC Repeater.""" +"""Select entities for openHop Repeater.""" from __future__ import annotations @@ -19,7 +19,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up pyMC Repeater selects.""" + """Set up openHop Repeater selects.""" coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] api = hass.data[DOMAIN][entry.entry_id]["api"] async_add_entities( diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index a22cac7..550c0ae 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -1,4 +1,4 @@ -"""Sensor platform for pyMC Repeater.""" +"""Sensor platform for openHop Repeater.""" from __future__ import annotations @@ -106,6 +106,35 @@ def _companion_items(data: dict[str, Any]) -> list[dict[str, Any]]: return payload if isinstance(payload, list) else [] +def _external_sensor_readings(data: dict[str, Any]) -> list[dict[str, Any]]: + readings = _nested(data, "stats", "sensors", "readings") or [] + return readings if isinstance(readings, list) else [] + + +def _external_sensor_identity(reading: dict[str, Any]) -> str: + name = str(reading.get("name") or "sensor") + sensor_type = str(reading.get("type") or "sensor") + return slugify(f"{name}_{sensor_type}") or "sensor" + + +def _external_sensor_data_items(data: dict[str, Any]) -> list[tuple[dict[str, Any], str, Any]]: + items: list[tuple[dict[str, Any], str, Any]] = [] + for reading in _external_sensor_readings(data): + if not isinstance(reading, dict): + continue + payload = reading.get("data") or {} + if not isinstance(payload, dict): + continue + for key, value in payload.items(): + if isinstance(value, (str, int, float)) and not isinstance(value, bool): + items.append((reading, str(key), value)) + return items + + +def _external_sensor_ok_count(data: dict[str, Any]) -> int: + return sum(1 for reading in _external_sensor_readings(data) if reading.get("ok")) + + def _running_companion_bridges(data: dict[str, Any]) -> int: return sum(1 for item in _companion_items(data) if item.get("is_running")) @@ -151,7 +180,7 @@ def _convert_data_size(value_bytes: float | int | None, unit: str) -> float | in @dataclass(frozen=True, kw_only=True) class PyMCSensorDescription(SensorEntityDescription): - """Describes a pyMC sensor.""" + """Describes an openHop sensor.""" value_fn: Callable[[dict[str, Any]], Any] attrs_fn: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None @@ -603,6 +632,31 @@ class PyMCSensorDescription(SensorEntityDescription): value_fn=_running_companion_bridges, attrs_fn=lambda data: {"companions": _companion_items(data)}, ), + PyMCSensorDescription( + key="external_sensors_loaded", + name="External sensors loaded", + icon="mdi:chip", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: _nested(data, "stats", "sensors", "loaded"), + attrs_fn=lambda data: { + "enabled": _nested(data, "stats", "sensors", "enabled"), + "configured": _nested(data, "stats", "sensors", "configured"), + "running": _nested(data, "stats", "sensors", "running"), + "poll_interval_seconds": _nested( + data, "stats", "sensors", "poll_interval_seconds" + ), + }, + ), + PyMCSensorDescription( + key="external_sensor_readings_ok", + name="External sensor readings OK", + icon="mdi:check-network-outline", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=_external_sensor_ok_count, + attrs_fn=lambda data: {"readings": _external_sensor_readings(data)}, + ), PyMCSensorDescription( key="room_servers", name="Room servers", @@ -927,7 +981,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up pyMC Repeater sensors.""" + """Set up openHop Repeater sensors.""" coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] entities: list[SensorEntity] = [ PyMCSensorEntity(entry, coordinator, description) for description in SENSORS @@ -968,11 +1022,29 @@ async def async_setup_entry( ] ) + external_sensor_unique_ids: set[str] = set() + + def add_external_sensor_entities() -> None: + new_entities: list[SensorEntity] = [] + for reading, field, _value in _external_sensor_data_items(coordinator.data): + if not isinstance(reading, dict) or not reading.get("name"): + continue + entity = PyMCExternalSensorMetricSensor(entry, coordinator, reading, field) + unique_id = str(entity.unique_id) + if unique_id in external_sensor_unique_ids: + continue + external_sensor_unique_ids.add(unique_id) + new_entities.append(entity) + if new_entities: + async_add_entities(new_entities) + async_add_entities(entities) + add_external_sensor_entities() + entry.async_on_unload(coordinator.async_add_listener(add_external_sensor_entities)) class PyMCBaseEntity(CoordinatorEntity): - """Common entity behavior for pyMC Repeater.""" + """Common entity behavior for openHop Repeater.""" _attr_has_entity_name = True @@ -994,7 +1066,7 @@ def device_info(self) -> DeviceInfo: class PyMCSensorEntity(PyMCBaseEntity, SensorEntity): - """Representation of a pyMC Repeater sensor.""" + """Representation of an openHop Repeater sensor.""" entity_description: PyMCSensorDescription @@ -1192,6 +1264,95 @@ def extra_state_attributes(self) -> dict[str, Any]: } +class PyMCExternalSensorMetricSensor(PyMCBaseEntity, SensorEntity): + """Dynamic sensor-manager metric from the repeater stats payload.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + _UNIT_HINTS = { + "battery_percent": PERCENTAGE, + "humidity_pct": PERCENTAGE, + "bus_voltage_v": "V", + "shunt_voltage_v": "V", + "battery_voltage_mv": "mV", + "vbus_voltage_mv": "mV", + "shunt_voltage_mv": "mV", + "current_ma": "mA", + "battery_current_ma": "mA", + "vbus_current_ma": "mA", + "power_mw": "mW", + "vbus_power_mw": "mW", + "remaining_capacity_mah": "mAh", + "time_to_empty_min": UnitOfTime.MINUTES, + "time_to_full_min": UnitOfTime.MINUTES, + "temperature_c": "°C", + "temperature_f": "°F", + } + _ICON_HINTS = { + "battery_percent": "mdi:battery", + "charge_state": "mdi:battery-sync-outline", + "temperature_c": "mdi:thermometer", + "temperature_f": "mdi:thermometer", + "humidity_pct": "mdi:water-percent", + } + + def __init__( + self, + entry: ConfigEntry, + coordinator, + reading: dict[str, Any], + field: str, + ) -> None: + super().__init__(entry, coordinator) + self._sensor_name = str(reading.get("name") or "sensor") + self._sensor_type = str(reading.get("type") or "sensor") + self._identity = _external_sensor_identity(reading) + self._field = field + friendly_field = field.replace("_", " ") + self._attr_name = f"Sensor {self._sensor_name} {friendly_field}" + self._attr_unique_id = ( + f"{entry.unique_id or entry.entry_id}_external_sensor_" + f"{self._identity}_{slugify(field) or 'value'}" + ) + self._attr_icon = self._ICON_HINTS.get(field, "mdi:chip") + self._attr_native_unit_of_measurement = self._UNIT_HINTS.get(field) + self._attr_state_class = ( + SensorStateClass.MEASUREMENT if self._attr_native_unit_of_measurement else None + ) + + def _get_reading(self) -> dict[str, Any] | None: + for reading in _external_sensor_readings(self.coordinator.data): + if not isinstance(reading, dict): + continue + if _external_sensor_identity(reading) == self._identity: + return reading + return None + + @property + def available(self) -> bool: + reading = self._get_reading() + payload = reading.get("data") if isinstance(reading, dict) else None + return super().available and isinstance(payload, dict) and self._field in payload + + @property + def native_value(self) -> Any: + reading = self._get_reading() or {} + payload = reading.get("data") or {} + return payload.get(self._field) if isinstance(payload, dict) else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + reading = self._get_reading() or {} + return { + "sensor_name": reading.get("name"), + "sensor_type": reading.get("type"), + "ok": reading.get("ok"), + "timestamp": reading.get("timestamp"), + "error": reading.get("error"), + } + + class PyMCDatabaseTableSensor(PyMCBaseEntity, SensorEntity): """Database table row count sensor.""" diff --git a/custom_components/pymc_repeater/services.yaml b/custom_components/pymc_repeater/services.yaml index 0c2fa08..3359c2a 100644 --- a/custom_components/pymc_repeater/services.yaml +++ b/custom_components/pymc_repeater/services.yaml @@ -20,7 +20,7 @@ ping_neighbor: room_post_message: name: Post room message - description: Post a message to a pyMC room server. + description: Post a message to an openHop room server. fields: config_entry_id: selector: @@ -128,7 +128,7 @@ save_cad_settings: db_purge: name: Purge database tables - description: Purge one or more pyMC database tables. + description: Purge one or more openHop database tables. fields: config_entry_id: selector: @@ -167,7 +167,7 @@ update_mqtt_config: companion_send_text: name: Companion send text - description: Send a text message to a target through a pyMC companion bridge. + description: Send a text message to a target through an openHop companion bridge. fields: config_entry_id: selector: @@ -381,7 +381,7 @@ get_broker_presets: get_logs: name: Get logs - description: Return the current pyMC repeater log buffer. + description: Return the current openHop repeater log buffer. fields: config_entry_id: selector: diff --git a/custom_components/pymc_repeater/switch.py b/custom_components/pymc_repeater/switch.py index 730ade4..efe2ce9 100644 --- a/custom_components/pymc_repeater/switch.py +++ b/custom_components/pymc_repeater/switch.py @@ -1,4 +1,4 @@ -"""Switch entities for pyMC Repeater.""" +"""Switch entities for openHop Repeater.""" from __future__ import annotations @@ -17,7 +17,7 @@ @dataclass(frozen=True, kw_only=True) class PyMCSwitchDescription: - """Description for a pyMC switch.""" + """Description for an openHop switch.""" key: str name: str @@ -88,14 +88,14 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up pyMC Repeater switches.""" + """Set up openHop Repeater switches.""" coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] api = hass.data[DOMAIN][entry.entry_id]["api"] async_add_entities(PyMCSwitchEntity(entry, coordinator, api, desc) for desc in SWITCHES) class PyMCSwitchEntity(PyMCBaseEntity, SwitchEntity): - """A pyMC switch entity.""" + """An openHop switch entity.""" _attr_entity_category = EntityCategory.CONFIG _attr_has_entity_name = True diff --git a/custom_components/pymc_repeater/translations/en.json b/custom_components/pymc_repeater/translations/en.json index 3007402..cc48cb8 100644 --- a/custom_components/pymc_repeater/translations/en.json +++ b/custom_components/pymc_repeater/translations/en.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Connect to pyMC Repeater", + "title": "Connect to openHop Repeater", "description": "Enter the repeater connection details. Home Assistant will log in once, create a dedicated API token, and then store only that token.", "data": { "host": "Repeater IP address or hostname", @@ -11,7 +11,7 @@ } }, "reauth_confirm": { - "title": "Reconnect to pyMC Repeater", + "title": "Reconnect to openHop Repeater", "description": "The saved API token is no longer valid. Enter the current admin password to create a new token.", "data": { "password": "Admin password" @@ -32,8 +32,8 @@ "options": { "step": { "init": { - "title": "pyMC Repeater options", - "description": "Adjust how selected pyMC Repeater values are shown in Home Assistant.", + "title": "openHop Repeater options", + "description": "Adjust how selected openHop Repeater values are shown in Home Assistant.", "data": { "data_size_unit": "Data size display unit", "uptime_unit": "System uptime display unit" diff --git a/dashboards/pymc_repeater_dashboard.yaml b/dashboards/pymc_repeater_dashboard.yaml index bcee31a..e85393a 100644 --- a/dashboards/pymc_repeater_dashboard.yaml +++ b/dashboards/pymc_repeater_dashboard.yaml @@ -1,4 +1,4 @@ -# pyMC Repeater dashboard template +# openHop Repeater dashboard template # # Replace every instance of REPEATER_SLUG with your real entity prefix. # Example: @@ -13,7 +13,7 @@ # view below, swap the example placeholders for the broker/companion entity IDs # that exist in your Home Assistant instance. -title: pyMC Repeater +title: openHop Repeater views: - title: Overview path: overview @@ -22,7 +22,7 @@ views: cards: - type: markdown content: | - # pyMC Repeater + # openHop Repeater Clean operational view for radio health, MQTT state, packet flow, and system load. - type: grid diff --git a/hacs.json b/hacs.json index 06466a7..ede1912 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { - "name": "pyMC Repeater", + "name": "openHop Repeater", "render_readme": true, "homeassistant": "2024.1.0" }