#!/usr/bin/env python3
"""CricketOne P1 stump bring-up test.

Checks every part wired to the Pi Zero 2 W in one stump, one at a time, and prints PASS, FAIL or INFO for each:
camera and frame rate, I2C boards (INA219, LSM6DSOX, ATECC608), battery voltage, bail sensors, tamper switch,
call lights and call tone. Run after install-stump.sh and a reboot:

    /opt/cricketone/venv/bin/python /opt/cricketone/stump-bringup.py          # asks you to do things (lift a bail...)
    /opt/cricketone/venv/bin/python /opt/cricketone/stump-bringup.py --quick  # no questions, sensors only

Pins match the wiring drawing at https://iemilabs.com/build/cricketone. Untested on real parts until the P1 order arrives.
"""
import argparse
import math
import os
import subprocess
import sys
import time

PINS = {"hall1": 5, "hall2": 6, "hall3": 26, "tamper": 16, "tone": 13}
I2C = {0x40: "INA219 power monitor", 0x6A: "LSM6DSOX motion sensor", 0x60: "ATECC608 secure element"}
N_PIXELS = 16
results = []


def report(name, status, detail=""):
    results.append((name, status, detail))
    print(f"[{status:4}] {name}" + (f": {detail}" if detail else ""), flush=True)


def ask(quick, prompt):
    if quick:
        return None
    return input(f"   → {prompt} [y/n] ").strip().lower().startswith("y")


def check_camera():
    try:
        out = subprocess.run(["rpicam-hello", "--list-cameras"], capture_output=True, text=True, timeout=20).stdout
    except FileNotFoundError:
        return report("Camera found", "FAIL", "rpicam-hello missing: run install-stump.sh")
    if "ov9281" not in out.lower():
        return report("Camera found", "FAIL", "no OV9281: check the ribbon (contacts toward the board at both ends) and config.txt")
    report("Camera found", "PASS", "OV9281")
    for w, h, fps, need in ((1280, 800, 120, 115), (1280, 720, 180, 170)):
        pts = "/tmp/cricketone-pts.txt"
        cmd = ["rpicam-vid", "-t", "3000", "--width", str(w), "--height", str(h), "--framerate", str(fps),
               "--nopreview", "--shutter", "100", "--gain", "8", "-o", "/dev/null", "--save-pts", pts]
        subprocess.run(cmd, capture_output=True, timeout=30)
        try:
            stamps = [float(x) for x in open(pts).read().split()[1:] if x.replace(".", "").isdigit()]
        except OSError:
            stamps = []
        if len(stamps) < 10:
            report(f"Camera {w}x{h} @ {fps}", "FAIL" if fps == 120 else "INFO", "no frames saved")
            continue
        got = (len(stamps) - 1) / ((stamps[-1] - stamps[0]) / 1000.0)
        status = "PASS" if got >= need else ("FAIL" if fps == 120 else "INFO")
        report(f"Camera {w}x{h} @ {fps}", status, f"{got:.1f} fps measured ({len(stamps)} frames)")


def check_i2c():
    from smbus2 import SMBus
    seen = set()
    with SMBus(1) as bus:
        for _ in range(2):  # the ATECC608 sleeps; the first pass wakes it
            for addr in I2C:
                try:
                    bus.read_byte(addr)
                    seen.add(addr)
                except OSError:
                    pass
            time.sleep(0.05)
    for addr, name in I2C.items():
        if addr in seen:
            report(f"I2C {name}", "PASS", f"0x{addr:02X}")
        else:
            status = "INFO" if addr == 0x60 else "FAIL"
            report(f"I2C {name}", status, f"0x{addr:02X} not answering: check VIN, GND, SDA (pin 3), SCL (pin 5) and the STEMMA QT chain")
    return seen


def check_power(seen):
    if 0x40 not in seen:
        return
    import board
    from adafruit_ina219 import INA219
    ina = INA219(board.I2C())
    v, ma = ina.bus_voltage + ina.shunt_voltage, ina.current
    ok = 2.9 <= v <= 3.65
    report("Battery voltage", "PASS" if ok else "FAIL", f"{v:.2f} V, {ma:.0f} mA to the boost (cells must read 2.9-3.65 V)")


def check_motion(seen):
    if 0x6A not in seen:
        return
    import board
    from adafruit_lsm6ds.lsm6dsox import LSM6DSOX
    imu = LSM6DSOX(board.I2C())
    ax, ay, az = imu.acceleration
    g = math.sqrt(ax * ax + ay * ay + az * az)
    lean = math.degrees(math.acos(max(-1.0, min(1.0, abs(az) / g)))) if g else 0.0
    report("Motion sensor", "PASS" if 8.3 <= g <= 11.3 else "FAIL", f"{g:.2f} m/s² total, lean {lean:.1f}° from its z axis")


def check_inputs(quick):
    from gpiozero import Button
    halls = [("Hall 1 (GPIO5)", PINS["hall1"]), ("Hall 2 (GPIO6)", PINS["hall2"]), ("Hall 3 (GPIO26, spare)", PINS["hall3"])]
    for name, pin in halls:
        b = Button(pin, pull_up=True, bounce_time=None)
        state = "magnet seen (bail on)" if b.is_pressed else "no magnet (bail off)"
        if quick:
            report(name, "INFO", state)
        else:
            input(f"   → Put a bail magnet on {name} and press Enter ")
            on = b.is_pressed
            input(f"   → Take the magnet away and press Enter ")
            off = not b.is_pressed
            report(name, "PASS" if on and off else ("INFO" if "spare" in name else "FAIL"),
                   "sees the magnet come and go" if on and off else "no change: check VCC 3V3, GND and OUT to the Pi pin")
        b.close()
    t = Button(PINS["tamper"], pull_up=True, bounce_time=None)
    report("Tamper switch (GPIO16)", "INFO", "closed (base on)" if t.is_pressed else "open (base off)")
    if not quick:
        input("   → Press the tamper switch lever and hold it, then press Enter ")
        pressed = t.is_pressed
        input("   → Let go of the lever and press Enter ")
        report("Tamper switch works", "PASS" if pressed and not t.is_pressed else "FAIL", "COM to GPIO16, NO to GND")
    t.close()


def check_outputs(quick):
    try:
        import board
        import neopixel_spi
        px = neopixel_spi.NeoPixel_SPI(board.SPI(), N_PIXELS, brightness=0.25, auto_write=False)
        for col in ((255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)):
            px.fill(col)
            px.show()
            time.sleep(0.8)
        px.fill((0, 0, 0))
        px.show()
        seen = ask(quick, "Did both light sticks show red, green, blue, then white?")
        report("Call lights", "INFO" if seen is None else ("PASS" if seen else "FAIL"),
               "brightness capped at 25 %" if seen is not False else "check 5 V, the 74AHCT125 (pins 1, 7 to GND; 14 to 5 V) and DIN")
    except Exception as e:  # noqa: BLE001 - report any driver error as a failed step
        report("Call lights", "FAIL", str(e))
    try:
        from gpiozero import PWMOutputDevice
        tone = PWMOutputDevice(PINS["tone"], frequency=2700)
        for _ in range(2):
            tone.value = 0.5
            time.sleep(0.25)
            tone.value = 0
            time.sleep(0.15)
        tone.close()
        heard = ask(quick, "Did you hear two beeps?")
        report("Call tone", "INFO" if heard is None else ("PASS" if heard else "FAIL"), "piezo + on GPIO13, − on GND")
    except Exception as e:  # noqa: BLE001
        report("Call tone", "FAIL", str(e))


def check_temp():
    try:
        t = int(open("/sys/class/thermal/thermal_zone0/temp").read()) / 1000
        report("Pi temperature", "PASS" if t < 70 else "INFO", f"{t:.1f} °C (soak test logs this for 2 hours)")
    except OSError:
        pass


def main():
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--quick", action="store_true", help="no questions; read the sensors only")
    args = ap.parse_args()
    print(f"CricketOne stump bring-up on {os.uname().nodename}\n")
    check_camera()
    seen = check_i2c()
    check_power(seen)
    check_motion(seen)
    check_inputs(args.quick)
    check_outputs(args.quick)
    check_temp()
    fails = [r for r in results if r[1] == "FAIL"]
    print(f"\n{len(results) - len(fails)} of {len(results)} steps passed or noted; {len(fails)} failed.")
    for name, _, detail in fails:
        print(f"  FAIL {name}: {detail}")
    sys.exit(1 if fails else 0)


if __name__ == "__main__":
    main()
