commit aa73a4e255c130f7e36ff79256e8c52db5404ec5 Author: Till-Immer Date: Tue Jun 23 15:30:55 2026 +0200 setup diff --git a/README.md b/README.md new file mode 100644 index 0000000..2db7f4d --- /dev/null +++ b/README.md @@ -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://:8080/health +``` + +Antwort: + +```json +{ + "ok": true +} +``` + +--- + +# Weboberfläche + +```text +http://: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://: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://: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://: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. diff --git a/RadioOn.json b/RadioOn.json new file mode 100644 index 0000000..791b518 --- /dev/null +++ b/RadioOn.json @@ -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 } + ] +} diff --git a/apiout.log b/apiout.log new file mode 100644 index 0000000..a39055c --- /dev/null +++ b/apiout.log @@ -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 diff --git a/commands.json b/commands.json new file mode 100644 index 0000000..0b82aa3 --- /dev/null +++ b/commands.json @@ -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": {} + } + } +} + diff --git a/hektagonOn.json b/hektagonOn.json new file mode 100644 index 0000000..b75c9bf --- /dev/null +++ b/hektagonOn.json @@ -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 } + ] +} diff --git a/ir_send.py b/ir_send.py new file mode 100644 index 0000000..9a41772 --- /dev/null +++ b/ir_send.py @@ -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()) diff --git a/listOfCommands.txt b/listOfCommands.txt new file mode 100644 index 0000000..09383c6 --- /dev/null +++ b/listOfCommands.txt @@ -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 diff --git a/script-api.py b/script-api.py new file mode 100755 index 0000000..02d9237 --- /dev/null +++ b/script-api.py @@ -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", "

Unknown command.

")) + return + + if not command_cfg.get("web_enabled", False): + self.send_html(403, render_page("Forbidden", "

Command is not enabled for web UI.

")) + 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"
{html.escape(str(exc))}
")) + + +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""" +
+ +

{title}

+

{desc}

+ +
+ """) + + if not cards: + cards.append("

No web-enabled commands configured.

") + + return render_page("Raspberry Pi Script API", "\n".join(cards)) + + +def render_result(command_id, result): + body = f""" +

Back

+

Result: {html.escape(command_id)}

+ + + + + + + +
OK{html.escape(str(result.get("ok")))}
Exit Code{html.escape(str(result.get("exit_code")))}
Timed out{html.escape(str(result.get("timed_out")))}
Duration{html.escape(str(result.get("duration_ms")))} ms
Request ID{html.escape(str(result.get("request_id")))}
+ +

stdout

+
{html.escape(str(result.get("stdout", "")))}
+ +

stderr

+
{html.escape(str(result.get("stderr", "")))}
+ """ + + return render_page("Command Result", body) + + +def render_page(title, body): + return f""" + + + + {html.escape(title)} + + + + +

{html.escape(title)}

+ {body} + +""" + + +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() + + diff --git a/signal.json b/signal.json new file mode 100644 index 0000000..c228b3c --- /dev/null +++ b/signal.json @@ -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 } + ] +}