Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
48 changes: 48 additions & 0 deletions custom_components/pymc_repeater/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 55 additions & 2 deletions custom_components/pymc_repeater/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions custom_components/pymc_repeater/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
),
)


Expand Down
2 changes: 1 addition & 1 deletion custom_components/pymc_repeater/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
64 changes: 62 additions & 2 deletions custom_components/pymc_repeater/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions custom_components/pymc_repeater/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down