feat(heat): add autonomous wireless heat meter daemon and CLI manager

This commit is contained in:
2026-11-06 01:26:53 +01:00
parent 1faa467dc0
commit bb8dc3bee8
2 changed files with 393 additions and 0 deletions
Executable
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
heat: Observatoř Wireless Heating Telemetry CLI
Manages, queries, and inspects 868.95 MHz Wireless M-Bus radiator metrics.
"""
import os
import sys
import json
import sqlite3
import subprocess
from datetime import datetime
HOME = os.path.expanduser("~")
DATA_DIR = os.path.join(HOME, "src", "observator", "data")
DB_PATH = os.path.join(DATA_DIR, "heat_meters.db")
JSONL_PATH = os.path.join(DATA_DIR, "heat_meters.jsonl")
SHM_LATEST = "/dev/shm/latest_heat_meter.json"
SHM_SUMMARY = "/dev/shm/heat_fleet_summary.json"
SERVICE_NAME = "sdr-heat.service"
def cmd_status():
if not os.path.exists(DB_PATH):
print("[-] Žádná databáze měřičů topení zatím neexistuje.")
print(" Spusťte sběrač pomocí: heat start")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("""
SELECT id, manufacturer, room_label, last_seen, last_hca, prev_hca, billing_date, total_packets
FROM meters ORDER BY last_seen DESC
""")
rows = cur.fetchall()
conn.close()
print("╔═══════════════════════════════════════════════════════════════════════════════════════════════════════╗")
print("║ THE OBSERVATOŘ // RADIÁTORY & INDIKÁTORY TOPENÍ (868.95 MHz Wireless M-Bus Mode C&T) ║")
print("╚═══════════════════════════════════════════════════════════════════════════════════════════════════════╝")
# Check daemon status
is_active = subprocess.run(["systemctl", "--user", "is-active", "--quiet", SERVICE_NAME]).returncode == 0
status_str = "\033[32m● AKTIVNÍ (démon běží)\033[0m" if is_active else "\033[31m○ NEAKTIVNÍ\033[0m"
print(f" Služba sdr-heat: {status_str} | Tuner: RTL-SDR Fitipower FC0013 (t520) | Zisk: 19.7 dB")
print("─" * 105)
if not rows:
print(" [!] V databázi zatím není zaznamenán žádný indikátor. Vyčkejte na vysílací interval.")
return
header = f" {'ID MĚŘIČE':<12} │ {'VÝROBCE':<8} │ {'MÍSTNOST / ŠTÍTEK':<26} │ {'DÍLKY':>8} │ {'MINULE':>8} │ {'POSLEDNÍ SIGNÁL':<19} │ {'PKT':>5}"
print(header)
print("═" * 105)
total_units = 0.0
for r in rows:
mid, mfg, label, last_seen, hca, prev_hca, billing_date, pkts = r
total_units += (hca or 0.0)
lbl = label if label else "—"
hca_str = f"{hca:.1f}" if hca is not None else "0.0"
prev_str = f"{prev_hca:.1f}" if prev_hca is not None else "0.0"
print(f" {mid:<12} │ {mfg:<8} │ {lbl:<26} │ {hca_str:>8} │ {prev_str:>8} │ {last_seen:<19} │ {pkts:>5}")
print("─" * 105)
print(f" Celkem indikátorů: {len(rows)} │ Celková naměřená spotřeba: {total_units:.1f} dílků")
print(" Tip: Pro pojmenování radiátoru použijte: heat label <ID> \"Kuchyň okno\"")
print()
def cmd_label(args):
if len(args) < 2:
print("Použití: heat label <ID_MĚŘIČE> <NÁZEV MÍSTNOSTI>")
print("Příklad: heat label 18466419 \"Kuchyň velký radiátor\"")
return
meter_id = args[0]
label = " ".join(args[1:])
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("UPDATE meters SET room_label = ? WHERE id = ?", (label, meter_id))
if cur.rowcount > 0:
conn.commit()
print(f"[✓] Indikátor #{meter_id} úspěšně označen jako: '{label}'")
else:
# Insert placeholder if not seen yet
now_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cur.execute("""
INSERT INTO meters (id, manufacturer, model, room_label, first_seen, last_seen, last_hca, prev_hca, billing_date, total_packets)
VALUES (?, 'UNK', 'HCA', ?, ?, ?, 0.0, 0.0, '', 0)
""", (meter_id, label, now_ts, now_ts))
conn.commit()
print(f"[✓] Vytvořen nový záznam pro #{meter_id} s označením: '{label}'")
conn.close()
def cmd_live():
print("[*] Spouštím živý odposlech paketů radiátorů na 868.95 MHz (Ctrl+C pro ukončení)...")
cmd = ["rtl_433", "-f", "868.95M", "-s", "1200k", "-R", "104", "-g", "19.7", "-F", "json"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
try:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
pkt = json.loads(line)
mid = pkt.get("id", "UNK")
mfg = pkt.get("M", "UNK")
hca = pkt.get("inst_hca_0", "0.0").strip()
t_str = pkt.get("time", "")
data_hex = pkt.get("data", "")
print(f"[{t_str}] \033[36m#{mid}\033[0m ({mfg}) ➔ dílky: \033[32m{hca}\033[0m │ raw: {data_hex[:24]}...")
except json.JSONDecodeError:
pass
except KeyboardInterrupt:
proc.terminate()
print("\n[*] Živý odposlech ukončen.")
def cmd_log(n=10):
if not os.path.exists(JSONL_PATH):
print("[-] Žádný archiv paketů zatím neexistuje.")
return
cmd = ["tail", "-n", str(n), JSONL_PATH]
subprocess.run(cmd)
def cmd_service(action):
if action in ["start", "stop", "restart", "status", "enable", "disable"]:
subprocess.run(["systemctl", "--user", action, SERVICE_NAME])
else:
print(f"Neznámá akce služby: {action}")
def main():
args = sys.argv[1:]
if not args or args[0] in ["status", "s"]:
cmd_status()
elif args[0] in ["label", "l"]:
cmd_label(args[1:])
elif args[0] in ["live", "watch", "w"]:
cmd_live()
elif args[0] in ["log", "tail"]:
n = int(args[1]) if len(args) > 1 else 10
cmd_log(n)
elif args[0] in ["json", "summary"]:
if os.path.exists(SHM_SUMMARY):
with open(SHM_SUMMARY) as f:
print(f.read())
else:
print("{}")
elif args[0] in ["start", "stop", "restart", "enable", "disable"]:
cmd_service(args[0])
else:
print("Observatoř Heat Telemetry CLI")
print("Použití:")
print(" heat status Zobrazí přehled všech zachycených radiátorů")
print(" heat live Živý streaming příchozích paketů v terminálu")
print(" heat label <ID> <NÁZEV> Pojmenuje radiátor (např. 'Kuchyň')")
print(" heat log [N] Vypíše posledních N surových JSON paketů")
print(" heat json Vypíše agregovaný JSON z /dev/shm")
print(" heat start|stop|restart Ovládání systemd služby")
if __name__ == "__main__":
main()
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
sdr-heat-daemon: Autonomous Wireless Heating Meter Telemetry Collector
Captures Wireless M-Bus Mode C/T packets on 868.95 MHz using RTL-SDR and rtl_433.
Maintains SQLite database, JSONL archive, /dev/shm fast IPC, and SuperCollider OSC relay.
Part of The Observatoř environmental telemetry suite.
"""
import os
import sys
import json
import time
import socket
import struct
import signal
import sqlite3
import subprocess
from datetime import datetime
HOME = os.path.expanduser("~")
DATA_DIR = os.path.join(HOME, "src", "observator", "data")
DB_PATH = os.path.join(DATA_DIR, "heat_meters.db")
JSONL_PATH = os.path.join(DATA_DIR, "heat_meters.jsonl")
SHM_LATEST = "/dev/shm/latest_heat_meter.json"
SHM_SUMMARY = "/dev/shm/heat_fleet_summary.json"
OSC_HOST = "127.0.0.1"
OSC_PORT_SCLANG = 57120
os.makedirs(DATA_DIR, exist_ok=True)
# OSC UDP Socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
def make_osc_message(address, args):
def pad4(b):
return b + b"\x00" * ((4 - (len(b) % 4)) % 4)
msg = pad4(address.encode("utf-8") + b"\x00")
tags = ","
payload = bytearray()
for arg in args:
if isinstance(arg, int):
tags += "i"
payload.extend(struct.pack(">i", arg))
elif isinstance(arg, float):
tags += "f"
payload.extend(struct.pack(">f", arg))
elif isinstance(arg, str):
tags += "s"
payload.extend(pad4(arg.encode("utf-8") + b"\x00"))
msg += pad4(tags.encode("utf-8") + b"\x00")
msg += payload
return bytes(msg)
def init_db():
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS meters (
id INTEGER PRIMARY KEY,
manufacturer TEXT,
model TEXT,
room_label TEXT DEFAULT '',
first_seen TEXT,
last_seen TEXT,
last_hca REAL,
prev_hca REAL,
billing_date TEXT,
total_packets INTEGER DEFAULT 0
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS readings (
id INTEGER,
timestamp TEXT,
hca_val REAL,
hca_prev REAL,
billing_date TEXT,
raw_hex TEXT,
FOREIGN KEY(id) REFERENCES meters(id)
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_readings_id_time ON readings(id, timestamp)")
conn.commit()
conn.close()
def update_summary(conn):
cur = conn.cursor()
cur.execute("""
SELECT id, manufacturer, room_label, last_seen, last_hca, prev_hca, billing_date, total_packets
FROM meters ORDER BY last_seen DESC
""")
rows = cur.fetchall()
meters_list = []
total_consumption = 0.0
for r in rows:
val = r[4] or 0.0
total_consumption += val
meters_list.append({
"id": r[0],
"manufacturer": r[1],
"room_label": r[2] if r[2] else f"Meter #{r[0]}",
"last_seen": r[3],
"last_hca": val,
"prev_hca": r[5] or 0.0,
"billing_date": r[6],
"packets": r[7]
})
summary = {
"timestamp": datetime.now().isoformat(),
"total_meters": len(meters_list),
"total_units": round(total_consumption, 3),
"meters": meters_list
}
tmp_path = SHM_SUMMARY + ".tmp"
with open(tmp_path, "w") as f:
json.dump(summary, f, indent=2)
os.replace(tmp_path, SHM_SUMMARY)
def process_packet(pkt):
meter_id = pkt.get("id")
if not meter_id:
return
mfg = pkt.get("M", "UNK")
now_ts = pkt.get("time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
data_hex = pkt.get("data", "")
billing_date = pkt.get("inst_date_17", "")
# Try parsing current consumption
last_hca = 0.0
try:
last_hca = float(pkt.get("inst_hca_0", "0.0").strip())
except (ValueError, TypeError):
pass
# Try parsing previous billing period
prev_hca = 0.0
try:
prev_hca = float(pkt.get("inst_hca_17", "0.0").strip())
except (ValueError, TypeError):
pass
# 1. Update SQLite DB
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("SELECT id, total_packets FROM meters WHERE id = ?", (meter_id,))
row = cur.fetchone()
if row:
pkts = row[1] + 1
cur.execute("""
UPDATE meters
SET last_seen = ?, last_hca = ?, prev_hca = ?, billing_date = ?, total_packets = ?
WHERE id = ?
""", (now_ts, last_hca, prev_hca, billing_date, pkts, meter_id))
else:
cur.execute("""
INSERT INTO meters (id, manufacturer, model, first_seen, last_seen, last_hca, prev_hca, billing_date, total_packets)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
""", (meter_id, mfg, pkt.get("type_string", "HCA"), now_ts, now_ts, last_hca, prev_hca, billing_date))
cur.execute("""
INSERT INTO readings (id, timestamp, hca_val, hca_prev, billing_date, raw_hex)
VALUES (?, ?, ?, ?, ?, ?)
""", (meter_id, now_ts, last_hca, prev_hca, billing_date, data_hex))
conn.commit()
# 2. Update SHM summary and latest
update_summary(conn)
conn.close()
tmp_latest = SHM_LATEST + ".tmp"
with open(tmp_latest, "w") as f:
json.dump(pkt, f, indent=2)
os.replace(tmp_latest, SHM_LATEST)
# 3. Append to JSONL
with open(JSONL_PATH, "a") as f:
f.write(json.dumps(pkt) + "\n")
# 4. Relay to SuperCollider via OSC
try:
osc_data = make_osc_message("/observator/heat", [int(meter_id), float(last_hca), float(prev_hca), str(mfg)])
sock.sendto(osc_data, (OSC_HOST, OSC_PORT_SCLANG))
except Exception:
pass
print(f"[{now_ts}] Heat Meter #{meter_id} ({mfg}): current={last_hca} prev={prev_hca} (logged)")
sys.stdout.flush()
def run_collector():
init_db()
print("[*] sdr-heat-daemon started. Listening on 868.95 MHz Mode C&T...")
sys.stdout.flush()
cmd = [
"rtl_433",
"-f", "868.95M",
"-s", "1200k",
"-R", "104",
"-g", "19.7",
"-F", "json"
]
while True:
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
pkt = json.loads(line)
process_packet(pkt)
except json.JSONDecodeError:
pass
proc.wait()
print("[-] rtl_433 exited with code", proc.returncode, "- restarting in 3s...")
time.sleep(3)
except Exception as e:
print("[-] Collector exception:", e, "- retrying in 5s...")
time.sleep(5)
if __name__ == "__main__":
def sig_handler(sig, frame):
print("\n[*] Stopping sdr-heat-daemon...")
sys.exit(0)
signal.signal(signal.SIGINT, sig_handler)
signal.signal(signal.SIGTERM, sig_handler)
run_collector()