#!/usr/bin/env python3
"""Loomwave operator capture — a self-contained, READ-ONLY logger for a meshtasticd node.

Run this ON the Raspberry Pi that runs meshtasticd (it connects to 127.0.0.1, so nothing is
exposed to the network). It logs, for a fixed time window, the metadata of every packet the
node hears plus the node's own telemetry — then bundles it into one .zip you can share.

WHAT IT DOES (and you can verify by reading it — it's one short file):
  * Connects to the LOCAL meshtasticd TCP API and *subscribes* to received packets.
  * It NEVER sends a message, changes configuration, or writes anything to the node. Read-only.
  * Logs METADATA ONLY — message *content* is never recorded (only its byte-length + type).
  * Optionally hashes node IDs (--anonymize) so even identities aren't shared in the clear.

SETUP (on the Pi):
    pip3 install meshtastic
    python3 operator_capture.py --hours 24        # runs ~24h then stops on its own
    # (or run in the background:  nohup python3 operator_capture.py --hours 24 & )
    # when it finishes it prints the path to a single .zip — send that file.

Two CSVs go in the bundle:
  rx_packets.csv — per heard packet: time, from/to, type, rx_snr, rx_rssi, hop_start/limit,
                   payload LENGTH (not content), channel.
  node_stats.csv — the node's own telemetry over time: channel_utilization, air_util_tx, and
                   the LocalStats loss counters (num_packets_rx_bad, num_rx_dupe, num_tx_relay).
                   These collision/loss numbers exist nowhere else — they're the whole point.
"""

from __future__ import annotations

import argparse
import csv
import hashlib
import os
import shutil
import sys
import time
from datetime import datetime, timezone

try:
    import meshtastic
    import meshtastic.tcp_interface
    from pubsub import pub
except ImportError:
    sys.exit("Missing dependency. Run:  pip3 install meshtastic")

PKT_COLS = ["iso_time", "rx_time", "from_id", "to_id", "portnum", "rx_snr", "rx_rssi",
            "hop_start", "hop_limit", "payload_len", "want_ack", "via_mqtt", "channel", "pkt_id"]
STAT_COLS = ["iso_time", "from_id", "channel_utilization", "air_util_tx", "num_packets_tx",
             "num_packets_rx", "num_packets_rx_bad", "num_rx_dupe", "num_tx_relay",
             "num_tx_relay_canceled", "num_online_nodes", "num_total_nodes", "uptime_s"]

state = {"my": None, "pkt": 0, "stat": 0, "salt": os.urandom(8).hex(), "anon": False,
         "pw": None, "sw": None, "pf": None, "sf": None}


def _now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def _id(v):
    if v is None or not state["anon"]:
        return v
    return "h" + hashlib.sha256(f"{state['salt']}{v}".encode()).hexdigest()[:10]


def on_receive(packet, interface):
    try:
        dec = packet.get("decoded", {}) or {}
        payload = dec.get("payload")
        plen = len(payload) if isinstance(payload, (bytes, bytearray)) else ""
        state["pw"].writerow([
            _now(), packet.get("rxTime"), _id(packet.get("from")), _id(packet.get("to")),
            dec.get("portnum"), packet.get("rxSnr"), packet.get("rxRssi"),
            packet.get("hopStart"), packet.get("hopLimit"), plen,
            packet.get("wantAck"), packet.get("viaMqtt"), packet.get("channel"), packet.get("id"),
        ])
        state["pf"].flush()
        state["pkt"] += 1
        tel = dec.get("telemetry")
        if tel and packet.get("from") == state["my"]:
            dm = tel.get("deviceMetrics", {}) or {}
            ls = tel.get("localStats", {}) or {}
            if dm or ls:
                state["sw"].writerow([
                    _now(), _id(packet.get("from")), dm.get("channelUtilization"), dm.get("airUtilTx"),
                    ls.get("numPacketsTx"), ls.get("numPacketsRx"), ls.get("numPacketsRxBad"),
                    ls.get("numRxDupe"), ls.get("numTxRelay"), ls.get("numTxRelayCanceled"),
                    ls.get("numOnlineNodes"), ls.get("numTotalNodes"), dm.get("uptimeSeconds"),
                ])
                state["sf"].flush()
                state["stat"] += 1
        if state["pkt"] % 100 == 0:
            print(f"[{_now()}] heard {state['pkt']} packets, {state['stat']} telemetry samples")
    except Exception as e:
        print(f"(skipped one packet: {e})")


def main():
    ap = argparse.ArgumentParser(description="Read-only meshtasticd capture for Loomwave.")
    ap.add_argument("--host", default="127.0.0.1", help="meshtasticd host (default: this Pi)")
    ap.add_argument("--port", type=int, default=4403)
    ap.add_argument("--hours", type=float, default=24.0, help="how long to capture, then stop")
    ap.add_argument("--out", default="loomwave_capture")
    ap.add_argument("--anonymize", action="store_true", help="hash node IDs before logging")
    args = ap.parse_args()
    state["anon"] = args.anonymize

    os.makedirs(args.out, exist_ok=True)
    state["pf"] = open(os.path.join(args.out, "rx_packets.csv"), "w", newline="")
    state["sf"] = open(os.path.join(args.out, "node_stats.csv"), "w", newline="")
    state["pw"] = csv.writer(state["pf"]); state["pw"].writerow(PKT_COLS)
    state["sw"] = csv.writer(state["sf"]); state["sw"].writerow(STAT_COLS)
    with open(os.path.join(args.out, "README.txt"), "w") as f:
        f.write(f"Loomwave read-only meshtasticd capture\nstarted {_now()}  host {args.host}\n"
                f"anonymized IDs: {args.anonymize}\nmetadata only; no message content recorded.\n")

    pub.subscribe(on_receive, "meshtastic.receive")
    deadline = time.time() + args.hours * 3600
    print(f"Capturing for {args.hours}h (read-only). Ctrl-C to stop early.\n")
    while True:
        try:
            iface = meshtastic.tcp_interface.TCPInterface(hostname=args.host, portNumber=args.port)
            try:
                state["my"] = iface.myInfo.my_node_num
            except Exception:
                pass
            print(f"connected to {args.host}:{args.port}. Logging...")
            while time.time() < deadline:
                time.sleep(5)
            iface.close()
            break
        except KeyboardInterrupt:
            break
        except Exception as e:
            if time.time() >= deadline:
                break
            print(f"connection issue ({e}); retrying in 20s...")
            time.sleep(20)

    for f in (state["pf"], state["sf"]):
        f.close()
    bundle = shutil.make_archive(f"loomwave_capture_{int(time.time())}", "zip", args.out)
    print(f"\nDone. Heard {state['pkt']} packets, {state['stat']} telemetry samples.")
    print(f">>> Share this file:  {os.path.abspath(bundle)}")


if __name__ == "__main__":
    main()
