diff --git a/CHANGELOG.md b/CHANGELOG.md index 364b34c..1eff77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.2 + +- fixed the `Packets received per hour` and `Packets forwarded per hour` sensors to use `/api/packet_stats?hours=1` instead of the repeater status counters backed by the capped `recent_packets` buffer +- added newer GPS diagnostics fields from recent repeater API updates, including position source metadata and GPS time sync state +- added Home Assistant actions for `adverts_by_contact_type` and `adverts_count_by_contact_type` + ## 1.1.1 - stopped polling GitHub branch data every 60 seconds by removing `update_channels` from the normal integration refresh loop diff --git a/custom_components/pymc_repeater/__init__.py b/custom_components/pymc_repeater/__init__.py index d290678..a4fc48f 100644 --- a/custom_components/pymc_repeater/__init__.py +++ b/custom_components/pymc_repeater/__init__.py @@ -49,6 +49,8 @@ SERVICE_GET_RECENT_PACKETS = "get_recent_packets" SERVICE_GET_FILTERED_PACKETS = "get_filtered_packets" SERVICE_GET_PACKET_BY_HASH = "get_packet_by_hash" +SERVICE_GET_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" SERVICE_REMOVE_ACL_CLIENT = "remove_acl_client" SERVICE_GET_ROOM_MESSAGES = "get_room_messages" @@ -610,6 +612,52 @@ async def _with_api_response( supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_ADVERTS_BY_CONTACT_TYPE, + lambda call: _with_api_response( + call, + lambda api, _: api.async_get_adverts_by_contact_type( + contact_type=call.data["contact_type"], + limit=call.data.get("limit", 100), + offset=call.data.get("offset", 0), + hours=call.data.get("hours"), + ), + always_return=True, + ), + schema=vol.Schema( + { + vol.Optional(CONF_ENTRY_ID): str, + vol.Required("contact_type"): str, + vol.Optional("limit", default=100): vol.Coerce(int), + vol.Optional("offset", default=0): vol.Coerce(int), + vol.Optional("hours"): vol.Coerce(int), + } + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_ADVERTS_COUNT_BY_CONTACT_TYPE, + 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, + vol.Required("contact_type"): str, + vol.Optional("hours"): vol.Coerce(int), + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_GET_ACL_CLIENTS, diff --git a/custom_components/pymc_repeater/api.py b/custom_components/pymc_repeater/api.py index 8df3269..68a2b51 100644 --- a/custom_components/pymc_repeater/api.py +++ b/custom_components/pymc_repeater/api.py @@ -130,6 +130,7 @@ async def async_fetch_all(self) -> dict[str, Any]: "hardware_processes": self.async_get_hardware_processes(), "mqtt_status": self.async_get_mqtt_status(), "packet_stats": self.async_get_packet_stats(), + "packet_stats_1h": self.async_get_packet_stats(hours=1), "route_stats": self.async_get_route_stats(), "noise_floor_stats": self.async_get_noise_floor_stats(), "crc_error_count": self.async_get_crc_error_count(), @@ -176,12 +177,14 @@ async def async_get_hardware_processes(self) -> dict[str, Any]: """Return process summary stats.""" return await self._async_request_wrapped("GET", "/api/hardware_processes") - async def async_get_packet_stats(self) -> dict[str, Any]: + async def async_get_packet_stats( + self, *, hours: int = DEFAULT_PACKET_WINDOW_HOURS + ) -> dict[str, Any]: """Return packet stats.""" return await self._async_request_wrapped( "GET", "/api/packet_stats", - params={"hours": DEFAULT_PACKET_WINDOW_HOURS}, + params={"hours": hours}, ) async def async_get_route_stats(self) -> dict[str, Any]: @@ -316,6 +319,56 @@ async def async_get_filtered_packets( "filters": payload.get("filters"), } + async def async_get_adverts_by_contact_type( + self, + *, + contact_type: str, + limit: int = 100, + offset: int = 0, + hours: int | None = None, + ) -> dict[str, Any]: + """Return adverts for one contact type.""" + params: dict[str, Any] = { + "contact_type": contact_type, + "limit": limit, + "offset": offset, + } + if hours is not None: + params["hours"] = hours + + payload = await self._async_request_json( + "GET", + "/api/adverts_by_contact_type", + params=params, + auth="api_token", + ) + if payload.get("success") is False: + raise PyMCRepeaterApiError( + payload.get("error", "Failed to fetch adverts by contact type") + ) + adverts = payload.get("data", []) + return { + "adverts": adverts, + "count": payload.get("count", len(adverts)), + "filters": payload.get("filters"), + } + + async def async_get_adverts_count_by_contact_type( + self, + *, + contact_type: str, + hours: int | None = None, + ) -> dict[str, Any]: + """Return the total advert count for one contact type.""" + params: dict[str, Any] = {"contact_type": contact_type} + if hours is not None: + params["hours"] = hours + return await self._async_request_wrapped( + "GET", + "/api/adverts_count_by_contact_type", + params=params, + ) + async def async_get_packet_by_hash(self, packet_hash: str) -> dict[str, Any]: """Return one stored packet by packet hash.""" payload = await self._async_request_json( diff --git a/custom_components/pymc_repeater/binary_sensor.py b/custom_components/pymc_repeater/binary_sensor.py index 7e7d9a5..471f827 100644 --- a/custom_components/pymc_repeater/binary_sensor.py +++ b/custom_components/pymc_repeater/binary_sensor.py @@ -111,6 +111,13 @@ def _any_mqtt_connected(data: dict[str, Any]) -> bool: entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: bool(_nested(data, "gps", "location_update", "enabled")), ), + PyMCBinarySensorDescription( + key="gps_time_sync_enabled", + name="GPS time sync enabled", + icon="mdi:clock-sync-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: bool(_nested(data, "gps", "time_sync", "enabled")), + ), ) diff --git a/custom_components/pymc_repeater/manifest.json b/custom_components/pymc_repeater/manifest.json index 8dbbf11..558a9a5 100644 --- a/custom_components/pymc_repeater/manifest.json +++ b/custom_components/pymc_repeater/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pyMC-dev/pyMC-HA-Integration/issues", "requirements": [], - "version": "1.1.1" + "version": "1.1.2" } diff --git a/custom_components/pymc_repeater/sensor.py b/custom_components/pymc_repeater/sensor.py index af06196..a22cac7 100644 --- a/custom_components/pymc_repeater/sensor.py +++ b/custom_components/pymc_repeater/sensor.py @@ -76,6 +76,20 @@ def _update_channel_options(data: dict[str, Any]) -> list[str]: return options +def _gps_position_attrs(data: dict[str, Any]) -> dict[str, Any]: + return { + "gps_position": _nested(data, "gps", "gps_position"), + "manual_position": _nested(data, "gps", "manual_position"), + "position_source": _nested(data, "gps", "position_meta", "source"), + "position_source_label": _nested(data, "gps", "position_meta", "source_label"), + "position_policy": _nested(data, "gps", "position_meta", "policy"), + "manual_config_available": _nested( + data, "gps", "position_meta", "manual_config_available" + ), + "gps_fix_valid": _nested(data, "gps", "position_meta", "gps_fix_valid"), + } + + def _transport_key_count(data: dict[str, Any]) -> int: keys = data.get("transport_keys") or [] return len(keys) if isinstance(keys, list) else 0 @@ -211,8 +225,19 @@ class PyMCSensorDescription(SensorEntityDescription): "last_update": _nested(data, "gps", "status", "last_update"), "last_error": _nested(data, "gps", "status", "last_error"), "source": _nested(data, "gps", "source"), + "time_sync_state": _nested(data, "gps", "time_sync", "state"), + **_gps_position_attrs(data), }, ), + PyMCSensorDescription( + key="gps_position_source", + name="GPS position source", + icon="mdi:crosshairs-question", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: _nested(data, "gps", "position_meta", "source_label") + or _nested(data, "gps", "position_meta", "source"), + attrs_fn=_gps_position_attrs, + ), PyMCSensorDescription( key="gps_quality", name="GPS quality", @@ -233,6 +258,7 @@ class PyMCSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: _nested(data, "gps", "position", "latitude"), + attrs_fn=_gps_position_attrs, ), PyMCSensorDescription( key="gps_longitude", @@ -241,6 +267,7 @@ class PyMCSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: _nested(data, "gps", "position", "longitude"), + attrs_fn=_gps_position_attrs, ), PyMCSensorDescription( key="gps_altitude", @@ -341,6 +368,23 @@ class PyMCSensorDescription(SensorEntityDescription): "date": _nested(data, "gps", "time", "date"), }, ), + PyMCSensorDescription( + key="gps_time_sync_state", + name="GPS time sync state", + icon="mdi:clock-sync-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: _nested(data, "gps", "time_sync", "state"), + attrs_fn=lambda data: { + "enabled": _nested(data, "gps", "time_sync", "enabled"), + "last_attempt": _nested(data, "gps", "time_sync", "last_attempt"), + "last_success": _nested(data, "gps", "time_sync", "last_success"), + "last_error": _nested(data, "gps", "time_sync", "last_error"), + "last_gps_time": _nested(data, "gps", "time_sync", "last_gps_time"), + "last_offset_seconds": _nested( + data, "gps", "time_sync", "last_offset_seconds" + ), + }, + ), PyMCSensorDescription( key="gps_location_update_state", name="GPS location update state", @@ -575,7 +619,14 @@ class PyMCSensorDescription(SensorEntityDescription): icon="mdi:download-network-outline", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: _nested(data, "stats", "rx_per_hour"), + value_fn=lambda data: _nested(data, "packet_stats_1h", "total_packets") + or _nested(data, "stats", "rx_per_hour"), + attrs_fn=lambda data: { + "source": "packet_stats_1h" + if _nested(data, "packet_stats_1h", "total_packets") is not None + else "stats", + "legacy_stats_rx_per_hour": _nested(data, "stats", "rx_per_hour"), + }, ), PyMCSensorDescription( key="forwarded_per_hour", @@ -584,7 +635,16 @@ class PyMCSensorDescription(SensorEntityDescription): icon="mdi:upload-network-outline", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: _nested(data, "stats", "forwarded_per_hour"), + value_fn=lambda data: _nested(data, "packet_stats_1h", "transmitted_packets") + or _nested(data, "stats", "forwarded_per_hour"), + attrs_fn=lambda data: { + "source": "packet_stats_1h" + if _nested(data, "packet_stats_1h", "transmitted_packets") is not None + else "stats", + "legacy_stats_forwarded_per_hour": _nested( + data, "stats", "forwarded_per_hour" + ), + }, ), PyMCSensorDescription( key="radio_utilization", diff --git a/custom_components/pymc_repeater/services.yaml b/custom_components/pymc_repeater/services.yaml index d36aff2..903f0c9 100644 --- a/custom_components/pymc_repeater/services.yaml +++ b/custom_components/pymc_repeater/services.yaml @@ -444,6 +444,58 @@ get_packet_by_hash: selector: text: +get_adverts_by_contact_type: + name: Get adverts by contact type + description: Return adverts for one contact type, with paging support. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + contact_type: + required: true + selector: + text: + limit: + default: 100 + selector: + number: + min: 1 + max: 5000 + mode: box + offset: + default: 0 + selector: + number: + min: 0 + max: 100000 + mode: box + hours: + selector: + number: + min: 1 + max: 8760 + mode: box + +get_adverts_count_by_contact_type: + name: Get advert count by contact type + description: Return the total advert count for one contact type. + fields: + config_entry_id: + selector: + config_entry: + integration: pymc_repeater + contact_type: + required: true + selector: + text: + hours: + selector: + number: + min: 1 + max: 8760 + mode: box + get_acl_clients: name: Get ACL clients description: Return authenticated ACL clients, optionally filtered to one identity.