diff --git a/custom_components/ha_probe/__pycache__/__init__.cpython-312.pyc b/custom_components/ha_probe/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d35665d Binary files /dev/null and b/custom_components/ha_probe/__pycache__/__init__.cpython-312.pyc differ diff --git a/custom_components/ha_probe/__pycache__/config_flow.cpython-312.pyc b/custom_components/ha_probe/__pycache__/config_flow.cpython-312.pyc new file mode 100644 index 0000000..6047a3e Binary files /dev/null and b/custom_components/ha_probe/__pycache__/config_flow.cpython-312.pyc differ diff --git a/custom_components/ha_probe/__pycache__/const.cpython-312.pyc b/custom_components/ha_probe/__pycache__/const.cpython-312.pyc new file mode 100644 index 0000000..4e6db1f Binary files /dev/null and b/custom_components/ha_probe/__pycache__/const.cpython-312.pyc differ diff --git a/custom_components/ha_probe/__pycache__/device.cpython-312.pyc b/custom_components/ha_probe/__pycache__/device.cpython-312.pyc new file mode 100644 index 0000000..886ec2f Binary files /dev/null and b/custom_components/ha_probe/__pycache__/device.cpython-312.pyc differ diff --git a/custom_components/ha_probe/__pycache__/sensor.cpython-312.pyc b/custom_components/ha_probe/__pycache__/sensor.cpython-312.pyc new file mode 100644 index 0000000..72715a7 Binary files /dev/null and b/custom_components/ha_probe/__pycache__/sensor.cpython-312.pyc differ diff --git a/custom_components/ha_probe/brand/icon.png b/custom_components/ha_probe/brand/icon.png new file mode 100644 index 0000000..5954b0a Binary files /dev/null and b/custom_components/ha_probe/brand/icon.png differ diff --git a/custom_components/ha_probe/brand/icon@2x.png b/custom_components/ha_probe/brand/icon@2x.png new file mode 100644 index 0000000..29b4747 Binary files /dev/null and b/custom_components/ha_probe/brand/icon@2x.png differ diff --git a/custom_components/ha_probe/device.py b/custom_components/ha_probe/device.py new file mode 100644 index 0000000..6245ab3 --- /dev/null +++ b/custom_components/ha_probe/device.py @@ -0,0 +1,92 @@ +"""Device metadata helpers for the HA Probe integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + +DEFAULT_MANUFACTURER = "gerviba/systemmonitor" +DEFAULT_MODEL = "Probe" + +# Probe payload key -> normalized metadata key. +_PAYLOAD_KEYS = { + "manufacturer": "manufacturer", + "model": "model", + "configurationUrl": "configuration_url", + "swVersion": "sw_version", + "viaDevice": "via_device", +} + + +def parse_device_meta(device: dict[str, Any]) -> dict[str, str]: + """Pick the optional metadata fields a probe device record carries.""" + return { + key: value + for payload_key, key in _PAYLOAD_KEYS.items() + if isinstance(value := device.get(payload_key), str) and value + } + + +def device_identifier(entry_id: str, device_ha_id: str) -> tuple[str, str]: + """Registry identifier of a probe device.""" + return (DOMAIN, f"{entry_id}_{device_ha_id}") + + +def build_device_info( + entry_id: str, device_ha_id: str, name: str, meta: dict[str, str] +) -> DeviceInfo: + """DeviceInfo for a probe device, using whatever metadata is known.""" + info = DeviceInfo( + identifiers={device_identifier(entry_id, device_ha_id)}, + name=name, + manufacturer=meta.get("manufacturer") or DEFAULT_MANUFACTURER, + model=meta.get("model") or DEFAULT_MODEL, + ) + if sw_version := meta.get("sw_version"): + info["sw_version"] = sw_version + if configuration_url := meta.get("configuration_url"): + info["configuration_url"] = configuration_url + if via_device := meta.get("via_device"): + info["via_device"] = device_identifier(entry_id, via_device) + return info + + +def sync_device_registry( + hass: HomeAssistant, + entry_id: str, + device_ha_id: str, + meta: dict[str, str], +) -> None: + """Push changed metadata (e.g. a new docker image) to an existing device. + + Entities only hand their DeviceInfo to HA when first created, so later + changes from the probe have to be applied to the registry directly. + """ + registry = dr.async_get(hass) + device = registry.async_get_device( + identifiers={device_identifier(entry_id, device_ha_id)} + ) + if device is None: + return + + changes: dict[str, Any] = { + "manufacturer": meta.get("manufacturer") or DEFAULT_MANUFACTURER, + "model": meta.get("model") or DEFAULT_MODEL, + } + if sw_version := meta.get("sw_version"): + changes["sw_version"] = sw_version + if configuration_url := meta.get("configuration_url"): + changes["configuration_url"] = configuration_url + if via_device := meta.get("via_device"): + parent = registry.async_get_device( + identifiers={device_identifier(entry_id, via_device)} + ) + if parent is not None: + changes["via_device_id"] = parent.id + + registry.async_update_device(device.id, **changes) diff --git a/src/main/kotlin/hu/gerviba/probe/DeviceLinker.kt b/src/main/kotlin/hu/gerviba/probe/DeviceLinker.kt new file mode 100644 index 0000000..3d0dbb5 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/DeviceLinker.kt @@ -0,0 +1,30 @@ +package hu.gerviba.probe + +import org.slf4j.LoggerFactory + +/** + * Nests devices that represent a Docker container under the host device. + * + * The host is the first non-container device whose haDisplayName or haId matches the + * config root's haDisplayName / haId, falling back to the device with haId "default". + */ +object DeviceLinker { + + private val log = LoggerFactory.getLogger(DeviceLinker::class.java) + + fun link(config: ConfigRecord): List { + val hosts = config.devices.filter { it.dockerContainer == null } + val host = hosts.firstOrNull { + it.haDisplayName.equals(config.haDisplayName, ignoreCase = true) || + it.haId.equals(config.haId, ignoreCase = true) + } ?: hosts.firstOrNull { it.haId == "default" } + + if (host == null && config.devices.any { it.dockerContainer != null }) + log.warn("No host device matches the config root; container devices will not be nested") + + return config.devices.map { + if (it.dockerContainer != null && host != null) it.copy(viaDevice = host.haId) else it + } + } + +} diff --git a/src/main/kotlin/hu/gerviba/probe/DeviceService.kt b/src/main/kotlin/hu/gerviba/probe/DeviceService.kt new file mode 100644 index 0000000..6b87c8a --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/DeviceService.kt @@ -0,0 +1,84 @@ +package hu.gerviba.probe + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient +import com.github.dockerjava.transport.DockerHttpClient +import hu.gerviba.probe.api.ApiService +import org.slf4j.LoggerFactory +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Keeps the sw_version of Docker container devices in sync with the image the + * container currently runs, re-checking periodically. + */ +class DeviceService( + private val apiService: ApiService, +) { + + private val log = LoggerFactory.getLogger(DeviceService::class.java) + private val mapper = ObjectMapper() + private val scheduler = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "device-refresh").apply { isDaemon = true } + } + + private val httpClient: DockerHttpClient by lazy { + val config = DefaultDockerClientConfig.createDefaultConfigBuilder().build() + ApacheDockerHttpClient.Builder() + .dockerHost(config.dockerHost) + .sslConfig(config.sslConfig) + .build() + } + + /** Resolves images once (before the first push), then refreshes them periodically. */ + fun start() { + if (apiService.currentDevices().none { it.dockerContainer != null }) + return + + refresh(push = false) + scheduler.scheduleWithFixedDelay({ refresh(push = true) }, REFRESH_HOURS, REFRESH_HOURS, TimeUnit.HOURS) + } + + private fun refresh(push: Boolean) { + try { + val current = apiService.currentDevices() + val updated = current.map { device -> + val container = device.dockerContainer ?: return@map device + val image = imageOf(container) ?: return@map device + device.copy(swVersion = image) + } + if (updated == current) + return + + if (push) { + apiService.updateDevices(updated) + } else { + apiService.setDevicesSilently(updated) + } + } catch (e: Exception) { + log.warn("Failed to refresh device versions: {}", e.message) + } + } + + /** Image of a container (as configured, e.g. "gitea/gitea:1.22"), or null if unavailable. */ + private fun imageOf(container: String): String? { + return try { + val request = DockerHttpClient.Request.builder() + .method("GET") + .path("/containers/$container/json") + .build() + httpClient.execute(request).use { response -> + mapper.readTree(response.body).get("Config")?.get("Image")?.asText() + } + } catch (e: Exception) { + log.warn("Failed to inspect container {}: {}", container, e.message) + null + } + } + + private companion object { + const val REFRESH_HOURS = 1L + } + +}