This commit is contained in:
2026-06-23 15:30:55 +02:00
commit aa73a4e255
9 changed files with 1829 additions and 0 deletions
+464
View File
@@ -0,0 +1,464 @@
# Raspberry Pi IR Web API
Kleine Python Web API für Raspberry Pi zur Ausführung definierter IR-Kommandos mittels `ir-ctl`.
Features:
- REST API mit API-Key
- Kleine Weboberfläche ohne Login
- JSON-basierte Konfiguration
- Definierte Kommandos
- Keine Shell Injection (`shell=False`)
- Timeout pro Kommando
- Logging
- Geeignet für openHAB HTTP Binding
- Kein Docker notwendig
---
# Architektur
```text
+-------------------+
| openHAB / Browser |
+-------------------+
|
v
+-------------------+
| Python Web API |
| script-api.py |
+-------------------+
|
v
+-------------------+
| ir-ctl |
| Linux IR Subsystem|
+-------------------+
|
v
+-------------------+
| IR Sender GPIO |
+-------------------+
```
---
# Voraussetzungen
## Pakete installieren
```bash
sudo apt update
sudo apt install -y ir-ctl python3
```
Optional:
```bash
sudo apt install -y v4l-utils
```
---
# Dateien
```text
/home/timm/IRSender/
├── script-api.py
├── commands.json
├── apiout.log
├── blue.txt
├── brighter.txt
├── darker.txt
├── defaultColor.txt
├── fade.txt
├── green.txt
├── LichtAus.txt
├── LichtEin.txt
├── Nachtlicht.txt
├── off.txt
├── on.txt
├── purple.txt
├── red.txt
├── smooth.txt
├── white.txt
└── yellow.txt
```
---
# Starten
```bash
cd /home/timm/IRSender
chmod +x script-api.py
nohup python3 -u script-api.py ./commands.json >> apiout.log 2>&1 &
```
---
# Stoppen
```bash
pkill -f script-api.py
```
---
# Logs anzeigen
```bash
tail -f /home/timm/IRSender/apiout.log
```
---
# Health Check
```bash
curl http://<rpi-ip>:8080/health
```
Antwort:
```json
{
"ok": true
}
```
---
# Weboberfläche
```text
http://<rpi-ip>:8080/
```
Die Weboberfläche benötigt keinen Login.
Nur Commands mit:
```json
"web_enabled": true
```
werden angezeigt.
---
# REST API
## Kommando ausführen
### Request
```bash
curl -s -X POST \
-H "X-API-Key: change-this-global-api-key" \
-H "Content-Type: application/json" \
http://<rpi-ip>:8080/run/lirc-tv-power \
-d '{"args":{}}'
```
### Response
```json
{
"ok": true,
"request_id": "f8d7f6a1",
"command_id": "lirc-tv-power",
"exit_code": 0,
"timed_out": false,
"duration_ms": 124,
"stdout": "",
"stderr": ""
}
```
---
# Kommandos anzeigen
```bash
curl http://<rpi-ip>:8080/commands
```
---
# commands.json
## Beispiel
```json
{
"host": "0.0.0.0",
"port": 8080,
"commands": {
"lirc-tv-power": {
"description": "Send TV power button",
"web_title": "TV Power",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": [
"ir-ctl",
"--send",
"/home/timm/IRSender/LichtEin.txt"
],
"args": {}
}
}
}
```
---
# Neues Kommando hinzufügen
## Beispiel
```json
"my-command": {
"description": "Example command",
"web_title": "Example",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": [
"ir-ctl",
"--send",
"/home/timm/IRSender/example.txt"
],
"args": {}
}
```
---
# openHAB Integration
## HTTP Thing
### Thing
```text
UID: http:url:ir_sender
label: IR Sender
thingTypeUID: http:url
configuration:
baseURL: http://<rpi-ip>:8080
commandMethod: POST
contentType: application/json
headers:
- X-API-Key=change-this-global-api-key
```
---
## Channel
```text
channels:
- id: lirc_tv_power
channelTypeUID: http:switch
label: TV Power
configuration:
mode: WRITEONLY
commandExtension: /run/lirc-tv-power
onValue: {"args":{}}
offValue: {"args":{}}
```
---
## Item
```text
Switch IR_TV_Power "TV Power"
{ channel="http:url:ir_sender:lirc_tv_power" }
```
---
# Systemd Service
## Service Datei
```ini
[Unit]
Description=Raspberry Pi IR Web API
After=network-online.target
[Service]
Type=simple
User=timm
WorkingDirectory=/home/timm/IRSender
ExecStart=/usr/bin/python3 /home/timm/IRSender/script-api.py /home/timm/IRSender/commands.json
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=false
[Install]
WantedBy=multi-user.target
```
---
## Installation
```bash
sudo nano /etc/systemd/system/rpi-ir-api.service
```
Service einfügen.
Dann:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now rpi-ir-api
```
---
# Service Status
```bash
sudo systemctl status rpi-ir-api
```
---
# Service Logs
```bash
journalctl -u rpi-ir-api -f
```
---
# Sicherheit
## Implementiert
- Keine Shell-Ausführung
- Keine freien Kommandos
- API-Key Prüfung
- JSON Validierung
- Timeout pro Kommando
- Regex Validierung
- HTML Escaping
- CSRF Token für Web UI
- Security Headers
---
# Wichtig
## Niemals erlauben:
```json
"command": ["bash", "-c", "{user_input}"]
```
oder:
```python
shell=True
```
Sonst wird aus der Lichtsteuerung sehr schnell eine Fernwartung mit Spezialeffekten.
---
# Troubleshooting
## 403 invalid api key
API Key falsch oder Header fehlt.
Prüfen:
```bash
-H "X-API-Key: change-this-global-api-key"
```
---
## 400 invalid json
Body ist kein gültiges JSON.
Korrekt:
```json
{"args":{}}
```
---
## 500 command failed
`ir-ctl` oder Datei prüfen:
```bash
ir-ctl --send /home/timm/IRSender/LichtEin.txt
```
---
## Permission denied
GPIO / IR Rechte prüfen.
Test:
```bash
sudo ir-ctl --send /home/timm/IRSender/LichtEin.txt
```
Falls nur mit sudo funktioniert:
```bash
sudo usermod -aG video timm
```
Danach neu anmelden.
---
# Backup
Wichtig sichern:
```text
script-api.py
commands.json
*.txt
```
---
# Lizenz
Internes Projekt.
+66
View File
@@ -0,0 +1,66 @@
{
"carrier": 38000,
"repeat": 1,
"signal": [
{ "type": "mark", "duration": 2666 },
{ "type": "space", "duration": 893 },
{ "type": "mark", "duration": 410 },
{ "type": "space", "duration": 934 },
{ "type": "mark", "duration": 405 },
{ "type": "space", "duration": 463 },
{ "type": "mark", "duration": 431 },
{ "type": "space", "duration": 485 },
{ "type": "mark", "duration": 406 },
{ "type": "space", "duration": 921 },
{ "type": "mark", "duration": 868 },
{ "type": "space", "duration": 458 },
{ "type": "mark", "duration": 410 },
{ "type": "space", "duration": 484 },
{ "type": "mark", "duration": 407 },
{ "type": "space", "duration": 487 },
{ "type": "mark", "duration": 406 },
{ "type": "space", "duration": 486 },
{ "type": "mark", "duration": 409 },
{ "type": "space", "duration": 485 },
{ "type": "mark", "duration": 883 },
{ "type": "space", "duration": 905 },
{ "type": "mark", "duration": 408 },
{ "type": "space", "duration": 484 },
{ "type": "mark", "duration": 409 },
{ "type": "space", "duration": 486 },
{ "type": "mark", "duration": 409 },
{ "type": "space", "duration": 484 },
{ "type": "mark", "duration": 408 },
{ "type": "space", "duration": 486 },
{ "type": "mark", "duration": 409 },
{ "type": "space", "duration": 486 },
{ "type": "mark", "duration": 883 },
{ "type": "space", "duration": 458 },
{ "type": "mark", "duration": 408 },
{ "type": "space", "duration": 932 },
{ "type": "mark", "duration": 406 },
{ "type": "space", "duration": 488 },
{ "type": "mark", "duration": 406 },
{ "type": "space", "duration": 21217 }
]
}
+275
View File
@@ -0,0 +1,275 @@
Listening on http://0.0.0.0:8080
Config: commands.json
192.168.178.49 [16/May/2026 01:29:20] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 01:36:27] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 01:36:30] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 01:37:00] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 01:44:07] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:44:22] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:44:52] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:45:22] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:45:52] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:46:08] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:46:27] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:46:57] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:47:27] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:47:57] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:48:27] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:48:57] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:49:27] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:49:57] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:50:27] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:50:57] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:51:27] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:51:57] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:52:10] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:52:15] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:52:41] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:53:11] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:53:29] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:53:33] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:53:36] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:54:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:54:58] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 01:55:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:55:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:56:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:56:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:57:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:57:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:58:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:58:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:59:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 01:59:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:00:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:00:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:01:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:01:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:02:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:02:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:03:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:03:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:04:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:04:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:05:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:05:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:06:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:06:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:07:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:07:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:08:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:08:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:09:17] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:09:47] "GET /run/default-color HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:09:59] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:10:07] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:10:37] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:11:07] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:11:37] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:12:07] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:12:38] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:12:59] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:13:29] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:13:59] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:14:24] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:14:54] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:15:24] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:15:54] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:16:00] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:16:04] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:16:34] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:17:04] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:17:34] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:18:04] "POST /run/default-color HTTP/1.1" 403 -
192.168.178.49 [16/May/2026 02:18:34] "POST /run/default-color HTTP/1.1" 403 -
request_id=08bc90cc-8f80-4670-91fc-34d665e5c815 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:18:59] "POST /run/default-color HTTP/1.1" 200 -
request_id=0779ebeb-ea40-485b-91bf-6dd73b3d8987 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:19:29] "POST /run/default-color HTTP/1.1" 200 -
request_id=7de7058d-9150-4fd8-b101-46dbbe00eaab command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:19:56] "POST /run/default-color HTTP/1.1" 200 -
request_id=82cdc6b3-4a66-4eca-b616-558af970abbe command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:20:26] "POST /run/default-color HTTP/1.1" 200 -
request_id=c12ad6b0-b449-41ca-bdd6-673fad54162b command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:20:56] "POST /run/default-color HTTP/1.1" 200 -
request_id=96c834f2-c3c9-4ffc-97df-9e1f2d9acc49 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:21:26] "POST /run/default-color HTTP/1.1" 200 -
request_id=35ae01f4-2402-4e6e-9620-db0503395af5 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:21:56] "POST /run/default-color HTTP/1.1" 200 -
request_id=60fa399b-792e-4710-bc5e-5fba714a87d7 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:22:26] "POST /run/default-color HTTP/1.1" 200 -
request_id=be4616cd-9033-4f83-934d-928a62352f5f command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:22:56] "POST /run/default-color HTTP/1.1" 200 -
request_id=c4667876-9088-4073-a1b1-aa512800e658 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:23:26] "POST /run/default-color HTTP/1.1" 200 -
request_id=e92e3464-6d07-49d8-a575-413a9227569b command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:23:42] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:23:42] "POST /run/default-color HTTP/1.1" 200 -
request_id=13611a6f-57d6-4e0f-832b-93feed7e1789 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:24:12] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:24:12] "POST /run/default-color HTTP/1.1" 200 -
request_id=39e49f9e-e1ed-4c3e-8e3d-a83b2653ea73 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:24:35] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:24:35] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:25:05] "POST /run/aus HTTP/1.1" 404 -
request_id=a3f3deae-7d43-4cea-8e09-a35ff27a5f93 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:25:05] "POST /run/default-color HTTP/1.1" 200 -
request_id=153f9708-d23f-4ecb-ae6a-966cc854a3bb command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:25:35] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:25:35] "POST /run/default-color HTTP/1.1" 200 -
request_id=8416c145-cd05-43cb-8f37-f1dc58b83cc5 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:26:05] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:26:05] "POST /run/default-color HTTP/1.1" 200 -
request_id=8b5db5b6-fa6b-4678-9057-8c2f9627cd43 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:26:35] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:26:35] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:27:05] "POST /run/aus HTTP/1.1" 404 -
request_id=8dcf8526-654a-436d-94b4-398d5d743c43 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:27:05] "POST /run/default-color HTTP/1.1" 200 -
request_id=02b217d3-efb4-4c27-8617-ae0b3f1bf63a command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:27:35] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:27:35] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:28:05] "POST /run/aus HTTP/1.1" 404 -
request_id=58181ed3-c4c0-4313-ba82-c9bb81c8a382 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:28:05] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:28:35] "POST /run/aus HTTP/1.1" 404 -
request_id=8faa0c58-d86c-4ee1-a94d-725810650d0a command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:28:35] "POST /run/default-color HTTP/1.1" 200 -
request_id=aaa86e58-08c9-4fe3-a9d6-6be11bdbea9b command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:29:05] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:29:05] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:29:35] "POST /run/aus HTTP/1.1" 404 -
request_id=cbb264f0-873b-4643-8eb9-c2951aa2dabf command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:29:35] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:30:05] "POST /run/aus HTTP/1.1" 404 -
request_id=e54719c6-a2f6-4f04-ab26-7bd118608871 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:30:05] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:30:26] "POST /run/default-color HTTP/1.1" 400 -
192.168.178.49 [16/May/2026 02:30:28] "POST /run/default-color HTTP/1.1" 400 -
request_id=ce11713a-fc90-4a8a-9926-809eaf3cb3a1 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:30:35] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:30:35] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:31:05] "POST /run/aus HTTP/1.1" 404 -
request_id=5e6843be-7f8f-4753-b22e-6c3df88d92fa command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:31:05] "POST /run/default-color HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 02:31:10] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:32:47] "POST /run/aus HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 02:36:07] "POST /run/off HTTP/1.1" 400 -
192.168.178.49 [16/May/2026 02:36:20] "POST /run/on HTTP/1.1" 400 -
192.168.178.49 [16/May/2026 02:36:42] "POST /run/default-color HTTP/1.1" 400 -
192.168.178.49 [16/May/2026 02:40:07] "POST /run/default-color HTTP/1.1" 400 -
request_id=0120b342-b821-431b-8c2f-e61c9ccd1779 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:40:38] "POST /run/default-color HTTP/1.1" 200 -
request_id=f0e7ca6c-7a71-4cea-9b28-1e74b503baa8 command_id=off command=['ir-ctl', '--send', '/home/timm/IRSender/off.txt']
192.168.178.49 [16/May/2026 02:41:22] "POST /run/off HTTP/1.1" 200 -
request_id=57597df5-e9dd-4276-b277-bc51fa13e32f command_id=on command=['ir-ctl', '--send', '/home/timm/IRSender/on.txt']
192.168.178.49 [16/May/2026 02:41:23] "POST /run/on HTTP/1.1" 200 -
request_id=f9bf9d3b-7bc2-4995-b287-703d02ae78bf command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:41:24] "POST /run/default-color HTTP/1.1" 200 -
request_id=86f7a4f2-0f4f-4fc1-8da2-d7eb9f75beff command_id=off command=['ir-ctl', '--send', '/home/timm/IRSender/off.txt']
192.168.178.49 [16/May/2026 02:41:38] "POST /run/off HTTP/1.1" 200 -
request_id=3a1b50a2-634a-4382-a10b-e1731fcaff81 command_id=on command=['ir-ctl', '--send', '/home/timm/IRSender/on.txt']
192.168.178.49 [16/May/2026 02:42:04] "POST /run/on HTTP/1.1" 200 -
request_id=15d455b7-5b8e-441e-a65b-96f0e97ec31a command_id=on command=['ir-ctl', '--send', '/home/timm/IRSender/on.txt']
192.168.178.49 [16/May/2026 02:42:04] "POST /run/on HTTP/1.1" 200 -
request_id=ad0500f8-f2b9-43a7-92e6-459c73240b44 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:42:14] "POST /run/default-color HTTP/1.1" 200 -
request_id=b6bf3486-902a-4fe8-94f2-3b13ef421770 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.49 [16/May/2026 02:42:14] "POST /run/default-color HTTP/1.1" 200 -
Listening on http://0.0.0.0:8080
Config: commands.json
192.168.178.49 [16/May/2026 08:21:51] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 08:21:51] "GET /favicon.ico HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 08:21:53] "GET / HTTP/1.1" 200 -
request_id=ac6710b9-e33b-4df7-8ff4-f94b4f98b918 command_id=brighter command=['ir-ctl', '--send', '/home/timm/IRSender/brighter.txt']
192.168.178.49 [16/May/2026 08:21:55] "POST /web/run/brighter HTTP/1.1" 200 -
request_id=de27675f-156c-44af-b246-be3eeecb0e3c command_id=darker command=['ir-ctl', '--send', '/home/timm/IRSender/darker.txt']
192.168.178.49 [16/May/2026 08:21:59] "POST /web/run/darker HTTP/1.1" 200 -
request_id=28987032-93b2-4635-a5c9-37793334fbdc command_id=darker command=['ir-ctl', '--send', '/home/timm/IRSender/darker.txt']
192.168.178.49 [16/May/2026 08:22:01] "POST /web/run/darker HTTP/1.1" 200 -
request_id=39fbf168-85d4-4b9e-8101-a63279eb9346 command_id=darker command=['ir-ctl', '--send', '/home/timm/IRSender/darker.txt']
192.168.178.49 [16/May/2026 08:22:02] "POST /web/run/darker HTTP/1.1" 200 -
request_id=ad03bc03-fb6e-4498-b6e9-de220201eace command_id=fade command=['ir-ctl', '--send', '/home/timm/IRSender/fade.txt']
192.168.178.49 [16/May/2026 08:22:06] "POST /web/run/fade HTTP/1.1" 200 -
request_id=9d254a73-f211-4d00-bae4-2380151b2170 command_id=green command=['ir-ctl', '--send', '/home/timm/IRSender/green.txt']
192.168.178.49 [16/May/2026 08:22:09] "POST /web/run/green HTTP/1.1" 200 -
request_id=4624c540-7083-470f-9045-3bf8f4a36023 command_id=off command=['ir-ctl', '--send', '/home/timm/IRSender/off.txt']
192.168.178.49 [16/May/2026 08:22:15] "POST /web/run/off HTTP/1.1" 200 -
request_id=ebdf4c20-645b-4e14-9424-c763c848144a command_id=on command=['ir-ctl', '--send', '/home/timm/IRSender/on.txt']
192.168.178.49 [16/May/2026 08:22:18] "POST /web/run/on HTTP/1.1" 200 -
request_id=91d5b868-8ac8-44c7-aeab-e3a4f53cfec7 command_id=red command=['ir-ctl', '--send', '/home/timm/IRSender/red.txt']
192.168.178.49 [16/May/2026 08:22:22] "POST /web/run/red HTTP/1.1" 200 -
request_id=a47da5e7-6ba1-4edc-8234-1693c0cc8291 command_id=purple command=['ir-ctl', '--send', '/home/timm/IRSender/purple.txt']
192.168.178.49 [16/May/2026 08:22:24] "POST /web/run/purple HTTP/1.1" 200 -
request_id=a8af7d54-0341-453f-94c6-5528d006392b command_id=smooth command=['ir-ctl', '--send', '/home/timm/IRSender/smooth.txt']
192.168.178.49 [16/May/2026 08:22:27] "POST /web/run/smooth HTTP/1.1" 200 -
request_id=f75cf627-135b-4d2c-9728-040707de6d87 command_id=white command=['ir-ctl', '--send', '/home/timm/IRSender/white.txt']
192.168.178.49 [16/May/2026 08:22:32] "POST /web/run/white HTTP/1.1" 200 -
request_id=24de491a-786d-4a75-840a-7826310055e6 command_id=yellow command=['ir-ctl', '--send', '/home/timm/IRSender/yellow.txt']
192.168.178.49 [16/May/2026 08:22:35] "POST /web/run/yellow HTTP/1.1" 200 -
request_id=45256d4b-c898-4b6e-a34d-6920fa35d6b5 command_id=white command=['ir-ctl', '--send', '/home/timm/IRSender/white.txt']
192.168.178.49 [16/May/2026 08:22:39] "POST /web/run/white HTTP/1.1" 200 -
Listening on http://0.0.0.0:8080
Config: commands.json
192.168.178.149 [16/May/2026 09:16:43] "GET /apple-touch-icon-precomposed.png HTTP/1.1" 404 -
192.168.178.149 [16/May/2026 09:16:43] "GET /apple-touch-icon.png HTTP/1.1" 404 -
192.168.178.149 [16/May/2026 09:16:43] "GET /favicon.ico HTTP/1.1" 404 -
192.168.178.149 [16/May/2026 09:16:43] "GET / HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 09:16:47] "GET / HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 09:16:50] "GET /favicon.ico HTTP/1.1" 404 -
192.168.178.149 [16/May/2026 09:17:40] "GET / HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 09:17:42] "GET / HTTP/1.1" 200 -
----------------------------------------
Exception occurred during processing of request from ('192.168.178.149', 61332)
Traceback (most recent call last):
File "/usr/lib/python3.9/socketserver.py", line 650, in process_request_thread
self.finish_request(request, client_address)
File "/usr/lib/python3.9/socketserver.py", line 360, in finish_request
self.RequestHandlerClass(request, client_address, self)
File "/usr/lib/python3.9/socketserver.py", line 720, in __init__
self.handle()
File "/usr/lib/python3.9/http/server.py", line 434, in handle
self.handle_one_request()
File "/usr/lib/python3.9/http/server.py", line 402, in handle_one_request
self.raw_requestline = self.rfile.readline(65537)
File "/usr/lib/python3.9/socket.py", line 704, in readinto
return self._sock.recv_into(b)
ConnectionResetError: [Errno 104] Connection reset by peer
----------------------------------------
192.168.178.49 [16/May/2026 09:22:40] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 09:22:41] "GET /favicon.ico HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 10:27:25] "GET / HTTP/1.1" 200 -
192.168.178.49 [16/May/2026 10:27:25] "GET /favicon.ico HTTP/1.1" 404 -
192.168.178.49 [16/May/2026 10:27:37] "GET / HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 11:22:14] "GET / HTTP/1.1" 200 -
request_id=c69ae4c0-2071-48ea-9500-235ae9568273 command_id=fade command=['ir-ctl', '--send', '/home/timm/IRSender/fade.txt']
192.168.178.149 [16/May/2026 11:22:20] "POST /web/run/fade HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 11:22:28] "GET / HTTP/1.1" 200 -
request_id=8a4e9c5b-b93d-47a3-8a3a-79e38c35a454 command_id=default-color command=['ir-ctl', '--send', '/home/timm/IRSender/defaultColor.txt']
192.168.178.149 [16/May/2026 11:22:31] "POST /web/run/default-color HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 11:22:35] "GET / HTTP/1.1" 200 -
request_id=09e16188-3704-4265-a1c7-9772611ff0d5 command_id=licht-aus command=['ir-ctl', '--send', '/home/timm/IRSender/LichtAus.txt']
192.168.178.149 [16/May/2026 11:22:42] "POST /web/run/licht-aus HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 11:22:48] "GET / HTTP/1.1" 200 -
request_id=65cb85ac-7aa2-486b-b4f9-af87e9e9c68e command_id=licht-ein command=['ir-ctl', '--send', '/home/timm/IRSender/LichtEin.txt']
192.168.178.149 [16/May/2026 11:22:54] "POST /web/run/licht-ein HTTP/1.1" 200 -
192.168.178.149 [16/May/2026 11:22:57] "GET / HTTP/1.1" 200 -
----------------------------------------
Exception occurred during processing of request from ('192.168.178.149', 61580)
Traceback (most recent call last):
File "/usr/lib/python3.9/socketserver.py", line 650, in process_request_thread
self.finish_request(request, client_address)
File "/usr/lib/python3.9/socketserver.py", line 360, in finish_request
self.RequestHandlerClass(request, client_address, self)
File "/usr/lib/python3.9/socketserver.py", line 720, in __init__
self.handle()
File "/usr/lib/python3.9/http/server.py", line 434, in handle
self.handle_one_request()
File "/usr/lib/python3.9/http/server.py", line 402, in handle_one_request
self.raw_requestline = self.rfile.readline(65537)
File "/usr/lib/python3.9/socket.py", line 704, in readinto
return self._sock.recv_into(b)
ConnectionResetError: [Errno 104] Connection reset by peer
----------------------------------------
Listening on http://0.0.0.0:8080
Config: commands.json
+168
View File
@@ -0,0 +1,168 @@
{
"host": "0.0.0.0",
"port": 8080,
"max_body_bytes": 65536,
"commands": {
"blue": {
"description": "Send IR command blue.txt",
"web_title": "Blue",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/blue.txt"],
"args": {}
},
"brighter": {
"description": "Send IR command brighter.txt",
"web_title": "Brighter",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/brighter.txt"],
"args": {}
},
"darker": {
"description": "Send IR command darker.txt",
"web_title": "Darker",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/darker.txt"],
"args": {}
},
"default-color": {
"description": "Send IR command defaultColor.txt",
"web_title": "Default Color",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/defaultColor.txt"],
"args": {}
},
"fade": {
"description": "Send IR command fade.txt",
"web_title": "Fade",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/fade.txt"],
"args": {}
},
"green": {
"description": "Send IR command green.txt",
"web_title": "Green",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/green.txt"],
"args": {}
},
"licht-aus": {
"description": "Send IR command LichtAus.txt",
"web_title": "Licht Aus",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/LichtAus.txt"],
"args": {}
},
"licht-ein": {
"description": "Send IR command LichtEin.txt",
"web_title": "Licht Ein",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/LichtEin.txt"],
"args": {}
},
"nachtlicht": {
"description": "Send IR command Nachtlicht.txt",
"web_title": "Nachtlicht",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/Nachtlicht.txt"],
"args": {}
},
"off": {
"description": "Send IR command off.txt",
"web_title": "Off",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/off.txt"],
"args": {}
},
"on": {
"description": "Send IR command on.txt",
"web_title": "On",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/on.txt"],
"args": {}
},
"purple": {
"description": "Send IR command purple.txt",
"web_title": "Purple",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/purple.txt"],
"args": {}
},
"red": {
"description": "Send IR command red.txt",
"web_title": "Red",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/red.txt"],
"args": {}
},
"smooth": {
"description": "Send IR command smooth.txt",
"web_title": "Smooth",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/smooth.txt"],
"args": {}
},
"white": {
"description": "Send IR command white.txt",
"web_title": "White",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/white.txt"],
"args": {}
},
"yellow": {
"description": "Send IR command yellow.txt",
"web_title": "Yellow",
"web_enabled": true,
"api_key": "change-this-global-api-key",
"timeout_seconds": 10,
"command": ["ir-ctl", "--send", "/home/timm/IRSender/yellow.txt"],
"args": {}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
{
"carrier": 38000,
"repeat": 1,
"signal": [
{ "type": "mark", "duration": 8977 },
{ "type": "space", "duration": 4485 },
{ "type": "mark", "duration": 586 },
{ "type": "space", "duration": 1690 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 573 },
{ "type": "mark", "duration": 559 },
{ "type": "space", "duration": 548 },
{ "type": "mark", "duration": 582 },
{ "type": "space", "duration": 569 },
{ "type": "mark", "duration": 560 },
{ "type": "space", "duration": 545 },
{ "type": "mark", "duration": 584 },
{ "type": "space", "duration": 568 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 573 },
{ "type": "mark", "duration": 587 },
{ "type": "space", "duration": 521 },
{ "type": "mark", "duration": 577 },
{ "type": "space", "duration": 549 },
{ "type": "mark", "duration": 606 },
{ "type": "space", "duration": 1669 },
{ "type": "mark", "duration": 564 },
{ "type": "space", "duration": 1689 },
{ "type": "mark", "duration": 587 },
{ "type": "space", "duration": 1664 },
{ "type": "mark", "duration": 582 },
{ "type": "space", "duration": 1676 },
{ "type": "mark", "duration": 551 },
{ "type": "space", "duration": 1694 },
{ "type": "mark", "duration": 560 },
{ "type": "space", "duration": 1691 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 1694 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 570 },
{ "type": "mark", "duration": 580 },
{ "type": "space", "duration": 549 },
{ "type": "mark", "duration": 564 },
{ "type": "space", "duration": 543 },
{ "type": "mark", "duration": 580 },
{ "type": "space", "duration": 550 },
{ "type": "mark", "duration": 577 },
{ "type": "space", "duration": 552 },
{ "type": "mark", "duration": 577 },
{ "type": "space", "duration": 549 },
{ "type": "mark", "duration": 604 },
{ "type": "space", "duration": 548 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 546 },
{ "type": "mark", "duration": 584 },
{ "type": "space", "duration": 1692 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 1692 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 1693 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 1693 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 1696 },
{ "type": "mark", "duration": 555 },
{ "type": "space", "duration": 1697 },
{ "type": "mark", "duration": 554 },
{ "type": "space", "duration": 1694 },
{ "type": "mark", "duration": 583 },
{ "type": "space", "duration": 1668 },
{ "type": "mark", "duration": 581 },
{ "type": "space", "duration": 23166 }
]
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
import json
import sys
import time
import pigpio
DEFAULT_GPIO = 12
def load_config(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if "signal" not in data or not isinstance(data["signal"], list):
raise ValueError("JSON braucht ein 'signal'-Array.")
return data
def build_wave(pi: pigpio.pi, gpio: int, carrier_hz: int, signal: list[dict]) -> int:
period_us = max(2, round(1_000_000 / carrier_hz))
on_us = max(1, period_us // 2)
off_us = max(1, period_us - on_us)
gpio_mask = 1 << gpio
pulses = []
for item in signal:
kind = str(item.get("type", "")).lower()
duration = int(item.get("duration", 0))
if duration <= 0:
continue
if kind == "space":
pulses.append(pigpio.pulse(0, 0, duration))
elif kind == "mark":
full_cycles = duration // period_us
remainder = duration % period_us
for _ in range(full_cycles):
pulses.append(pigpio.pulse(gpio_mask, 0, on_us))
pulses.append(pigpio.pulse(0, gpio_mask, off_us))
if remainder > 0:
pulses.append(pigpio.pulse(gpio_mask, 0, remainder))
else:
raise ValueError(f"Unbekannter type: {kind} (erlaubt: mark, space)")
pi.wave_clear()
pi.wave_add_generic(pulses)
wid = pi.wave_create()
return wid
def send_wave(pi: pigpio.pi, wid: int, repeat: int) -> None:
for i in range(repeat):
pi.wave_send_once(wid)
while pi.wave_tx_busy():
time.sleep(0.001)
if i != repeat - 1:
time.sleep(0.03)
def main() -> int:
if len(sys.argv) != 2:
print("Usage: python3 ir_send.py signal.json")
return 1
cfg = load_config(sys.argv[1])
gpio = int(cfg.get("gpio", DEFAULT_GPIO))
carrier = int(cfg.get("carrier", 38000))
repeat = int(cfg.get("repeat", 1))
if repeat < 1:
repeat = 1
pi = pigpio.pi()
if not pi.connected:
print("Fehler: pigpiod läuft nicht.")
return 1
wid = -1
try:
pi.set_mode(gpio, pigpio.OUTPUT)
pi.write(gpio, 0)
wid = build_wave(pi, gpio, carrier, cfg["signal"])
if wid < 0:
print("Fehler: wave_create fehlgeschlagen.")
return 1
send_wave(pi, wid, repeat)
print("IR-Signal gesendet.")
return 0
finally:
try:
if wid >= 0:
pi.wave_tx_stop()
pi.wave_delete(wid)
except Exception:
pass
try:
pi.write(gpio, 0)
except Exception:
pass
pi.wave_clear()
pi.stop()
if __name__ == "__main__":
raise SystemExit(main())
+20
View File
@@ -0,0 +1,20 @@
List of all Commands:
add .txt
- red
- green
- blue
- white
- yellow
- purple
- defaultColor (Is set on boot)
- on
- off
- brighter
- darker
- smooth
- fade
Executable
+499
View File
@@ -0,0 +1,499 @@
#!/usr/bin/env python3
import html
import json
import re
import secrets
import subprocess
import sys
import time
import uuid
from pathlib import Path
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
CONFIG_PATH = Path(sys.argv[1] if len(sys.argv) > 1 else "./commands.json")
WEB_CSRF_TOKEN = secrets.token_urlsafe(32)
def load_config():
with CONFIG_PATH.open("r", encoding="utf-8") as f:
cfg = json.load(f)
cfg.setdefault("host", "0.0.0.0")
cfg.setdefault("port", 8080)
cfg.setdefault("max_body_bytes", 65536)
cfg.setdefault("commands", {})
if not isinstance(cfg["commands"], dict) or not cfg["commands"]:
raise RuntimeError("No commands configured")
validate_config(cfg)
return cfg
def validate_config(cfg):
for command_id, command in cfg["commands"].items():
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", command_id):
raise RuntimeError(f"Invalid command id: {command_id}")
if "command" not in command or not isinstance(command["command"], list) or not command["command"]:
raise RuntimeError(f"Command '{command_id}' needs a non-empty command list")
command.setdefault("args", {})
command.setdefault("timeout_seconds", 30)
if int(command["timeout_seconds"]) <= 0:
raise RuntimeError(f"Command '{command_id}' has invalid timeout_seconds")
if not command.get("web_enabled", False):
if not command.get("api_key") or len(command["api_key"]) < 16:
raise RuntimeError(f"Command '{command_id}' needs api_key with at least 16 chars")
placeholders = set()
for part in command["command"]:
for match in re.findall(r"{([a-zA-Z0-9_]+)}", str(part)):
placeholders.add(match)
missing_args = placeholders - set(command["args"].keys())
if missing_args:
raise RuntimeError(
f"Command '{command_id}' uses undefined args: {', '.join(sorted(missing_args))}"
)
for arg_name, rule in command["args"].items():
if not re.fullmatch(r"[a-zA-Z0-9_]{1,64}", arg_name):
raise RuntimeError(f"Command '{command_id}' has invalid arg name: {arg_name}")
pattern = rule.get("pattern", r"^[a-zA-Z0-9_.:/ -]{0,128}$")
try:
re.compile(pattern)
except re.error as exc:
raise RuntimeError(
f"Command '{command_id}' arg '{arg_name}' has invalid regex: {exc}"
)
if command.get("web_enabled", False) and rule.get("required", False):
if "web_default" not in rule and "default" not in rule:
raise RuntimeError(
f"Web command '{command_id}' needs web_default/default for required arg '{arg_name}'"
)
CONFIG = load_config()
class ApiHandler(BaseHTTPRequestHandler):
server_version = "RpiScriptApi/1.3"
def log_message(self, fmt, *args):
print(
"%s [%s] %s"
% (self.client_address[0], self.log_date_time_string(), fmt % args)
)
def send_json(self, status, payload):
data = json.dumps(payload, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_security_headers()
self.end_headers()
self.wfile.write(data)
def send_html(self, status, body):
data = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_security_headers()
self.end_headers()
self.wfile.write(data)
def send_security_headers(self):
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"
)
def read_body_raw(self):
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
raise ValueError("invalid content-length")
if length > int(CONFIG["max_body_bytes"]):
raise ValueError("request body too large")
if length == 0:
return b""
return self.rfile.read(length)
def read_json_body(self):
raw = self.read_body_raw()
if not raw:
return {}
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"invalid json: {exc}")
def read_form_body(self):
raw = self.read_body_raw()
if not raw:
return {}
content_type = self.headers.get("Content-Type", "")
if "application/x-www-form-urlencoded" not in content_type:
raise ValueError("invalid form content-type")
parsed = parse_qs(raw.decode("utf-8"), keep_blank_values=True)
return {k: v[0] if v else "" for k, v in parsed.items()}
def do_GET(self):
path = urlparse(self.path).path
if path == "/":
self.send_html(200, render_index())
return
if path == "/health":
self.send_json(200, {"ok": True})
return
if path == "/commands":
self.send_json(200, list_commands())
return
self.send_json(404, {"ok": False, "error": "not found"})
def do_POST(self):
path = urlparse(self.path).path
api_match = re.fullmatch(r"/run/([a-zA-Z0-9_-]+)", path)
web_match = re.fullmatch(r"/web/run/([a-zA-Z0-9_-]+)", path)
if api_match:
self.handle_run_api(api_match.group(1))
return
if web_match:
self.handle_run_web(web_match.group(1))
return
self.send_json(404, {"ok": False, "error": "not found"})
def handle_run_api(self, command_id):
command_cfg = CONFIG["commands"].get(command_id)
if not command_cfg:
self.send_json(404, {"ok": False, "error": "unknown command"})
return
api_key = self.headers.get("X-API-Key", "")
expected_key = command_cfg.get("api_key", "")
if not expected_key or not secrets.compare_digest(api_key, expected_key):
self.send_json(403, {"ok": False, "error": "invalid api key"})
return
try:
payload = self.read_json_body()
args = payload.get("args", {})
if not isinstance(args, dict):
raise ValueError("args must be an object")
validated_args = validate_args(command_cfg, args)
command = build_command(command_cfg, validated_args)
result = run_command(command_id, command, command_cfg)
status = 200 if result["ok"] else 500
self.send_json(status, result)
except subprocess.TimeoutExpired as exc:
self.send_json(504, timeout_result(command_id, exc))
except Exception as exc:
self.send_json(400, {"ok": False, "error": str(exc)})
def handle_run_web(self, command_id):
command_cfg = CONFIG["commands"].get(command_id)
if not command_cfg:
self.send_html(404, render_page("Unknown command", "<p>Unknown command.</p>"))
return
if not command_cfg.get("web_enabled", False):
self.send_html(403, render_page("Forbidden", "<p>Command is not enabled for web UI.</p>"))
return
try:
form = self.read_form_body()
csrf = form.get("csrf", "")
if not secrets.compare_digest(csrf, WEB_CSRF_TOKEN):
raise ValueError("invalid csrf token")
args = {}
for name, rule in command_cfg.get("args", {}).items():
if "web_default" in rule:
args[name] = rule["web_default"]
elif "default" in rule:
args[name] = rule["default"]
elif rule.get("required", False):
raise ValueError(f"missing web_default for required arg: {name}")
validated_args = validate_args(command_cfg, args)
command = build_command(command_cfg, validated_args)
result = run_command(command_id, command, command_cfg)
self.send_html(200, render_result(command_id, result))
except subprocess.TimeoutExpired as exc:
self.send_html(504, render_result(command_id, timeout_result(command_id, exc)))
except Exception as exc:
self.send_html(400, render_page("Error", f"<pre>{html.escape(str(exc))}</pre>"))
def list_commands():
return {
"ok": True,
"commands": [
{
"id": command_id,
"description": command.get("description", ""),
"args": list(command.get("args", {}).keys()),
"web_enabled": bool(command.get("web_enabled", False))
}
for command_id, command in CONFIG["commands"].items()
]
}
def validate_args(command_cfg, incoming_args):
arg_rules = command_cfg.get("args", {})
validated = {}
unknown = set(incoming_args.keys()) - set(arg_rules.keys())
if unknown:
raise ValueError(f"unknown args: {', '.join(sorted(unknown))}")
for name, rule in arg_rules.items():
value = incoming_args.get(name, rule.get("default"))
if value in [None, ""]:
if rule.get("required", False):
raise ValueError(f"missing arg: {name}")
value = ""
value = str(value)
pattern = rule.get("pattern", r"^[a-zA-Z0-9_.:/ -]{0,128}$")
if not re.fullmatch(pattern, value):
raise ValueError(f"invalid arg: {name}")
validated[name] = value
allowlist = command_cfg.get("allowlist")
if allowlist:
checked_values = [
validated.get(name)
for name in command_cfg.get("allowlist_args", [])
]
if checked_values not in allowlist:
raise ValueError(f"not allowed: {checked_values}")
return validated
def build_command(command_cfg, args):
command = []
for part in command_cfg["command"]:
value = str(part)
for key, arg_value in args.items():
value = value.replace("{" + key + "}", arg_value)
command.append(value)
return command
def run_command(command_id, command, command_cfg):
request_id = str(uuid.uuid4())
started = time.time()
timeout = int(command_cfg.get("timeout_seconds", 30))
env = {
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"REQUEST_ID": request_id
}
env.update({str(k): str(v) for k, v in command_cfg.get("environment", {}).items()})
print(f"request_id={request_id} command_id={command_id} command={command}")
completed = subprocess.run(
command,
shell=False,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env
)
duration_ms = int((time.time() - started) * 1000)
return {
"ok": completed.returncode == 0,
"request_id": request_id,
"command_id": command_id,
"command": command,
"exit_code": completed.returncode,
"timed_out": False,
"duration_ms": duration_ms,
"stdout": completed.stdout,
"stderr": completed.stderr
}
def timeout_result(command_id, exc):
return {
"ok": False,
"request_id": str(uuid.uuid4()),
"command_id": command_id,
"command": getattr(exc, "cmd", []),
"exit_code": None,
"timed_out": True,
"duration_ms": None,
"stdout": exc.stdout or "",
"stderr": exc.stderr or "command timed out"
}
def render_index():
cards = []
for command_id, command in CONFIG["commands"].items():
if not command.get("web_enabled", False):
continue
title = html.escape(command.get("web_title", command_id))
desc = html.escape(command.get("description", ""))
cards.append(f"""
<form method="post" action="/web/run/{html.escape(command_id)}" class="card">
<input type="hidden" name="csrf" value="{html.escape(WEB_CSRF_TOKEN)}">
<h2>{title}</h2>
<p>{desc}</p>
<button type="submit">Run</button>
</form>
""")
if not cards:
cards.append("<p>No web-enabled commands configured.</p>")
return render_page("Raspberry Pi Script API", "\n".join(cards))
def render_result(command_id, result):
body = f"""
<p><a href="/">Back</a></p>
<h2>Result: {html.escape(command_id)}</h2>
<table>
<tr><th>OK</th><td>{html.escape(str(result.get("ok")))}</td></tr>
<tr><th>Exit Code</th><td>{html.escape(str(result.get("exit_code")))}</td></tr>
<tr><th>Timed out</th><td>{html.escape(str(result.get("timed_out")))}</td></tr>
<tr><th>Duration</th><td>{html.escape(str(result.get("duration_ms")))} ms</td></tr>
<tr><th>Request ID</th><td>{html.escape(str(result.get("request_id")))}</td></tr>
</table>
<h3>stdout</h3>
<pre>{html.escape(str(result.get("stdout", "")))}</pre>
<h3>stderr</h3>
<pre>{html.escape(str(result.get("stderr", "")))}</pre>
"""
return render_page("Command Result", body)
def render_page(title, body):
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{html.escape(title)}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 2rem;
background: #f6f7f8;
color: #222;
}}
.card {{
background: white;
border: 1px solid #ddd;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
max-width: 520px;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}}
button {{
font-size: 1rem;
padding: .65rem 1rem;
border: 0;
border-radius: 8px;
cursor: pointer;
background: #222;
color: white;
}}
pre {{
background: #111;
color: #eee;
padding: 1rem;
border-radius: 8px;
overflow: auto;
}}
table {{
border-collapse: collapse;
background: white;
margin-bottom: 1rem;
}}
th, td {{
border: 1px solid #ddd;
padding: .5rem .8rem;
text-align: left;
}}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
{body}
</body>
</html>"""
def main():
server = ThreadingHTTPServer((CONFIG["host"], int(CONFIG["port"])), ApiHandler)
print(f"Listening on http://{CONFIG['host']}:{CONFIG['port']}")
print(f"Config: {CONFIG_PATH}")
server.serve_forever()
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
{
"carrier": 38000,
"repeat": 1,
"signal": [
{ "type": "mark", "duration": 4502 },
{ "type": "space", "duration": 4487 },
{ "type": "mark", "duration": 555 },
{ "type": "space", "duration": 599 },
{ "type": "mark", "duration": 556 },
{ "type": "space", "duration": 571 },
{ "type": "mark", "duration": 533 },
{ "type": "space", "duration": 1701 },
{ "type": "mark", "duration": 557 },
{ "type": "space", "duration": 571 },
{ "type": "mark", "duration": 532 },
{ "type": "space", "duration": 1708 },
{ "type": "mark", "duration": 552 },
{ "type": "space", "duration": 572 },
{ "type": "mark", "duration": 555 },
{ "type": "space", "duration": 1677 },
{ "type": "mark", "duration": 559 },
{ "type": "space", "duration": 543 },
{ "type": "mark", "duration": 586 },
{ "type": "space", "duration": 1676 },
{ "type": "mark", "duration": 534 },
{ "type": "space", "duration": 594 },
{ "type": "mark", "duration": 557 },
{ "type": "space", "duration": 542 },
{ "type": "mark", "duration": 587 },
{ "type": "space", "duration": 543 },
{ "type": "mark", "duration": 586 },
{ "type": "space", "duration": 571 },
{ "type": "mark", "duration": 532 },
{ "type": "space", "duration": 1704 },
{ "type": "mark", "duration": 556 },
{ "type": "space", "duration": 1679 },
{ "type": "mark", "duration": 528 },
{ "type": "space", "duration": 568 },
{ "type": "mark", "duration": 561 },
{ "type": "space", "duration": 1703 },
{ "type": "mark", "duration": 558 },
{ "type": "space", "duration": 1682 },
{ "type": "mark", "duration": 527 },
{ "type": "space", "duration": 595 },
{ "type": "mark", "duration": 557 },
{ "type": "space", "duration": 555 },
{ "type": "mark", "duration": 574 },
{ "type": "space", "duration": 573 },
{ "type": "mark", "duration": 531 },
{ "type": "space", "duration": 597 },
{ "type": "mark", "duration": 555 },
{ "type": "space", "duration": 546 },
{ "type": "mark", "duration": 561 },
{ "type": "space", "duration": 594 },
{ "type": "mark", "duration": 535 },
{ "type": "space", "duration": 594 },
{ "type": "mark", "duration": 535 },
{ "type": "space", "duration": 567 },
{ "type": "mark", "duration": 587 },
{ "type": "space", "duration": 574 },
{ "type": "mark", "duration": 554 },
{ "type": "space", "duration": 1679 },
{ "type": "mark", "duration": 531 },
{ "type": "space", "duration": 1700 },
{ "type": "mark", "duration": 536 },
{ "type": "space", "duration": 1699 },
{ "type": "mark", "duration": 559 },
{ "type": "space", "duration": 540 },
{ "type": "mark", "duration": 571 },
{ "type": "space", "duration": 1698 },
{ "type": "mark", "duration": 555 },
{ "type": "space", "duration": 21428 }
]
}