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": [
{
"haId": "default",
"haDisplayName": "lab1"
"haDisplayName": "lab1",
"manufacturer": "gerviba/systemmonitor"
}
],
"services": [
+4
View File
@@ -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:
+1
View File
@@ -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)
+1 -1
View File
@@ -5,7 +5,7 @@
"integration_type": "device",
"iot_class": "local_push",
"dependencies": ["webhook"],
"codeowners": [],
"codeowners": ["gerviba"],
"requirements": [],
"config_flow": true
}
+84 -14
View File
@@ -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"
+1
View File
@@ -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"
@@ -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
@@ -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<Pair<Sensor, List<Measurement>>>
@@ -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("")
@@ -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<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) {
updateMeasurements(listOf(measurement))
}
fun updateMeasurements(measurements: List<Measurement>) {
updateMeasurements(config.devices, measurements)
updateMeasurements(devices, measurements)
}
fun updateMeasurements(devices: List<DeviceRecord>, measurements: List<Measurement>) {
@@ -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,
)
}
}