1 parent
c9d257a442
commit
dfc91d7eb8
10 files changed
+206
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in new issue
Block a user