# SPDX-FileCopyrightText: 2025 JG for Cedar Grove Maker Studios
# SPDX-License-Identifier: MIT
"""
analyzer/code.py
ver 1.0 2025-11-09 11:16PM

A custom scientific laboratory equipment theatrical prop lighting controller
built into a stripped flow cytometry analyzer.

For the Adafruit M4 Express Feather and optional OLED display.

CedarGrove PaletteFader and RangeSlicer are available from the Adafruit
CircuitPython Community Bundle.

NeoPixel strip configuration (daisy-chained)
STRIP      Description         Pixel Range Offset Length
---------- ------------------- ----------- ------ ------
MODE_LEDS, Stoplight LEDs,     [  0:  0],   0,     1
STRIP_A,   Equipment Interior, [  1: 30],   1,    30
STRIP_B,   processing area,    [ 31: 53],  31,    23
BEAM,      cell uplight,       [ 54: 85],  54,    32
STRIP_D,   cell chamber,       [ 86:122],  86,    37

Lighting zone logical configuration
ZONE  Description     Pixel Index
----- --------------- -----------------------
MODE, Process LEDs    [  0:   0]
INT,  Interior,       [  1:  30], [ 41:  53]
CAVE, Sample Cave,    [ 31:  40]
CELL, Sample Chamber, [ 86: 122]
BEAM, Under Cell,     [ 54:  85]
"""

import asyncio
import board
import pwmio
import time
import gc
from analogio import AnalogIn
from digitalio import DigitalInOut, Direction, Pull
import neopixel
from i2cdisplaybus import I2CDisplayBus
import adafruit_displayio_sh1107
import displayio
from cedargrove_palettefader.palettefader import PaletteFader
from cedargrove_rangeslicer import Slicer

class Pixels:
    # Creates Lists of pixels for each zone
    MODE = [0]  # A single pixel
    INT = [pix for pix in range(1, 30 + 1)] + [pix for pix in range(41, 53 + 1)]
    CAVE = [pix for pix in range(31, 40 + 1)]
    CELL = [pix for pix in range(86, 122 + 1)]
    BEAM = [pix for pix in range(54, 85 + 1)]

class State:
    """
    Analyzer process state definitions and parameters.
    """
    # Analyzer Process States
    TEST = "TEST"  # For setting brightness and color
    REST = "REST"  # Darkened analyzer with heartbeat
    WAKE = "WAKE"  # Warm the analyzer for operation
    IDLE = "IDLE"  # Wait for sample
    LOAD = "LOAD"  # Load the sample
    SCAN = "SCAN"  # Analyze the sample

    ALL = [TEST, REST, WAKE, IDLE, LOAD, SCAN]

    """
    State Parameters Dictionary
    Define each zone's [brightness, color index] and state timeout parameters
      - The brightness value is the factor to apply to the zone's rear panel
        control setting
      - The color index value is the index of the zone's color palette
      - When the beam color index is None, the color is set by the rear panel
        beam color control setting
      - The state timeout parameter is in seconds; None will hold until the
        start button is pressed to change to the next state
        
      Dictionary state definition example:
        state   mode      int       cave      cell      beam         timeout
        ---- :  --------  --------  --------  --------  -----------  -------
        TEST : [[1.0, 4], [1.0, 1], [1.0, 1], [1.0, 2], [1.0, None], 30],
    """
    params = {
        TEST: [[1.0, 4], [1.0, 1], [1.0, 1], [1.0, 2], [1.0, None], 30],
        REST: [[1.0, 1], [1.0, 1], [0.5, 1], [0.0, 1], [0.0, 1], None],
        WAKE: [[1.0, 1], [1.0, 1], [0.5, 1], [0.0, 1], [0.0, 1], 1],
        IDLE: [[1.0, 3], [1.0, 1], [1.0, 1], [0.0, 1], [0.0, 1], 2],
        LOAD: [[1.0, 2], [1.0, 1], [1.0, 5], [1.0, 5], [1.0, 3], None],
        SCAN: [[1.0, 1], [1.0, 1], [1.0, 1], [1.0, 2], [1.0, 1], None],
    }

class Color:
    """
    Define color values, reference palettes, and brightness-controlled
    palette (PaletteFader) objects.
    """
    # Some pure colors
    RED = 0xFF0000
    GREEN = 0x00FF00
    YELLOW = 0xFFFF00
    BLUE = 0x0000FF
    PURPLE = 0xFF00FF
    BLACK = 0x000000
    WHITE = 0xFFFFFF

    # For RGB Stemma-connected Mode LEDs
    LED_RED = 0x400000  # RED LED
    LED_GRN = 0x004000  # GRN LED
    LED_YEL = 0x0000FF  # YEL LED
    LED_ALL = 0x4040FF  # all LEDs ON

    ref_palette = displayio.Palette(6)
    ref_palette = [BLACK, RED, BLUE, GREEN, PURPLE, WHITE]

    mode_palette = displayio.Palette(5)
    mode_palette = [BLACK, LED_RED, LED_GRN, LED_YEL, LED_ALL]

    # Define the zone pixel PaletteFader palettes
    all_colors = [
        PaletteFader(mode_palette, 0.0, normalize=False),
        PaletteFader(ref_palette, 0.0, normalize=False),
        PaletteFader(ref_palette, 0.0, normalize=False),
        PaletteFader(ref_palette, 0.0, normalize=False),
        PaletteFader(ref_palette, 0.0, normalize=False),
    ]

# Instantiate OLED Display as REPL if available
displayio.release_displays()
try:
    i2c = board.I2C()  # uses board.SCL and board.SDA
    display_bus = I2CDisplayBus(i2c, device_address=0x3C)
    display = adafruit_displayio_sh1107.SH1107(display_bus, width=128, height=76)
except RuntimeError as err:
    print("WARNING: display not found:", err)
    print("Analyzer will operate without display.")

# Instantiate the piezo buzzer; pin D4
piezo = pwmio.PWMOut(board.D4, duty_cycle=0, frequency=440, variable_frequency=True)

# Instantiate the two control panel push buttons
pb_start = DigitalInOut(board.D0)  # RX
pb_start.direction = Direction.INPUT
pb_start.pull = Pull.UP

pb_stop = DigitalInOut(board.D1)  # TX
pb_stop.direction = Direction.INPUT
pb_stop.pull = Pull.UP

# Instantiate the OLED start button (for testing)
disp_sw_a = DigitalInOut(board.D9)  # OLED button A
disp_sw_a.direction = Direction.INPUT
disp_sw_a.pull = Pull.UP

# Instantiate the five analog inputs (A1 - A5)
analog_in = [
    AnalogIn(board.A1),
    AnalogIn(board.A2),
    AnalogIn(board.A3),
    AnalogIn(board.A4),
    AnalogIn(board.A5),
]

# Instantiate the five RangeSlicer instances for control value mapping
control = [
    Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
           out_slice=1 / 100, hyst_factor=0.25,
           out_integer=False),  # INT brightness
    Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
           out_slice=1 / 100, hyst_factor=0.25,
           out_integer=False),  # CAVE brightness
    Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
           out_slice=1 / 100, hyst_factor=0.25,
           out_integer=False),  # CELL brightness
    Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
           out_slice=1 / 100, hyst_factor=0.25,
           out_integer=False),  # BEAM brightness
    Slicer(in_min=400, in_max=65000, out_min=1, out_max=5,
           out_slice=1, hyst_factor=0.25,
           out_integer=True),  # BEAM color idx
]

# Initialize the control previous value history list
knob_old = [
    0.0,  # INT brightness
    0.0,  # CAVE brightness
    0.0,  # CELL brightness
    0.0,  # BEAM brightness
    0.0,  # BEAM color index
]

# Instantiate NeoPixel data output pins for pixel strips and on-board status
status = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.05)
status[0] = Color.YELLOW  # Startup
pixel_strip = neopixel.NeoPixel(board.D5, 123, brightness=1.0)

def fill_mode(brightness=0.0, color_idx=4):
    # Mode: Front Panel Process Traffic Lights; default ALL ON; zone index = 0
    status[0] = Color.PURPLE  # Busy
    Color.all_colors[0].brightness = brightness
    for pixel in Pixels.MODE:
        pixel_strip[pixel] = Color.all_colors[0].palette[color_idx]
    status[0] = Color.BLUE  # Normal

def fill_int(brightness=0.0, color_idx=1):
    # Interior: Analyzer Interior; default color RED; zone index = 1
    status[0] = Color.PURPLE  # Busy
    Color.all_colors[1].brightness = brightness
    for pixel in Pixels.INT:
        pixel_strip[pixel] = Color.all_colors[1].palette[color_idx]
    status[0] = Color.BLUE  # Normal

def fill_cave(brightness=0.0, color_idx=1):
    # Cave: Sample Chamber Cave Interior; default color RED; zone index = 2
    status[0] = Color.PURPLE  # Busy
    Color.all_colors[2].brightness = brightness
    for pixel in Pixels.CAVE:
        pixel_strip[pixel] = Color.all_colors[2].palette[color_idx]
    status[0] = Color.BLUE  # Normal

def fill_cell(brightness=0.0, color_idx=2):
    # Cell: Sample Chamber Exterior; default color BLUE; zone index = 3
    status[0] = Color.PURPLE  # Busy
    Color.all_colors[3].brightness = brightness
    for pixel in Pixels.CELL:
        pixel_strip[pixel] = Color.all_colors[3].palette[color_idx]
    status[0] = Color.BLUE  # Normal

def fill_beam(brightness=0.0, color_idx=1):
    # Beam: Uplight brightness and color; default color RED; zone index = 4
    status[0] = Color.PURPLE  # Busy
    Color.all_colors[4].brightness = brightness
    for pixel in Pixels.BEAM:
        pixel_strip[pixel] = Color.all_colors[4].palette[color_idx]
    status[0] = Color.BLUE  # Normal

def beep(frequency=440, duration=0.5):
    # Beep the piezo buzzer
    piezo.frequency = frequency
    piezo.duty_cycle = int(65535 / 2)  # Beep tone on; 50% duty cycle
    time.sleep(duration)
    piezo.duty_cycle = 0  # Beep tone off

def rgb_tuple_to_hex(value):
    # Convert an RGB color tuple to a 24-bit hex value
    return (value[0] << 16) | (value[1] << 8) | value[2]

def advance_state():
    # Advance to next state; loop to REST state rather than TEST
    global STATE
    # Advance STATE to next state; loop back to REST
    state_index = State.ALL.index(STATE)
    state_index += 1
    if state_index >= len(State.ALL):
        state_index = 1  # loop to REST state
    STATE = State.ALL[state_index]
    print(f"* new STATE {STATE}")

async def read_zone_controls():
    # Watch for state change; read zone controls and update pixels
    global STATE, knob_old
    beep(523, 0.25)  # C6; wake-up
    state_timer = time.monotonic()  # Initialize state duration timer
    new_state = True  # Initialize state changed flag
    while True:
        await asyncio.sleep(0.6)
        # Check Start switch; used for state change
        if not (pb_start.value and disp_sw_a.value):
            status[0] = Color.YELLOW  # Waiting for input
            beep(523, 0.25)  # C6
            print(f"* button old: {STATE}")
            while not (pb_start.value and disp_sw_a.value):
                time.sleep(0.1)
            beep(784, 0.25)  # G5
            advance_state()
            new_state = True  # State changed flag
            state_timer = time.monotonic()  # Start the state timeout timer
            status[0] = Color.BLUE  # Normal

        # STATE duration timeouts; used for state change
        if State.params[STATE][5] is not None:
            if time.monotonic() - state_timer >= State.params[STATE][5]:
                print(f"* timeout old: {STATE}")
                advance_state()
                new_state = True  # State changed flag
                state_timer = time.monotonic()  # Start the state timeout timer

        # Read panel zone control knobs
        #   INT bright, CAVE bright, CELL bright, BEAM bright,  BEAM color
        knob_value = [
            round(control[0].range_slicer(analog_in[0].value)[0], 2),
            round(control[1].range_slicer(analog_in[1].value)[0], 2),
            round(control[2].range_slicer(analog_in[2].value)[0], 2),
            round(control[3].range_slicer(analog_in[3].value)[0], 2),
            round(control[4].range_slicer(analog_in[4].value)[0], 2),
        ]

        # Update Zone Brightness and Color Values

        # INT Zone Brightness; knob index = 0, zone index = 1
        if (knob_value[0] != knob_old[0]) or new_state:
            # Update when value or state has changed
            knob_old[0] = knob_value[0]
            bright_factor, color_idx = State.params[STATE][1]  # Get params
            fill_int(knob_value[0] * bright_factor, color_idx)

        # CAVE Zone Brightness; knob index = 1, zone index = 2
        if (knob_value[1] != knob_old[1]) or new_state:
            # Update when value or state has changed
            knob_old[1] = knob_value[1]
            bright_factor, color_idx = State.params[STATE][2]  # Get params
            fill_cave(knob_value[1] * bright_factor, color_idx)

        # CELL Zone Brightness; knob index = 2, zone index = 3
        if (knob_value[2] != knob_old[2]) or new_state:
            # Update when value or state has changed
            knob_old[2] = knob_value[2]
            bright_factor, color_idx = State.params[STATE][3]  # Get params
            fill_cell(knob_value[2] * bright_factor, color_idx)

        # BEAM Zone Color; knob index = 4, zone index = 4
        if State.params[STATE][4][1] is None:
            # BEAM zone color set by control and latest color value
            if (knob_value[4] != knob_old[4]) or new_state:
                # Update when value or state has changed
                knob_old[4] = knob_value[4]
                bright_factor, _ = State.params[STATE][4]  # Get params
                fill_beam(knob_value[3] * bright_factor, knob_value[4])
        else:
            # BEAM zone color set by state parameter
            if new_state:
                bright_factor, color_idx = State.params[STATE][4]  # Get params
                fill_beam(knob_old[3] * bright_factor, color_idx)

        # BEAM Zone Brightness; knob index = 3, zone index = 4
        if (knob_value[3] != knob_old[3]) or new_state:
            # Update when value or state has changed
            knob_old[3] = knob_value[3]
            bright_factor, _ = State.params[STATE][4]  # Get params
            if State.params[STATE][4][1] is None:
                # BEAM color set by latest control value
                color_idx = knob_old[4]  # BEAM color knob
            else:
                # BEAM color is set by state parameter
                _, color_idx = State.params[STATE][4]  # Get params
            fill_beam(knob_value[3] * bright_factor, color_idx)

        new_state = False

async def animate_int(delay=1.0):
    while True:
        await asyncio.sleep(delay)
        for pixel in range(43, 45 + 1):
            if rgb_tuple_to_hex(pixel_strip[45]) == Color.all_colors[1].palette[1]:
                pixel_strip[pixel] = Color.all_colors[1].palette[3]
                pixel_strip[pixel + 3] = Color.all_colors[1].palette[1]
            else:
                pixel_strip[pixel] = Color.all_colors[1].palette[1]
                pixel_strip[pixel + 3] = Color.all_colors[1].palette[3]

async def animate_mode(delay=1.5, steps=15):
    global STATE
    while True:
        # Gradually brighten and dim a mode LED
        for i in range(int(steps * 0.10), steps + 1):
            Color.all_colors[0].brightness = 1.0 * (i / steps)
            pixel_strip[0] = Color.all_colors[0].palette[State.params[STATE][0][1]]
            await asyncio.sleep(delay / steps)
        for i in range(steps, int(steps * 0.10), -1):
            Color.all_colors[0].brightness = 1.0 * (i / steps)
            pixel_strip[0] = Color.all_colors[0].palette[State.params[STATE][0][1]]
            await asyncio.sleep(delay / steps)

async def animate_status():
    # Report current state, memory usage, and collect garbage
    global STATE
    while True:
        gc.collect()
        print(f"-- {STATE} {time.monotonic():0.0f} sec")
        print(f"   mem_free: {gc.mem_free() / 1000:3.0f}kb")
        await asyncio.sleep(15)

async def main():
    # Define the async tasks
    animate_int_task = asyncio.create_task(animate_int())
    read_ctrls_task = asyncio.create_task(read_zone_controls())
    animate_status_task = asyncio.create_task(animate_status())
    animate_mode_task = asyncio.create_task(animate_mode())
    # Start the async tasks
    await asyncio.gather(
        read_ctrls_task,
        animate_int_task,
        animate_status_task,
        animate_mode_task,
    )
    print("async main() done")  # Should never reach this point

# ### PRIMARY PROCESS ###
#   Establish the start-up state
STATE = State.TEST
#   Start the asynchronous task manager
asyncio.run(main())
print("primary process done")  # Should never reach this point