commit 58be310d638fab9dff041582b5f530a01a69ce8b Author: timo Date: Sat Jul 25 20:47:48 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1af57fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,188 @@ +# Created by https://www.toptal.com/developers/gitignore/api/venv,python +# Edit at https://www.toptal.com/developers/gitignore?templates=venv,python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### venv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + +# End of https://www.toptal.com/developers/gitignore/api/venv,python diff --git a/README.md b/README.md new file mode 100644 index 0000000..06aca31 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# Raspberry Pi Signal Light API + +A REST API for controlling a Raspberry Pi equipped with a 4-channel relay HAT. + +The API controls four relays connected to the following GPIO pins: + +| Function | GPIO | +| -------- | ---: | +| Green | 26 | +| Yellow | 19 | +| Red | 13 | +| Alert | 6 | + +## Features + +* REST API built with **FastAPI** +* Interactive Swagger UI +* Green, Yellow and Red are mutually exclusive +* Alert relay blinks independently of the color relays +* Demo mode continuously cycles: + +``` +Green → Yellow → Red → Yellow → ... +``` + +* Automatic relay reset during startup and shutdown +* Status endpoint for monitoring the current state + +--- + +# Requirements + +* Raspberry Pi +* Python 3.10 or newer +* 4-channel relay HAT +* Raspberry Pi OS + +--- + +# Installation + +Clone the repository and create a virtual environment. + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +Install the dependencies: + +```bash +pip install fastapi uvicorn gpiozero +``` + +--- + +# Starting the API + +Run the application with Uvicorn: + +```bash +uvicorn relay_api:app --host 0.0.0.0 --port 8000 +``` + +If your Python file has a different name, replace `relay_api` with the filename (without the `.py` extension). + +Example: + +```bash +uvicorn signal_api:app --host 0.0.0.0 --port 8000 +``` + +The API will then be available on port **8000**. + +--- + +# API Documentation + +FastAPI automatically provides interactive documentation. + +## Swagger UI + +``` +http://:8000/docs +``` + +Swagger allows you to: + +* View all available endpoints +* Execute requests directly from your browser +* Inspect request and response schemas + +## ReDoc + +``` +http://:8000/redoc +``` + +--- + +# Endpoints + +## Colors + +Only one color can be active at a time. + +| Method | Endpoint | Description | +| ------ | --------- | --------------------- | +| POST | `/green` | Activate green relay | +| POST | `/yellow` | Activate yellow relay | +| POST | `/red` | Activate red relay | + +Selecting a color automatically turns the other two color relays off. + +--- + +## Alert + +The alert relay operates independently from the color relays. + +| Method | Endpoint | Description | +| ------ | ------------ | -------------------------- | +| POST | `/alert` | Start blinking alert relay | +| POST | `/alert/off` | Stop blinking alert relay | + +The alert relay blinks continuously: + +* 1 second ON +* 1 second OFF + +The currently active color remains unchanged. + +--- + +## Demo Mode + +Demo mode automatically cycles through the colors. + +``` +Green +↓ +Yellow +↓ +Red +↓ +Yellow +↓ +(repeat) +``` + +| Method | Endpoint | Description | +| ------ | ----------- | --------------- | +| POST | `/demo` | Start demo mode | +| POST | `/demo/off` | Stop demo mode | + +Starting demo mode overrides manual color selection until demo mode is stopped. + +The alert relay continues to operate independently during demo mode. + +--- + +## System + +| Method | Endpoint | Description | +| ------ | --------- | --------------------------------------------- | +| POST | `/off` | Turn off all relays and stop demo/alert modes | +| GET | `/status` | Retrieve the current relay status | + +--- + +# Example Usage + +Activate the green relay: + +```bash +curl -X POST http://:8000/green +``` + +Start the alert relay: + +```bash +curl -X POST http://:8000/alert +``` + +Start demo mode: + +```bash +curl -X POST http://:8000/demo +``` + +Check the current status: + +```bash +curl http://:8000/status +``` + +Turn everything off: + +```bash +curl -X POST http://:8000/off +``` + +--- + +# Relay Logic + +| Relay | Behavior | +| ------ | -------------------------------------------------- | +| Green | Mutually exclusive with Yellow and Red | +| Yellow | Mutually exclusive with Green and Red | +| Red | Mutually exclusive with Green and Yellow | +| Alert | Blinks independently of the color relays | +| Demo | Cycles Green → Yellow → Red → Yellow until stopped | + +--- + +# License + +This project is provided as-is for educational and personal use. diff --git a/main.py b/main.py new file mode 100644 index 0000000..5e08e69 --- /dev/null +++ b/main.py @@ -0,0 +1,500 @@ +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, + }, + }, + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65a9167 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.140.0 +gpiozero==2.0.1.post2 +uvicorn==0.51.0