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": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"haId": "default",
|
"haId": "default",
|
||||||
"haDisplayName": "lab1"
|
"haDisplayName": "lab1",
|
||||||
|
"manufacturer": "gerviba/systemmonitor"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": [
|
"services": [
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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,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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user