Improve device handling
Build probe jar / build (push) Failing after 1m33s

This commit is contained in:
gerviba committed 2026-11-01 22:44:02 +01:00
1 parent d663f3336b
commit c9d257a442
11 files changed
+138 -25

No files matched your search

+2 -1
View File
@@ -144,7 +144,8 @@
"devices": [ "devices": [
{ {
"haId": "default", "haId": "default",
"haDisplayName": "lab1" "haDisplayName": "lab1",
"manufacturer": "gerviba/systemmonitor"
} }
], ],
"services": [ "services": [
+4
View File
@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send 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 from .const import CONF_WEBHOOK_ID, DOMAIN, PLATFORMS, HaProbeRuntimeData, signal_entities
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -49,6 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HaProbeConfigEntry) -> b
display_name = device.get("haDisplayName") display_name = device.get("haDisplayName")
if ha_id and display_name: if ha_id and display_name:
entry.runtime_data.devices[ha_id] = 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 () entities = payload.get("entities") or ()
if entities: if entities:
+1
View File
@@ -27,4 +27,5 @@ class HaProbeRuntimeData:
webhook_url: str = "" webhook_url: str = ""
devices: dict[str, str] = field(default_factory=dict) 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) entities: dict[str, "HaProbeSensor"] = field(default_factory=dict)
+1 -1
View File
@@ -5,7 +5,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"dependencies": ["webhook"], "dependencies": ["webhook"],
"codeowners": [], "codeowners": ["gerviba"],
"requirements": [], "requirements": [],
"config_flow": true "config_flow": true
} }
+84 -14
View File
@@ -10,6 +10,7 @@ updated in place and never re-created.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Any from typing import Any
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 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.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import HaProbeConfigEntry from . import HaProbeConfigEntry
from .const import DOMAIN, signal_entities from .const import signal_entities
from .device import build_device_info
_LOGGER = logging.getLogger(__name__) _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.""" """Map a probe SensorDeviceClass constant name to HA's enum member."""
if device_class_name is None: if device_class_name is None:
return 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) device_class = SensorDeviceClass.__members__.get(device_class_name)
if device_class is None: if device_class is None:
_LOGGER.warning("Unknown HA Probe device class '%s'", device_class_name) _LOGGER.warning("Unknown HA Probe device class '%s'", device_class_name)
return device_class 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: def _fallback_name(entity_id: str) -> str:
"""Make a readable name out of an id when no schema metadata is known yet.""" """Make a readable name out of an id when no schema metadata is known yet."""
return entity_id.rsplit("__", 1)[-1].replace("_", " ").title() return entity_id.rsplit("__", 1)[-1].replace("_", " ").title()
@@ -215,11 +272,18 @@ async def async_setup_entry(
else entry.title 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( sensor = HaProbeSensor(
entry_id=entry.entry_id, entry_id=entry.entry_id,
entity_key=entity_id, entity_key=entity_id,
device_ha_id=device_ha_id or "probe", device_info=device_info,
device_name=device_name,
initial_state=state, initial_state=state,
name=entity.get("name") or _fallback_name(entity_id), name=entity.get("name") or _fallback_name(entity_id),
device_class=_resolve_device_class(entity.get("deviceClass")), device_class=_resolve_device_class(entity.get("deviceClass")),
@@ -229,6 +293,8 @@ async def async_setup_entry(
new_sensors.append(sensor) new_sensors.append(sensor)
if new_sensors: 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) async_add_entities(new_sensors)
entry.async_on_unload( entry.async_on_unload(
@@ -247,8 +313,7 @@ class HaProbeSensor(SensorEntity):
*, *,
entry_id: str, entry_id: str,
entity_key: str, entity_key: str,
device_ha_id: str, device_info: DeviceInfo,
device_name: str,
initial_state: Any, initial_state: Any,
name: str, name: str,
device_class: SensorDeviceClass | None, device_class: SensorDeviceClass | None,
@@ -258,13 +323,18 @@ class HaProbeSensor(SensorEntity):
self._attr_unique_id = f"{entry_id}_{entity_key}" self._attr_unique_id = f"{entry_id}_{entity_key}"
self._attr_name = name self._attr_name = name
self._attr_device_class = device_class 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_unit_of_measurement = unit
self._attr_native_value = initial_state self._attr_suggested_display_precision = _display_precision(
self._attr_device_info = DeviceInfo( self._attr_device_class, unit
identifiers={(DOMAIN, f"{entry_id}_{device_ha_id}")},
name=device_name,
manufacturer="gerviba/systemmonitor",
model="Probe",
) )
@callback @callback
@@ -275,8 +345,6 @@ class HaProbeSensor(SensorEntity):
restarted it will resend full metadata too - pick that back up restarted it will resend full metadata too - pick that back up
instead of only ever trusting the first value we saw. instead of only ever trusting the first value we saw.
""" """
self._attr_native_value = state
name = entity.get("name") name = entity.get("name")
if name: if name:
self._attr_name = name self._attr_name = name
@@ -287,7 +355,9 @@ class HaProbeSensor(SensorEntity):
unit_name = entity.get("unit") unit_name = entity.get("unit")
if unit_name: 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: if self.hass is not None:
self.async_write_ha_state() self.async_write_ha_state()
@@ -3,7 +3,7 @@
"step": { "step": {
"user": { "user": {
"title": "HA Probe", "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": { "data": {
"name": "Integration Name", "name": "Integration Name",
"webhook_id": "Webhook ID" "webhook_id": "Webhook ID"
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Wrapper for probe.jar - passes all arguments through. # 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" PROBE_JAR="/home/clustermaster/storage/cdn/serve/internal/probe/probe.jar"
@@ -36,6 +36,15 @@ data class SensorRecord(
data class DeviceRecord( data class DeviceRecord(
val haId: String, val haId: String,
val haDisplayName: 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 @Serializable
@@ -37,6 +37,7 @@ class ProbeApplication {
private lateinit var config: ConfigRecord private lateinit var config: ConfigRecord
private lateinit var apiService: ApiService private lateinit var apiService: ApiService
private lateinit var deviceService: DeviceService
private lateinit var scheduler: SchedulerService private lateinit var scheduler: SchedulerService
private lateinit var basicSchema: List<Pair<Sensor, List<Measurement>>> private lateinit var basicSchema: List<Pair<Sensor, List<Measurement>>>
@@ -44,6 +45,7 @@ class ProbeApplication {
val configLoader = ConfigLoader() val configLoader = ConfigLoader()
config = configLoader.loadConfig() config = configLoader.loadConfig()
apiService = ApiService(config) apiService = ApiService(config)
deviceService = DeviceService(apiService)
sensors.addAll(listOf( sensors.addAll(listOf(
FolderSizeSensor(config), FolderSizeSensor(config),
@@ -69,6 +71,7 @@ class ProbeApplication {
fun setupSensors() { fun setupSensors() {
sensors.forEach { it.onConfig() } sensors.forEach { it.onConfig() }
basicSchema = sensors.map { it to it.getMeasurementSchemas() } basicSchema = sensors.map { it to it.getMeasurementSchemas() }
deviceService.start()
log.info("Sending schemas") log.info("Sending schemas")
apiService.updateMeasurements(basicSchema.flatMap { it.second }) apiService.updateMeasurements(basicSchema.flatMap { it.second })
log.info("") log.info("")
@@ -1,6 +1,7 @@
package hu.gerviba.probe.api package hu.gerviba.probe.api
import hu.gerviba.probe.ConfigRecord import hu.gerviba.probe.ConfigRecord
import hu.gerviba.probe.DeviceLinker
import hu.gerviba.probe.DeviceRecord import hu.gerviba.probe.DeviceRecord
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
@@ -29,12 +30,29 @@ class ApiService(
} }
} }
@Volatile
private var devices: List<DeviceRecord> = DeviceLinker.link(config)
fun currentDevices(): List<DeviceRecord> = devices
/** Replaces the device list and pushes it to Home Assistant straight away. */
fun updateDevices(newDevices: List<DeviceRecord>) {
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<DeviceRecord>) {
devices = newDevices
}
fun updateMeasurement(measurement: Measurement) { fun updateMeasurement(measurement: Measurement) {
updateMeasurements(listOf(measurement)) updateMeasurements(listOf(measurement))
} }
fun updateMeasurements(measurements: List<Measurement>) { fun updateMeasurements(measurements: List<Measurement>) {
updateMeasurements(config.devices, measurements) updateMeasurements(devices, measurements)
} }
fun updateMeasurements(devices: List<DeviceRecord>, measurements: List<Measurement>) { fun updateMeasurements(devices: List<DeviceRecord>, measurements: List<Measurement>) {
@@ -282,7 +282,7 @@ private class DockerMeasurementFactory(
Measurement( Measurement(
id = cpuUsage, id = cpuUsage,
name = "$haDisplayName cpu usage", name = "$haDisplayName cpu usage",
deviceClass = SensorDeviceClass.DATA_SIZE, deviceClass = null,
unit = UnitOfMeasurement.PERCENTAGE, unit = UnitOfMeasurement.PERCENTAGE,
device = device, device = device,
), ),
@@ -314,19 +314,23 @@ private class DockerMeasurementFactory(
return sequenceOf( return sequenceOf(
Measurement( Measurement(
id = startedAt, id = startedAt,
textValue = container.startedAt textValue = container.startedAt,
device = device,
), ),
Measurement( Measurement(
id = cpuUsage, id = cpuUsage,
value = container.cpuUsage value = container.cpuUsage,
device = device,
), ),
Measurement( Measurement(
id = memoryUsage, id = memoryUsage,
value = container.memoryUsage.toDouble() value = container.memoryUsage.toDouble(),
device = device,
), ),
Measurement( Measurement(
id = health, id = health,
textValue = container.health textValue = container.health,
device = device,
), ),
) )
} }
@@ -334,7 +338,8 @@ private class DockerMeasurementFactory(
fun measurementForState(container: DockerSensor.ContainerData): Measurement { fun measurementForState(container: DockerSensor.ContainerData): Measurement {
return Measurement( return Measurement(
id = state, id = state,
textValue = container.status textValue = container.status,
device = device,
) )
} }
@@ -343,7 +348,8 @@ private class DockerMeasurementFactory(
return container.health?.let { return container.health?.let {
Measurement( Measurement(
id = health, id = health,
textValue = it textValue = it,
device = device,
) )
} }
} }