# Teams Light firmware - runs on boot.
#
# Listens on USB serial for newline-terminated ASCII commands:
#   C r g b        solid colour (0-255 each)
#   P r g b ms     pulse/breathe the colour, one breath every `ms` milliseconds
#   B n            brightness, 0-100 % of the hard cap
#   OFF            all LEDs off
#   PING           replies "PONG <version>" (used by the Mac app to find the light)
# Every command replies "OK" or "ERR <reason>".
#
# Watchdog: if nothing arrives for 90 s, the light fades to off, so a crashed
# app or a sleeping Mac never leaves a stale colour showing.
#
# To get back to the REPL / mpremote, press Ctrl-C (mpremote does this itself).

import sys
import math
import time
import select
import machine
import neopixel

VERSION = "1.0"
NUM_LEDS = 12
DATA_PIN = 29              # ring IN (handoff said GP28; the wire is actually on GP29)
HARD_CAP = 80              # max value of any channel, out of 255. Keeps 12 LEDs under ~230 mA.
WATCHDOG_MS = 90_000       # fade off after this long without any command
FADE_MS = 2_000            # length of the watchdog fade-out
PULSE_FLOOR = 0.08         # pulses dip to 8 %, not fully dark, so the light never looks "off"
TICK_MS = 20               # animation frame time (~50 fps)
MAX_LINE = 64              # ignore absurdly long garbage lines

ring = neopixel.NeoPixel(machine.Pin(DATA_PIN), NUM_LEDS)

# --- current state ---------------------------------------------------------
mode = "off"               # "off" | "solid" | "pulse" | "fade"
colour = (0, 0, 0)         # requested colour, 0-255 per channel, before any scaling
period_ms = 2000           # pulse period
brightness = 100           # percent of HARD_CAP
mode_start = 0             # ticks_ms when the current mode/animation began
fade_from = 0.0            # level the watchdog fade starts from
last_cmd = time.ticks_ms()
last_written = None        # last pixel value sent, to skip redundant writes


def write(level):
    """Show `colour` at `level` (0.0-1.0), after brightness and the hard cap."""
    global last_written
    scale = level * brightness / 100 * HARD_CAP / 255
    px = tuple(int(c * scale) for c in colour)
    if px != last_written:
        ring.fill(px)
        ring.write()
        last_written = px


def pulse_level(now):
    # Smooth cosine breath: floor -> 1 -> floor over one period.
    phase = (time.ticks_diff(now, mode_start) % period_ms) / period_ms
    wave = (1 - math.cos(2 * math.pi * phase)) / 2
    return PULSE_FLOOR + (1 - PULSE_FLOOR) * wave


def current_level(now):
    if mode == "solid":
        return 1.0
    if mode == "pulse":
        return pulse_level(now)
    if mode == "fade":
        t = time.ticks_diff(now, mode_start) / FADE_MS
        return max(0.0, fade_from * (1 - t))
    return 0.0


def set_mode(new_mode, new_colour=None):
    global mode, colour, mode_start
    mode = new_mode
    if new_colour is not None:
        colour = new_colour
    mode_start = time.ticks_ms()


# --- commands --------------------------------------------------------------
def parse_ints(parts, count):
    if len(parts) != count:
        raise ValueError("expected {} numbers".format(count))
    return [int(p) for p in parts]


def check_rgb(r, g, b):
    for v in (r, g, b):
        if not 0 <= v <= 255:
            raise ValueError("colour values must be 0-255")
    return (r, g, b)


def handle(line):
    """Run one command line; return the reply string."""
    global period_ms, brightness
    parts = line.split()
    if not parts:
        return None                          # blank line: ignore, no reply
    cmd, args = parts[0].upper(), parts[1:]
    try:
        if cmd == "PING":
            return "PONG " + VERSION
        if cmd == "OFF":
            set_mode("off")
        elif cmd == "C":
            set_mode("solid", check_rgb(*parse_ints(args, 3)))
        elif cmd == "P":
            r, g, b, ms = parse_ints(args, 4)
            if not 200 <= ms <= 60_000:
                raise ValueError("period must be 200-60000 ms")
            period_ms = ms
            set_mode("pulse", check_rgb(r, g, b))
        elif cmd == "B":
            (n,) = parse_ints(args, 1)
            if not 0 <= n <= 100:
                raise ValueError("brightness must be 0-100")
            brightness = n
        else:
            return "ERR unknown command " + cmd
    except ValueError as e:
        return "ERR " + str(e)
    return "OK"


# --- boot animation --------------------------------------------------------
def boot_animation():
    # A soft white dot runs once round the ring, then everything goes dark.
    dim = HARD_CAP // 3
    for i in range(NUM_LEDS):
        ring.fill((0, 0, 0))
        ring[i] = (dim, dim, dim)
        ring.write()
        time.sleep_ms(40)
    ring.fill((0, 0, 0))
    ring.write()


# --- main loop -------------------------------------------------------------
def main():
    global last_cmd, fade_from
    boot_animation()
    poller = select.poll()
    poller.register(sys.stdin, select.POLLIN)
    buf = ""

    while True:
        # Wait up to one frame for input, then drain whatever has arrived.
        # Never blocks for longer than TICK_MS, so animations stay smooth.
        if poller.poll(TICK_MS):
            while poller.poll(0):
                ch = sys.stdin.read(1)
                if ch in "\r\n":
                    if buf:
                        reply = handle(buf)
                        last_cmd = time.ticks_ms()   # any line counts as "still alive"
                        if reply:
                            print(reply)
                    buf = ""
                elif len(buf) < MAX_LINE:
                    buf += ch

        now = time.ticks_ms()

        # Watchdog: start a fade from wherever the light currently is.
        if mode in ("solid", "pulse") and time.ticks_diff(now, last_cmd) > WATCHDOG_MS:
            fade_from = current_level(now)
            set_mode("fade")
        if mode == "fade" and time.ticks_diff(now, mode_start) >= FADE_MS:
            set_mode("off")

        write(current_level(now))


try:
    main()
finally:
    ring.fill((0, 0, 0))       # Ctrl-C or a crash leaves the ring dark
    ring.write()
