rushabhcodes/desk-fidget-counter

A desk knob assembly combining a circuit board with PETG-printed mechanical parts and vendor CAD-modeled hardware.

Version
5.6.1
License
MIT
Stars
0

companion/dashboard.py

#!/usr/bin/env python3
"""Send local computer metrics to Desk Knob over USB CDC. No network use."""
# SPDX-License-Identifier: MIT
import argparse
from datetime import datetime
import json
import math
import sys
import time


def tenths(percent):
    value = float(percent)
    if not math.isfinite(value):
        raise ValueError("Metric is not finite")
    return min(1000, max(0, round(value * 10)))


def sample(metrics, now=None):
    now = now or datetime.now()
    cpu = tenths(metrics.cpu_percent(interval=None))
    ram = tenths(metrics.virtual_memory().percent)
    battery = metrics.sensors_battery()
    bat = -1 if battery is None else min(100, max(0, round(battery.percent)))
    ac = int(battery.power_plugged) if battery is not None else 0
    return f"DASH {now:%H:%M:%S %Y-%m-%d} {cpu} {ram} {bat} {ac}\n".encode("ascii")


def identify(port):
    # The development VID/PID is shared by other TinyUSB examples. Match our
    # application identity before transmitting any telemetry, even for --port.
    port.reset_input_buffer()
    port.write(b"\nSTATUS\n")
    deadline = time.monotonic() + 2
    for _ in range(20):
        if time.monotonic() >= deadline:
            break
        line = port.readline(512)
        try:
            status = json.loads(line)
        except (ValueError, UnicodeError):
            continue
        if isinstance(status, dict):
            return status.get("device") == "desk-fidget-counter" and status.get("protocol") == 1
    return False


def connect(serial, list_ports, requested=None):
    names = [requested] if requested else [p.device for p in list_ports.comports()
                                         if p.vid == 0xCAFE and p.pid == 0x4011]
    matches = []
    for name in names:
        port = None
        try:
            port = serial.Serial(name, 115200, timeout=.15, write_timeout=1)
            if identify(port):
                matches.append(port)
                port = None
        except (serial.SerialException, OSError):
            pass
        finally:
            if port is not None:
                port.close()
    if len(matches) != 1:
        for port in matches:
            port.close()
        if len(matches) > 1:
            raise RuntimeError("Multiple Desk Knobs found; select one with --port")
        raise OSError("Desk Knob not found; check USB, firmware and serial-port permissions")
    return matches[0]


def run(args):
    import psutil
    if args.sample:
        psutil.cpu_percent(interval=.2)
        sys.stdout.buffer.write(sample(psutil))
        return
    import serial
    from serial.tools import list_ports
    psutil.cpu_percent(interval=None)  # Prime the first CPU interval.
    last_error = None
    while True:
        try:
            with connect(serial, list_ports, args.port) as port:
                print(f"Dashboard connected: {port.port}", file=sys.stderr)
                last_error = None
                heartbeat = time.monotonic()
                while True:
                    started = time.monotonic()
                    port.write(sample(psutil))
                    # Confirm application identity/liveness periodically. This
                    # also reads errors instead of filling the CDC receive FIFO.
                    if started - heartbeat >= 5:
                        if not identify(port):
                            raise OSError("Dashboard handshake lost")
                        heartbeat = started
                    if args.once:
                        return
                    time.sleep(max(0, 1 - (time.monotonic() - started)))
        except (serial.SerialException, OSError) as error:
            if args.once:
                raise
            if str(error) != last_error:
                print(str(error) + "; retrying every 2 seconds", file=sys.stderr)
                last_error = str(error)
            time.sleep(2)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--port", help="Explicit CDC port, e.g. /dev/cu.usbmodem… or COM5")
    parser.add_argument("--once", action="store_true", help="Send one update and exit")
    parser.add_argument("--sample", action="store_true", help="Print local metrics without opening USB")
    args = parser.parse_args()
    try:
        run(args)
    except KeyboardInterrupt:
        pass
    except (ImportError, OSError, RuntimeError, ValueError) as error:
        parser.exit(1, f"{error}\n")


if __name__ == "__main__":
    main()