Initial commit

This commit is contained in:
2026-07-25 20:47:48 +02:00
commit 58be310d63
4 changed files with 910 additions and 0 deletions
+188
View File
@@ -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
+219
View File
@@ -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://<RASPBERRY_PI_IP>:8000/docs
```
Swagger allows you to:
* View all available endpoints
* Execute requests directly from your browser
* Inspect request and response schemas
## ReDoc
```
http://<RASPBERRY_PI_IP>: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://<RASPBERRY_PI_IP>:8000/green
```
Start the alert relay:
```bash
curl -X POST http://<RASPBERRY_PI_IP>:8000/alert
```
Start demo mode:
```bash
curl -X POST http://<RASPBERRY_PI_IP>:8000/demo
```
Check the current status:
```bash
curl http://<RASPBERRY_PI_IP>:8000/status
```
Turn everything off:
```bash
curl -X POST http://<RASPBERRY_PI_IP>: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.
+500
View File
@@ -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,
},
},
}
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.140.0
gpiozero==2.0.1.post2
uvicorn==0.51.0