Add missing files
Build probe jar / build (push) Successful in 1m48s

This commit is contained in:
gerviba committed 2026-11-01 22:47:26 +01:00
1 parent c9d257a442
commit dfc91d7eb8
10 files changed
+206

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+92
View File
@@ -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)
@@ -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<DeviceRecord> {
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
}
}
}
@@ -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
}
}