From c9d257a44295d2ba30195e2590466e0aa60b63fb Mon Sep 17 00:00:00 2001 From: gerviba Date: Sun, 1 Nov 2026 22:44:02 +0100 Subject: [PATCH] Improve device handling --- config.json | 3 +- custom_components/ha_probe/__init__.py | 4 + custom_components/ha_probe/const.py | 1 + custom_components/ha_probe/manifest.json | 2 +- custom_components/ha_probe/sensor.py | 98 ++++++++++++++++--- .../ha_probe/translations/en.json | 2 +- probe | 1 + .../kotlin/hu/gerviba/probe/ConfigLoader.kt | 9 ++ .../hu/gerviba/probe/ProbeApplication.kt | 3 + .../kotlin/hu/gerviba/probe/api/ApiService.kt | 20 +++- .../hu/gerviba/probe/sensor/DockerSensor.kt | 20 ++-- 11 files changed, 138 insertions(+), 25 deletions(-) diff --git a/config.json b/config.json index 9c0f056..98b7dc7 100644 --- a/config.json +++ b/config.json @@ -144,7 +144,8 @@ "devices": [ { "haId": "default", - "haDisplayName": "lab1" + "haDisplayName": "lab1", + "manufacturer": "gerviba/systemmonitor" } ], "services": [ diff --git a/custom_components/ha_probe/__init__.py b/custom_components/ha_probe/__init__.py index 1fbdcc2..9a255fc 100644 --- a/custom_components/ha_probe/__init__.py +++ b/custom_components/ha_probe/__init__.py @@ -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: diff --git a/custom_components/ha_probe/const.py b/custom_components/ha_probe/const.py index f3009e0..2c1e28f 100644 --- a/custom_components/ha_probe/const.py +++ b/custom_components/ha_probe/const.py @@ -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) diff --git a/custom_components/ha_probe/manifest.json b/custom_components/ha_probe/manifest.json index cd4bfdb..ae12990 100644 --- a/custom_components/ha_probe/manifest.json +++ b/custom_components/ha_probe/manifest.json @@ -5,7 +5,7 @@ "integration_type": "device", "iot_class": "local_push", "dependencies": ["webhook"], - "codeowners": [], + "codeowners": ["gerviba"], "requirements": [], "config_flow": true } diff --git a/custom_components/ha_probe/sensor.py b/custom_components/ha_probe/sensor.py index f756b97..c997875 100644 --- a/custom_components/ha_probe/sensor.py +++ b/custom_components/ha_probe/sensor.py @@ -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() diff --git a/custom_components/ha_probe/translations/en.json b/custom_components/ha_probe/translations/en.json index 3601983..2ae8950 100644 --- a/custom_components/ha_probe/translations/en.json +++ b/custom_components/ha_probe/translations/en.json @@ -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" diff --git a/probe b/probe index 29e41c2..058851f 100644 --- a/probe +++ b/probe @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Wrapper for probe.jar - passes all arguments through. +# sudo install -m 755 probe /usr/local/bin/probe PROBE_JAR="/home/clustermaster/storage/cdn/serve/internal/probe/probe.jar" diff --git a/src/main/kotlin/hu/gerviba/probe/ConfigLoader.kt b/src/main/kotlin/hu/gerviba/probe/ConfigLoader.kt index 26e0804..2123a4f 100644 --- a/src/main/kotlin/hu/gerviba/probe/ConfigLoader.kt +++ b/src/main/kotlin/hu/gerviba/probe/ConfigLoader.kt @@ -36,6 +36,15 @@ data class SensorRecord( data class DeviceRecord( val haId: String, val haDisplayName: String, + val manufacturer: String? = null, + val model: String? = null, + val configurationUrl: String? = null, + /** Name or id of a Docker container this device represents. Nested under the host device. */ + val dockerContainer: String? = null, + /** Filled in at runtime with the container's Docker image. */ + val swVersion: String? = null, + /** Filled in at runtime: haId of the host device a container device is nested under. */ + val viaDevice: String? = null, ) @Serializable diff --git a/src/main/kotlin/hu/gerviba/probe/ProbeApplication.kt b/src/main/kotlin/hu/gerviba/probe/ProbeApplication.kt index 31e3572..12a2a56 100644 --- a/src/main/kotlin/hu/gerviba/probe/ProbeApplication.kt +++ b/src/main/kotlin/hu/gerviba/probe/ProbeApplication.kt @@ -37,6 +37,7 @@ class ProbeApplication { private lateinit var config: ConfigRecord private lateinit var apiService: ApiService + private lateinit var deviceService: DeviceService private lateinit var scheduler: SchedulerService private lateinit var basicSchema: List>> @@ -44,6 +45,7 @@ class ProbeApplication { val configLoader = ConfigLoader() config = configLoader.loadConfig() apiService = ApiService(config) + deviceService = DeviceService(apiService) sensors.addAll(listOf( FolderSizeSensor(config), @@ -69,6 +71,7 @@ class ProbeApplication { fun setupSensors() { sensors.forEach { it.onConfig() } basicSchema = sensors.map { it to it.getMeasurementSchemas() } + deviceService.start() log.info("Sending schemas") apiService.updateMeasurements(basicSchema.flatMap { it.second }) log.info("") diff --git a/src/main/kotlin/hu/gerviba/probe/api/ApiService.kt b/src/main/kotlin/hu/gerviba/probe/api/ApiService.kt index 5a79f08..ad9fb7a 100644 --- a/src/main/kotlin/hu/gerviba/probe/api/ApiService.kt +++ b/src/main/kotlin/hu/gerviba/probe/api/ApiService.kt @@ -1,6 +1,7 @@ package hu.gerviba.probe.api import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.DeviceLinker import hu.gerviba.probe.DeviceRecord import io.ktor.client.* import io.ktor.client.engine.cio.* @@ -29,12 +30,29 @@ class ApiService( } } + @Volatile + private var devices: List = DeviceLinker.link(config) + + fun currentDevices(): List = devices + + /** Replaces the device list and pushes it to Home Assistant straight away. */ + fun updateDevices(newDevices: List) { + devices = newDevices + log.info("sending device updates") + sendPostRequestToWebhook(newDevices, emptyList()) + } + + /** Replaces the device list without pushing it; the next measurement push carries it. */ + fun setDevicesSilently(newDevices: List) { + devices = newDevices + } + fun updateMeasurement(measurement: Measurement) { updateMeasurements(listOf(measurement)) } fun updateMeasurements(measurements: List) { - updateMeasurements(config.devices, measurements) + updateMeasurements(devices, measurements) } fun updateMeasurements(devices: List, measurements: List) { diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/DockerSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/DockerSensor.kt index 13706e5..7237245 100644 --- a/src/main/kotlin/hu/gerviba/probe/sensor/DockerSensor.kt +++ b/src/main/kotlin/hu/gerviba/probe/sensor/DockerSensor.kt @@ -282,7 +282,7 @@ private class DockerMeasurementFactory( Measurement( id = cpuUsage, name = "$haDisplayName cpu usage", - deviceClass = SensorDeviceClass.DATA_SIZE, + deviceClass = null, unit = UnitOfMeasurement.PERCENTAGE, device = device, ), @@ -314,19 +314,23 @@ private class DockerMeasurementFactory( return sequenceOf( Measurement( id = startedAt, - textValue = container.startedAt + textValue = container.startedAt, + device = device, ), Measurement( id = cpuUsage, - value = container.cpuUsage + value = container.cpuUsage, + device = device, ), Measurement( id = memoryUsage, - value = container.memoryUsage.toDouble() + value = container.memoryUsage.toDouble(), + device = device, ), Measurement( id = health, - textValue = container.health + textValue = container.health, + device = device, ), ) } @@ -334,7 +338,8 @@ private class DockerMeasurementFactory( fun measurementForState(container: DockerSensor.ContainerData): Measurement { return Measurement( id = state, - textValue = container.status + textValue = container.status, + device = device, ) } @@ -343,7 +348,8 @@ private class DockerMeasurementFactory( return container.health?.let { Measurement( id = health, - textValue = it + textValue = it, + device = device, ) } }