From d22ccfe141d339552f9e0a2cdb67f8e69eaa6846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kry=C5=A1tof=20Pe=C5=A1ek?= Date: Thu, 5 Nov 2026 17:14:57 +0100 Subject: [PATCH] fix(beacon): convert symlink to standalone executable with WAN bridge and phone detection --- fleet-beacon | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) mode change 120000 => 100755 fleet-beacon diff --git a/fleet-beacon b/fleet-beacon deleted file mode 120000 index 7cbd96e..0000000 --- a/fleet-beacon +++ /dev/null @@ -1 +0,0 @@ -/home/kof/src/observator/otop/fleet-beacon.py \ No newline at end of file diff --git a/fleet-beacon b/fleet-beacon new file mode 100755 index 0000000..b0a4583 --- /dev/null +++ b/fleet-beacon @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +fleet-beacon.py — Multi-node Realtime Telemetry Beacon +Part of Observatoř Top (otop) + +Broadcasts lightweight UDP telemetry packets (port 9850) every 1.5s. +Zero external dependencies, minimal CPU impact (< 0.05%). +""" + +import os +import sys +import time +import json +import socket +import shutil +import glob +import urllib.request + +PORT = 9850 +TARGETS = ["", "192.168.1.255", "192.168.1.100", "192.168.1.102", "10.0.0.255", "10.0.0.18", "10.0.0.14", "91.139.118.43", "192.168.43.255", "192.168.43.1", "127.0.0.1"] + +def get_hostname(): + if os.path.exists("/data/data/com.termux") or "TERMUX_VERSION" in os.environ: + return "phone" + h = socket.gethostname().split('.')[0] + if h in ("localhost", "android"): + return "phone" + return h + +def read_cpu_percent(prev_stat): + try: + with open("/proc/stat", "r") as f: + fields = [float(x) for x in f.readline().strip().split()[1:8]] + # fields: user, nice, system, idle, iowait, irq, softirq + idle = fields[3] + fields[4] + total = sum(fields) + if prev_stat is not None: + prev_idle, prev_total = prev_stat + d_idle = idle - prev_idle + d_total = total - prev_total + pct = 100.0 * (1.0 - (d_idle / max(1.0, d_total))) + return round(max(0.0, min(100.0, pct)), 1), (idle, total) + return 0.0, (idle, total) + except Exception: + return 0.0, prev_stat + +def read_memory(): + total_gb, used_gb = 0.0, 0.0 + try: + with open("/proc/meminfo", "r") as f: + mem = {} + for line in f: + parts = line.split(":") + if len(parts) == 2: + mem[parts[0].strip()] = float(parts[1].strip().split()[0]) + total_kb = mem.get("MemTotal", 0.0) + avail_kb = mem.get("MemAvailable", mem.get("MemFree", 0.0)) + total_gb = round(total_kb / (1024.0 * 1024.0), 1) + used_gb = round((total_kb - avail_kb) / (1024.0 * 1024.0), 1) + except Exception: + pass + return used_gb, total_gb + +def read_disk(): + hostname = get_hostname() + check_paths = ["/"] + if hostname == "boxC": + check_paths = ["/mnt/kof17", "/"] + elif hostname == "bazina": + check_paths = ["/mnt/ssd", "/mnt/seagate", "/"] + + for p in check_paths: + if os.path.exists(p): + try: + du = shutil.disk_usage(p) + free_gb = round(du.free / (1024.0 ** 3), 1) + total_gb = round(du.total / (1024.0 ** 3), 1) + return p, free_gb, total_gb + except Exception: + continue + return "/", 0.0, 0.0 + +def read_gpu(): + # AMD GPU busy percent (sysfs) + for path in glob.glob("/sys/class/drm/card*/device/gpu_busy_percent"): + try: + with open(path, "r") as f: + val = f.read().strip() + return f"Navi10 {val}%" + except Exception: + pass + return "" + +def read_temp(): + for path in ["/sys/class/thermal/thermal_zone0/temp", "/sys/class/hwmon/hwmon0/temp1_input"]: + if os.path.exists(path): + try: + with open(path, "r") as f: + return int(int(f.read().strip()) / 1000) + except Exception: + pass + return 0 + +def main(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + + hostname = get_hostname() + prev_stat = None + print(f"[fleet-beacon] Starting telemetry beacon on {hostname} (port {PORT})...") + + while True: + cpu_pct, prev_stat = read_cpu_percent(prev_stat) + mem_used, mem_total = read_memory() + mount, disk_free, disk_total = read_disk() + gpu_str = read_gpu() + temp_c = read_temp() + + alert = "" + if hostname == "boxC" and mount == "/mnt/kof17" and disk_free < 3.0: + alert = f"kof17 CRITICAL: {disk_free}G free" + elif hostname == "penguin" and disk_free < 1.0: + alert = f"penguin LOW DISK: {disk_free}G free" + + payload = { + "name": hostname, + "hostname": hostname, + "timestamp": time.time(), + "cpu_percent": cpu_pct, + "mem_used_gb": mem_used, + "mem_total_gb": mem_total, + "disk_mount": mount, + "disk_free_gb": disk_free, + "disk_total_gb": disk_total, + "gpu_str": gpu_str, + "temp_c": temp_c, + "alert": alert + } + + msg = json.dumps(payload).encode("utf-8") + + for target in TARGETS: + try: + sock.sendto(msg, (target, PORT)) + except Exception: + pass + + # Update local SHM cache + try: + shm_fleet = "/dev/shm/fleet_telemetry.json" + data = {} + if os.path.exists(shm_fleet): + try: + with open(shm_fleet, "r") as f: + data = json.load(f) + except Exception: + data = {} + data[hostname] = payload + tmp_path = f"{shm_fleet}.tmp.{os.getpid()}" + with open(tmp_path, "w") as f: + json.dump(data, f) + os.replace(tmp_path, shm_fleet) + except Exception: + pass + + # WAN HTTP bridge fallback for mobile/remote nodes (e.g. Staroměstská) + if hostname != "boxC": + try: + req = urllib.request.Request( + "http://91.139.118.43/telemetry.php", + data=msg, + headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=1.5) as resp: + if resp.status == 200: + remote_data = json.loads(resp.read().decode("utf-8")) + if isinstance(remote_data, dict) and "boxC" in remote_data: + shm_fleet = "/dev/shm/fleet_telemetry.json" + tmp_path = f"{shm_fleet}.tmp.{os.getpid()}" + with open(tmp_path, "w") as f: + json.dump(remote_data, f) + os.replace(tmp_path, shm_fleet) + except Exception: + pass + + time.sleep(1.5) + +if __name__ == "__main__": + main()