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