This commit is contained in:
1 parent
d663f3336b
commit
c9d257a442
11 files changed
+138
-25
No files matched your search
@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .device import parse_device_meta, sync_device_registry
|
||||
from .const import CONF_WEBHOOK_ID, DOMAIN, PLATFORMS, HaProbeRuntimeData, signal_entities
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -49,6 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HaProbeConfigEntry) -> b
|
||||
display_name = device.get("haDisplayName")
|
||||
if ha_id and display_name:
|
||||
entry.runtime_data.devices[ha_id] = display_name
|
||||
meta = parse_device_meta(device)
|
||||
entry.runtime_data.device_meta[ha_id] = meta
|
||||
sync_device_registry(hass, entry.entry_id, ha_id, meta)
|
||||
|
||||
entities = payload.get("entities") or ()
|
||||
if entities:
|
||||
|
||||
@@ -27,4 +27,5 @@ class HaProbeRuntimeData:
|
||||
|
||||
webhook_url: str = ""
|
||||
devices: dict[str, str] = field(default_factory=dict)
|
||||
device_meta: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
entities: dict[str, "HaProbeSensor"] = field(default_factory=dict)
|
||||
@@ -5,7 +5,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"dependencies": ["webhook"],
|
||||
"codeowners": [],
|
||||
"codeowners": ["gerviba"],
|
||||
"requirements": [],
|
||||
"config_flow": true
|
||||
}
|
||||
@@ -10,6 +10,7 @@ updated in place and never re-created.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
@@ -18,9 +19,11 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import HaProbeConfigEntry
|
||||
from .const import DOMAIN, signal_entities
|
||||
from .const import signal_entities
|
||||
from .device import build_device_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -172,12 +175,66 @@ def _resolve_device_class(device_class_name: str | None) -> SensorDeviceClass |
|
||||
"""Map a probe SensorDeviceClass constant name to HA's enum member."""
|
||||
if device_class_name is None:
|
||||
return None
|
||||
if device_class_name == "ENUM":
|
||||
# HA's enum device class demands a fixed `options` list up front, which
|
||||
# the probe can't know (container states, health values, ...). Without
|
||||
# it the state write raises and the entity shows "unknown", so expose
|
||||
# these as plain text sensors instead.
|
||||
return None
|
||||
device_class = SensorDeviceClass.__members__.get(device_class_name)
|
||||
if device_class is None:
|
||||
_LOGGER.warning("Unknown HA Probe device class '%s'", device_class_name)
|
||||
return device_class
|
||||
|
||||
|
||||
# Python's datetime parsing only copes with up to 6 fractional digits, but the
|
||||
# probe sends nanosecond-precision timestamps (e.g. Docker's StartedAt).
|
||||
_EXTRA_FRACTION = re.compile(r"(\.\d{6})\d+")
|
||||
|
||||
|
||||
def _coerce_state(device_class: SensorDeviceClass | None, state: Any) -> Any:
|
||||
"""Turn the probe's text timestamps into the datetime HA requires."""
|
||||
if device_class != SensorDeviceClass.TIMESTAMP or not isinstance(state, str):
|
||||
return state
|
||||
parsed = dt_util.parse_datetime(_EXTRA_FRACTION.sub(r"\1", state))
|
||||
if parsed is None:
|
||||
_LOGGER.warning("Unparseable HA Probe timestamp '%s'", state)
|
||||
return None
|
||||
return dt_util.as_utc(parsed)
|
||||
|
||||
|
||||
def _display_precision(
|
||||
device_class: SensorDeviceClass | None, unit: str | None
|
||||
) -> int | None:
|
||||
"""Decimals to show: 0 for percentages, at most 2 for sizes, else HA's default."""
|
||||
if unit == PERCENTAGE:
|
||||
return 0
|
||||
if device_class in (SensorDeviceClass.DATA_SIZE, SensorDeviceClass.VOLUME_STORAGE):
|
||||
return 2
|
||||
return None
|
||||
|
||||
|
||||
# Seconds per unit, largest first, for picking a readable duration unit.
|
||||
_DURATION_STEPS: tuple[tuple[str, float], ...] = (("d", 86400), ("h", 3600), ("min", 60))
|
||||
|
||||
|
||||
def _scale_duration(
|
||||
device_class: SensorDeviceClass | None, unit: str | None, state: Any
|
||||
) -> tuple[Any, str | None]:
|
||||
"""Show second-based durations in the largest unit that keeps them >= 1."""
|
||||
if (
|
||||
device_class != SensorDeviceClass.DURATION
|
||||
or unit != "s"
|
||||
or isinstance(state, bool)
|
||||
or not isinstance(state, (int, float))
|
||||
):
|
||||
return state, unit
|
||||
for symbol, seconds in _DURATION_STEPS:
|
||||
if abs(state) >= seconds:
|
||||
return round(state / seconds, 2), symbol
|
||||
return state, unit
|
||||
|
||||
|
||||
def _fallback_name(entity_id: str) -> str:
|
||||
"""Make a readable name out of an id when no schema metadata is known yet."""
|
||||
return entity_id.rsplit("__", 1)[-1].replace("_", " ").title()
|
||||
@@ -215,11 +272,18 @@ async def async_setup_entry(
|
||||
else entry.title
|
||||
)
|
||||
|
||||
resolved_ha_id = device_ha_id or "probe"
|
||||
device_info = build_device_info(
|
||||
entry.entry_id,
|
||||
resolved_ha_id,
|
||||
device_name,
|
||||
runtime_data.device_meta.get(resolved_ha_id, {}),
|
||||
)
|
||||
|
||||
sensor = HaProbeSensor(
|
||||
entry_id=entry.entry_id,
|
||||
entity_key=entity_id,
|
||||
device_ha_id=device_ha_id or "probe",
|
||||
device_name=device_name,
|
||||
device_info=device_info,
|
||||
initial_state=state,
|
||||
name=entity.get("name") or _fallback_name(entity_id),
|
||||
device_class=_resolve_device_class(entity.get("deviceClass")),
|
||||
@@ -229,6 +293,8 @@ async def async_setup_entry(
|
||||
new_sensors.append(sensor)
|
||||
|
||||
if new_sensors:
|
||||
# Register parent devices before the container devices nested under them.
|
||||
new_sensors.sort(key=lambda sensor: "via_device" in sensor.device_info)
|
||||
async_add_entities(new_sensors)
|
||||
|
||||
entry.async_on_unload(
|
||||
@@ -247,8 +313,7 @@ class HaProbeSensor(SensorEntity):
|
||||
*,
|
||||
entry_id: str,
|
||||
entity_key: str,
|
||||
device_ha_id: str,
|
||||
device_name: str,
|
||||
device_info: DeviceInfo,
|
||||
initial_state: Any,
|
||||
name: str,
|
||||
device_class: SensorDeviceClass | None,
|
||||
@@ -258,13 +323,18 @@ class HaProbeSensor(SensorEntity):
|
||||
self._attr_unique_id = f"{entry_id}_{entity_key}"
|
||||
self._attr_name = name
|
||||
self._attr_device_class = device_class
|
||||
self._probe_unit = unit
|
||||
self._apply_state(initial_state)
|
||||
self._attr_device_info = device_info
|
||||
|
||||
def _apply_state(self, state: Any) -> None:
|
||||
"""Set value, unit and precision from a raw probe state."""
|
||||
value = _coerce_state(self._attr_device_class, state)
|
||||
value, unit = _scale_duration(self._attr_device_class, self._probe_unit, value)
|
||||
self._attr_native_value = value
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_native_value = initial_state
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{entry_id}_{device_ha_id}")},
|
||||
name=device_name,
|
||||
manufacturer="gerviba/systemmonitor",
|
||||
model="Probe",
|
||||
self._attr_suggested_display_precision = _display_precision(
|
||||
self._attr_device_class, unit
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -275,8 +345,6 @@ class HaProbeSensor(SensorEntity):
|
||||
restarted it will resend full metadata too - pick that back up
|
||||
instead of only ever trusting the first value we saw.
|
||||
"""
|
||||
self._attr_native_value = state
|
||||
|
||||
name = entity.get("name")
|
||||
if name:
|
||||
self._attr_name = name
|
||||
@@ -287,7 +355,9 @@ class HaProbeSensor(SensorEntity):
|
||||
|
||||
unit_name = entity.get("unit")
|
||||
if unit_name:
|
||||
self._attr_native_unit_of_measurement = _resolve_unit(unit_name)
|
||||
self._probe_unit = _resolve_unit(unit_name)
|
||||
|
||||
self._apply_state(state)
|
||||
|
||||
if self.hass is not None:
|
||||
self.async_write_ha_state()
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA Probe",
|
||||
"description": "Set up a webhook to receive pushes from a systemmonitor probe. Put this webhook ID into the probe's config.json as \"webhookToken\".",
|
||||
"description": "Set up a webhook to receive pushes from a probe. Put this webhook ID into the probe's config.json as \"webhookToken\".",
|
||||
"data": {
|
||||
"name": "Integration Name",
|
||||
"webhook_id": "Webhook ID"
|
||||
|
||||
Reference in new issue
Block a user