This commit is contained in:
1 parent
d663f3336b
commit
c9d257a442
11 files changed
+138
-25
No files matched your search
+2
-1
@@ -144,7 +144,8 @@
|
||||
"devices": [
|
||||
{
|
||||
"haId": "default",
|
||||
"haDisplayName": "lab1"
|
||||
"haDisplayName": "lab1",
|
||||
"manufacturer": "gerviba/systemmonitor"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user