Initial reusable HA kiosk MQTT controls
This commit is contained in:
commit
0b7e6a0107
17 files changed
+3249
No files matched your search
Executable
+245
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Publish low-overhead Linux kiosk performance telemetry to MQTT."""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
import psutil
|
||||
import paho.mqtt.client as mqtt # type: ignore[import-not-found]
|
||||
|
||||
|
||||
MQTT_HOST = os.environ["MQTT_HOST"]
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
MQTT_USER = os.environ.get("MQTT_USER")
|
||||
MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD")
|
||||
KIOSK_ID = os.environ.get("KIOSK_ID", "kiosk")
|
||||
KIOSK_ENTITY_PREFIX = os.environ.get("KIOSK_ENTITY_PREFIX", f"{KIOSK_ID}_kiosk")
|
||||
KIOSK_NAME = os.environ.get("KIOSK_NAME", f"{KIOSK_ID.replace('-', ' ').title()} Kiosk")
|
||||
KIOSK_MANUFACTURER = os.environ.get("KIOSK_MANUFACTURER", "Unknown")
|
||||
KIOSK_MODEL = os.environ.get("KIOSK_MODEL", "Linux Touchscreen Kiosk")
|
||||
MQTT_TOPIC = os.environ.get("MQTT_TOPIC", f"{KIOSK_ID}/kiosk/performance")
|
||||
SAMPLE_SECONDS = max(5, int(os.environ.get("SAMPLE_SECONDS", "10")))
|
||||
|
||||
STATE_TOPIC = f"{MQTT_TOPIC}/state"
|
||||
AVAILABILITY_TOPIC = f"{MQTT_TOPIC}/availability"
|
||||
DISCOVERY_PREFIX = os.environ.get("MQTT_DISCOVERY_PREFIX", "homeassistant")
|
||||
|
||||
DEVICE = {
|
||||
"identifiers": [KIOSK_ENTITY_PREFIX],
|
||||
"name": KIOSK_NAME,
|
||||
"manufacturer": KIOSK_MANUFACTURER,
|
||||
"model": KIOSK_MODEL,
|
||||
}
|
||||
|
||||
METRICS = {
|
||||
"system_cpu_percent": ("System CPU", "%", None, "measurement", "mdi:cpu-64-bit"),
|
||||
"system_iowait_percent": ("CPU I/O Wait", "%", None, "measurement", "mdi:timer-sand"),
|
||||
"load_1m": ("Load 1 Minute", None, None, "measurement", "mdi:gauge"),
|
||||
"load_5m": ("Load 5 Minutes", None, None, "measurement", "mdi:gauge"),
|
||||
"memory_percent": ("Memory Used", "%", None, "measurement", "mdi:memory"),
|
||||
"cpu_temperature": ("CPU Temperature", "°C", "temperature", "measurement", "mdi:thermometer"),
|
||||
"cpu_frequency": ("CPU Frequency", "MHz", "frequency", "measurement", "mdi:sine-wave"),
|
||||
"fan_speed": ("Fan Speed", "rpm", None, "measurement", "mdi:fan"),
|
||||
"processor_fan_speed": ("Processor Fan Speed", "rpm", None, "measurement", "mdi:fan"),
|
||||
"motherboard_fan_speed": ("Motherboard Fan Speed", "rpm", None, "measurement", "mdi:fan"),
|
||||
"chrome_cpu_percent": ("Chrome CPU", "%", None, "measurement", "mdi:google-chrome"),
|
||||
"chrome_browser_cpu_percent": ("Chrome Browser CPU", "%", None, "measurement", "mdi:web"),
|
||||
"chrome_renderer_cpu_percent": ("Chrome Renderer CPU", "%", None, "measurement", "mdi:monitor-dashboard"),
|
||||
"chrome_gpu_cpu_percent": ("Chrome GPU Process CPU", "%", None, "measurement", "mdi:expansion-card"),
|
||||
"chrome_utility_cpu_percent": ("Chrome Utility CPU", "%", None, "measurement", "mdi:cog-transfer"),
|
||||
"chrome_memory": ("Chrome Memory", "MB", "data_size", "measurement", "mdi:memory"),
|
||||
"chrome_processes": ("Chrome Processes", None, None, "measurement", "mdi:format-list-numbered"),
|
||||
"gpu_busy_percent": ("GPU Busy", "%", None, "measurement", "mdi:expansion-card-variant"),
|
||||
"network_receive_rate": ("Network Receive Rate", "kB/s", "data_rate", "measurement", "mdi:download-network"),
|
||||
"network_transmit_rate": ("Network Transmit Rate", "kB/s", "data_rate", "measurement", "mdi:upload-network"),
|
||||
"disk_read_rate": ("Disk Read Rate", "kB/s", "data_rate", "measurement", "mdi:harddisk"),
|
||||
"disk_write_rate": ("Disk Write Rate", "kB/s", "data_rate", "measurement", "mdi:harddisk"),
|
||||
}
|
||||
|
||||
|
||||
def round_or_none(value, digits=1):
|
||||
return round(value, digits) if value is not None else None
|
||||
|
||||
|
||||
def cpu_temperature():
|
||||
values = []
|
||||
try:
|
||||
for entries in psutil.sensors_temperatures().values():
|
||||
values.extend(entry.current for entry in entries if entry.current is not None)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
return max(values) if values else None
|
||||
|
||||
|
||||
def fan_speeds():
|
||||
result: dict[str, float | None] = {
|
||||
"maximum": None,
|
||||
"processor": None,
|
||||
"motherboard": None,
|
||||
}
|
||||
try:
|
||||
for entries in psutil.sensors_fans().values():
|
||||
for entry in entries:
|
||||
if entry.current is None:
|
||||
continue
|
||||
result["maximum"] = max(result["maximum"] or 0, entry.current)
|
||||
label = (entry.label or "").lower()
|
||||
if "processor" in label:
|
||||
result["processor"] = entry.current
|
||||
elif "motherboard" in label:
|
||||
result["motherboard"] = entry.current
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def gpu_busy_percent():
|
||||
for pattern in (
|
||||
"/sys/class/drm/card*/device/gpu_busy_percent",
|
||||
"/sys/class/drm/card*/gt_busy_percent",
|
||||
):
|
||||
for path in glob.glob(pattern):
|
||||
try:
|
||||
with open(path, encoding="ascii") as source:
|
||||
return float(source.read().strip())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def chrome_processes():
|
||||
processes = []
|
||||
for process in psutil.process_iter(["name", "cmdline"]):
|
||||
try:
|
||||
name = (process.info["name"] or "").lower()
|
||||
command = " ".join(process.info["cmdline"] or []).lower()
|
||||
if not any(token in name for token in ("chrome", "chromium")):
|
||||
continue
|
||||
if "--type=renderer" in command:
|
||||
group = "renderer"
|
||||
elif "--type=gpu-process" in command:
|
||||
group = "gpu"
|
||||
elif "--type=utility" in command:
|
||||
group = "utility"
|
||||
elif "--type=" not in command:
|
||||
group = "browser"
|
||||
else:
|
||||
group = "other"
|
||||
process.cpu_percent(None)
|
||||
processes.append((process, group))
|
||||
except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
|
||||
continue
|
||||
return processes
|
||||
|
||||
|
||||
def publish_discovery(client):
|
||||
for key, (name, unit, device_class, state_class, icon) in METRICS.items():
|
||||
config = {
|
||||
"name": name,
|
||||
"unique_id": f"{KIOSK_ENTITY_PREFIX}_{key}",
|
||||
"state_topic": STATE_TOPIC,
|
||||
"availability_topic": AVAILABILITY_TOPIC,
|
||||
"value_template": f"{{{{ value_json.{key} }}}}",
|
||||
"device": DEVICE,
|
||||
"icon": icon,
|
||||
}
|
||||
if unit:
|
||||
config["unit_of_measurement"] = unit
|
||||
if device_class:
|
||||
config["device_class"] = device_class
|
||||
if state_class:
|
||||
config["state_class"] = state_class
|
||||
topic = f"{DISCOVERY_PREFIX}/sensor/{KIOSK_ENTITY_PREFIX}/{key}/config"
|
||||
client.publish(topic, json.dumps(config, separators=(",", ":")), qos=1, retain=True)
|
||||
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
"""Restore all MQTT state after the initial connection or a reconnect."""
|
||||
publish_discovery(client)
|
||||
client.publish(AVAILABILITY_TOPIC, "online", qos=1, retain=True)
|
||||
|
||||
|
||||
def collect_sample():
|
||||
chrome = chrome_processes()
|
||||
net_before = psutil.net_io_counters()
|
||||
disk_before = psutil.disk_io_counters()
|
||||
cpu_times = psutil.cpu_times_percent(interval=SAMPLE_SECONDS, percpu=False)
|
||||
net_after = psutil.net_io_counters()
|
||||
disk_after = psutil.disk_io_counters()
|
||||
|
||||
cpu_by_group = {group: 0.0 for group in ("browser", "renderer", "gpu", "utility", "other")}
|
||||
chrome_memory = 0
|
||||
active_processes = 0
|
||||
for process, group in chrome:
|
||||
try:
|
||||
cpu_by_group[group] += process.cpu_percent(None)
|
||||
chrome_memory += process.memory_info().rss
|
||||
active_processes += 1
|
||||
except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
load_1m, load_5m, _ = os.getloadavg()
|
||||
frequency = psutil.cpu_freq()
|
||||
fans = fan_speeds()
|
||||
interval = float(SAMPLE_SECONDS)
|
||||
disk_read_rate = None
|
||||
disk_write_rate = None
|
||||
if disk_before is not None and disk_after is not None:
|
||||
disk_read_rate = (disk_after.read_bytes - disk_before.read_bytes) / interval / 1024
|
||||
disk_write_rate = (disk_after.write_bytes - disk_before.write_bytes) / interval / 1024
|
||||
sample = {
|
||||
"timestamp": int(time.time()),
|
||||
"hostname": socket.gethostname(),
|
||||
"sample_seconds": SAMPLE_SECONDS,
|
||||
"system_cpu_percent": round_or_none(100.0 - cpu_times.idle),
|
||||
"system_iowait_percent": round_or_none(getattr(cpu_times, "iowait", 0.0)),
|
||||
"load_1m": round(load_1m, 2),
|
||||
"load_5m": round(load_5m, 2),
|
||||
"memory_percent": round_or_none(psutil.virtual_memory().percent),
|
||||
"cpu_temperature": round_or_none(cpu_temperature()),
|
||||
"cpu_frequency": round_or_none(frequency.current if frequency else None, 0),
|
||||
"fan_speed": round_or_none(fans["maximum"], 0),
|
||||
"processor_fan_speed": round_or_none(fans["processor"], 0),
|
||||
"motherboard_fan_speed": round_or_none(fans["motherboard"], 0),
|
||||
"chrome_browser_cpu_percent": round_or_none(cpu_by_group["browser"]),
|
||||
"chrome_renderer_cpu_percent": round_or_none(cpu_by_group["renderer"]),
|
||||
"chrome_gpu_cpu_percent": round_or_none(cpu_by_group["gpu"]),
|
||||
"chrome_utility_cpu_percent": round_or_none(cpu_by_group["utility"]),
|
||||
"chrome_cpu_percent": round_or_none(sum(cpu_by_group.values())),
|
||||
"chrome_memory": round(chrome_memory / 1048576, 1),
|
||||
"chrome_processes": active_processes,
|
||||
"gpu_busy_percent": round_or_none(gpu_busy_percent()),
|
||||
"network_receive_rate": round((net_after.bytes_recv - net_before.bytes_recv) / interval / 1024, 1),
|
||||
"network_transmit_rate": round((net_after.bytes_sent - net_before.bytes_sent) / interval / 1024, 1),
|
||||
"disk_read_rate": round_or_none(disk_read_rate),
|
||||
"disk_write_rate": round_or_none(disk_write_rate),
|
||||
}
|
||||
return sample
|
||||
|
||||
|
||||
def main():
|
||||
client = mqtt.Client(client_id=f"{KIOSK_ID}-kiosk-telemetry")
|
||||
if MQTT_USER:
|
||||
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
|
||||
client.will_set(AVAILABILITY_TOPIC, "offline", qos=1, retain=True)
|
||||
client.on_connect = on_connect
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
payload = json.dumps(collect_sample(), separators=(",", ":"), allow_nan=False)
|
||||
client.publish(STATE_TOPIC, payload, qos=0, retain=False)
|
||||
finally:
|
||||
client.publish(AVAILABILITY_TOPIC, "offline", qos=1, retain=True).wait_for_publish()
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user