from contextlib import asynccontextmanager from threading import Event, Lock, Thread from typing import Dict, Optional from time import sleep from fastapi import FastAPI from gpiozero import OutputDevice # BCM GPIO pin numbers RELAY_PINS = { 1: 26, # Green 2: 19, # Yellow 3: 13, # Red 4: 6, # Alert } # Most relay HATs are active-low. # Change this to True if your relay board is active-high. RELAY_ACTIVE_HIGH = False # Alert: one second on, one second off. ALERT_INTERVAL_SECONDS = 1.0 # Demo: duration of each color in the sequence. DEMO_INTERVAL_SECONDS = 0.25 # Green -> Yellow -> Red -> Yellow -> repeat DEMO_SEQUENCE = ( (1, "green"), (2, "yellow"), (3, "red"), (2, "yellow"), ) relays: Dict[int, OutputDevice] = {} # Protects access to the three color relays. color_lock = Lock() # Protects alert thread state. alert_lock = Lock() # Protects demo thread state. demo_lock = Lock() active_color: Optional[str] = None alert_stop_event = Event() alert_thread: Optional[Thread] = None demo_stop_event = Event() demo_thread: Optional[Thread] = None def reset_relays() -> None: """ Reset the entire relay system. - Stops demo mode - Stops alert mode - Turns every relay off """ stop_demo(turn_colors_off=False) stop_alert() with color_lock: relays[1].off() relays[2].off() relays[3].off() global active_color active_color = None if 4 in relays: relays[4].off() def turn_colors_off_locked() -> None: """ Turn off green, yellow, and red. color_lock must already be held. Alert is not affected. """ global active_color relays[1].off() relays[2].off() relays[3].off() active_color = None def set_color_locked(relay_number: int, color_name: str) -> None: """ Activate exactly one color relay. color_lock must already be held. Alert is not affected. """ global active_color turn_colors_off_locked() relays[relay_number].on() active_color = color_name # --------------------------------------------------------------------------- # Alert mode # --------------------------------------------------------------------------- def alert_worker() -> None: """ Toggle the alert relay until alert_stop_event is set. """ alert_relay = relays[4] try: while not alert_stop_event.is_set(): alert_relay.on() if alert_stop_event.wait(ALERT_INTERVAL_SECONDS): break alert_relay.off() if alert_stop_event.wait(ALERT_INTERVAL_SECONDS): break finally: alert_relay.off() def start_alert() -> None: """ Start alert blinking. Color and demo modes are not affected. Repeated calls do not create additional threads. """ global alert_thread with alert_lock: if alert_thread is not None and alert_thread.is_alive(): return alert_stop_event.clear() alert_thread = Thread( target=alert_worker, name="relay-alert-thread", daemon=True, ) alert_thread.start() def stop_alert() -> None: """ Stop alert blinking. Color and demo modes are not affected. """ global alert_thread with alert_lock: alert_stop_event.set() thread = alert_thread alert_thread = None # Do not hold alert_lock while waiting for the worker. if thread is not None and thread.is_alive(): thread.join() if 4 in relays: relays[4].off() def is_alert_running() -> bool: with alert_lock: return alert_thread is not None and alert_thread.is_alive() # --------------------------------------------------------------------------- # Demo mode # --------------------------------------------------------------------------- def demo_worker() -> None: """ Continuously cycle through: green -> yellow -> red -> yellow """ try: while not demo_stop_event.is_set(): for relay_number, color_name in DEMO_SEQUENCE: if demo_stop_event.is_set(): break with color_lock: set_color_locked(relay_number, color_name) if demo_stop_event.wait(DEMO_INTERVAL_SECONDS): break finally: # Leave all color relays off when demo mode ends. with color_lock: turn_colors_off_locked() def start_demo() -> None: """ Start demo mode. Alert mode is not affected. Repeated calls do not create additional demo threads. """ global demo_thread with demo_lock: if demo_thread is not None and demo_thread.is_alive(): return demo_stop_event.clear() demo_thread = Thread( target=demo_worker, name="relay-demo-thread", daemon=True, ) demo_thread.start() def stop_demo(turn_colors_off: bool = True) -> None: """ Stop demo mode. Alert mode is not affected. When turn_colors_off is True, all three color relays are turned off. """ global demo_thread with demo_lock: demo_stop_event.set() thread = demo_thread demo_thread = None # Do not hold demo_lock while waiting for the worker. if thread is not None and thread.is_alive(): thread.join() if turn_colors_off and relays: with color_lock: turn_colors_off_locked() def is_demo_running() -> bool: with demo_lock: return demo_thread is not None and demo_thread.is_alive() # --------------------------------------------------------------------------- # Manual color mode # --------------------------------------------------------------------------- def activate_manual_color(relay_number: int, color_name: str) -> None: """ Stop demo mode and activate a manually selected color. Alert mode is not affected. """ stop_demo(turn_colors_off=True) with color_lock: set_color_locked(relay_number, color_name) def turn_everything_off() -> None: """ Stop demo and alert modes and turn off every relay. """ stop_demo(turn_colors_off=True) stop_alert() if relays: with color_lock: turn_colors_off_locked() # --------------------------------------------------------------------------- # FastAPI lifecycle # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI): try: # Initialize GPIOs for relay_number, gpio_pin in RELAY_PINS.items(): relays[relay_number] = OutputDevice( pin=gpio_pin, active_high=RELAY_ACTIVE_HIGH, initial_value=False, ) # Give the GPIO driver a moment to initialize sleep(0.5) # Force every relay into the OFF state reset_relays() yield finally: reset_relays() for relay in relays.values(): relay.close() relays.clear() app = FastAPI( title="Raspberry Pi Signal Light API", description=( "Controls mutually exclusive green, yellow, and red relays. " "Alert blinking operates independently. " "Demo mode cycles through green, yellow, red, and yellow." ), version="3.0.0", lifespan=lifespan, ) # --------------------------------------------------------------------------- # API endpoints # --------------------------------------------------------------------------- @app.get("/") def root(): return { "service": "Raspberry Pi Signal Light API", "active_color": active_color, "alert_active": is_alert_running(), "demo_active": is_demo_running(), "endpoints": { "green": "POST /green", "yellow": "POST /yellow", "red": "POST /red", "start_alert": "POST /alert", "stop_alert": "POST /alert/off", "start_demo": "POST /demo", "stop_demo": "POST /demo/off", "all_off": "POST /off", "status": "GET /status", }, } @app.post("/green") def green(): activate_manual_color( relay_number=1, color_name="green", ) return { "active_color": "green", "demo_active": False, "alert_active": is_alert_running(), } @app.post("/yellow") def yellow(): activate_manual_color( relay_number=2, color_name="yellow", ) return { "active_color": "yellow", "demo_active": False, "alert_active": is_alert_running(), } @app.post("/red") def red(): activate_manual_color( relay_number=3, color_name="red", ) return { "active_color": "red", "demo_active": False, "alert_active": is_alert_running(), } @app.post("/alert") def alert(): start_alert() return { "active_color": active_color, "alert_active": True, "demo_active": is_demo_running(), "mode": "blinking", "on_seconds": ALERT_INTERVAL_SECONDS, "off_seconds": ALERT_INTERVAL_SECONDS, } @app.post("/alert/off") def alert_off(): stop_alert() return { "active_color": active_color, "alert_active": False, "demo_active": is_demo_running(), } @app.post("/demo") def demo(): start_demo() return { "demo_active": True, "alert_active": is_alert_running(), "sequence": [ color_name for _, color_name in DEMO_SEQUENCE ], "interval_seconds": DEMO_INTERVAL_SECONDS, } @app.post("/demo/off") def demo_off(): stop_demo(turn_colors_off=True) return { "active_color": None, "demo_active": False, "alert_active": is_alert_running(), } @app.post("/off") def off(): turn_everything_off() return { "active_color": None, "alert_active": False, "demo_active": False, } @app.get("/status") def status(): with color_lock: color_status = { "green": { "gpio": RELAY_PINS[1], "on": relays[1].is_active, }, "yellow": { "gpio": RELAY_PINS[2], "on": relays[2].is_active, }, "red": { "gpio": RELAY_PINS[3], "on": relays[3].is_active, }, } current_color = active_color alert_running = is_alert_running() demo_running = is_demo_running() return { "active_color": current_color, "alert_active": alert_running, "demo_active": demo_running, "relays": { **color_status, "alert": { "gpio": RELAY_PINS[4], "on": relays[4].is_active, "blinking": alert_running, }, }, }