From 4f3435ba37c166c102be0be47e5e8db2355210a4 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 29 May 2026 21:55:18 +0200 Subject: [PATCH] Add per-client LED controls aligned with the REST API schema. Each client panel gets mode-specific LED inputs (color, intensity, progress, digit, blink) and the Makefile now copies API_REST.md alongside the WebSocket docs. Co-authored-by: Cursor --- API_REST.md | 284 ++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 3 +- scripts/demo.gd | 239 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 API_REST.md diff --git a/API_REST.md b/API_REST.md new file mode 100644 index 0000000..9628146 --- /dev/null +++ b/API_REST.md @@ -0,0 +1,284 @@ +# REST API + +`go run . -port /dev/ttyUSB0 serve` starts two HTTP servers on the same UART link: + +| Base URL | Flag | Used by | +|----------|------|---------| +| `http://localhost:8080` | `-addr` (default `:8080`) | Web dashboard + automation on the UI routes | +| `http://localhost:8081` | `-api-addr` (default `:8081`, `""` disables) | External programs; subset of routes + service info | + +WebSocket streaming (accel/tap push): [`API_WEBSOCKET.md`](API_WEBSOCKET.md). + +All JSON responses use `Content-Type: application/json`. On UART errors many routes return **503** with `"error"` in the body. + +--- + +## External API (`:8081`) + +### Service info + +```http +GET / +GET /api/v1/ +``` + +```json +{ + "name": "powerpod-external-api", + "version": "1", + "serial_port": "/dev/ttyUSB0", + "websocket": "/ws", + "default_interval_ms": 16, + "min_interval_ms": 1, + "max_interval_ms": 10000, + "tap_display_min_ms": 2000, + "description": "..." +} +``` + +### Battery + +```http +GET /api/battery?all_clients=true +GET /api/battery?client_id=16 +POST /api/battery +Content-Type: application/json +``` + +POST body: + +```json +{"all_clients": true} +{"client_id": 0} +{"client_id": 16} +``` + +Response: + +```json +{ + "success": true, + "samples": [ + { + "client_id": 16, + "lipo1": {"valid": true, "voltage_mv": 3850, "percent": 71}, + "lipo2": {"valid": false}, + "age_ms": 1200 + } + ] +} +``` + +Slaves push battery to the master every **30 s**; these routes read the master cache. + +WebSocket equivalent: `get_battery` on `ws://localhost:8081/ws` (reply type `battery_status`). + +### LED ring + +```http +POST /api/led-ring +Content-Type: application/json +``` + +Body: + +```json +{"mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":128} +{"mode":"digit","client_id":0,"digit":3,"r":0,"g":255,"b":0} +{"mode":"find-me","all_clients":true,"slaves_only":true} +``` + +| `mode` | Notes | +|--------|--------| +| `clear` | Turn off | +| `color` | Full ring RGB + `intensity` | +| `progress` | `progress` 0–100 | +| `digit` | `digit` 0–10 | +| `blink` | `blink_ms`, `blink_count` | +| `find-me` | Locate pod | + +Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast. + +Response: `success`, `slaves_updated`, optional `error`. + +WebSocket: `set_led_ring` with the same fields plus `"type":"set_led_ring"` → `led_ring_status`. + +--- + +## Dashboard API (`:8080`) + +Used by the web UI; safe for scripts that drive the same features. + +### Live stream (host `CACHE_STATUS` poll ~16 ms) + +```http +GET /api/live-stream +PUT /api/live-stream +Content-Type: application/json +{"enable": true} +``` + +```json +{"enabled": true, "success": true} +``` + +Enables fast UART polling for dashboard accel/tap display. Per-slave accel still requires accel-stream (below). + +### Accel stream (firmware ESP-NOW, per slave) + +```http +GET /api/clients/16/accel-stream +PUT /api/clients/16/accel-stream +Content-Type: application/json +{"enable": true} +``` + +```json +{"enabled": true, "client_id": 16, "success": true} +``` + +All slaves: + +```http +POST /api/accel-stream +Content-Type: application/json +{"write": true, "enable": true, "all_clients": true} +``` + +Polling on the host runs only while at least one slave has streaming enabled (here or via external WebSocket / dashboard). + +### Tap notify (firmware; does not start host tap polling) + +```http +GET /api/clients/16/tap-notify +PUT /api/clients/16/tap-notify +Content-Type: application/json +{"single": true, "double_tap": false, "triple": false} +``` + +```json +{ + "client_id": 16, + "success": true, + "slaves_updated": 1, + "single": true, + "double_tap": false, + "triple": false +} +``` + +All slaves: + +```http +POST /api/tap-notify +Content-Type: application/json +{"single": true, "double_tap": false, "triple": false, "all_clients": true} +``` + +Host tap display / external `set_tap_stream` is separate. + +### Tap snapshot (one-shot, via `CACHE_STATUS`) + +```http +GET /api/tap-snapshot?client_id=16 +``` + +Reads the combined cache (`CACHE_STATUS`); optional `client_id` filters pending tap events. Pending taps are consumed on read. + +```json +{ + "events": [ + {"client_id": 16, "kind": "single", "age_ms": 4} + ] +} +``` + +### Deadzone + +```http +GET /api/deadzone?client_id=0 +POST /api/deadzone +Content-Type: application/json +{"write": true, "deadzone": 128, "client_id": 0} +``` + +With `all_clients` + `slaves_only`: push to ESP-NOW slaves only (master BMA456 unchanged). + +```json +{"deadzone": 128, "client_id": 0, "success": true, "slaves_updated": 2} +``` + +### Unicast test + +```http +POST /api/unicast-test +Content-Type: application/json +{"client_id": 16, "seq": 42} +``` + +### Find me + +```http +POST /api/find-me +Content-Type: application/json +{"client_id": 16} +``` + +`client_id` `0` = master LED ring. + +### Restart + +```http +POST /api/restart +Content-Type: application/json +{"client_id": 16} +``` + +### OTA (master UART upload) + +```http +POST /api/ota +Content-Type: multipart/form-data +``` + +Form field **`firmware`**: binary image, max **2 MiB**. + +```json +{"success": true, "bytes_written": 123456, "target_slot": 1} +``` + +Firmware distributes to slaves over ESP-NOW after `OTA_END`. Progress also appears on dashboard WebSocket as `ota_progress` messages. + +CLI equivalent: `go run . -port /dev/ttyUSB0 ota build/powerpod.bin` + +### LED ring and battery + +Same as external API: + +- `POST /api/led-ring` +- `GET` / `POST` `/api/battery` + +--- + +## Dashboard vs external + +| Feature | Dashboard `:8080` | External `:8081` | +|---------|-------------------|------------------| +| Client list | Via dashboard WebSocket state / CLI `clients` | WebSocket `list_clients` | +| Accel/tap **push stream** | WebSocket state when live-stream on | WebSocket `set_stream` / `set_tap_stream` | +| Accel stream enable | REST `PUT .../accel-stream` | WebSocket `set_accel_stream` | +| Tap notify | REST `PUT .../tap-notify` | WebSocket `set_tap_notify` | +| LED / battery | REST | REST + WebSocket on `:8081` | + +--- + +## UI mapping + +| UI action | REST / CLI | +|-----------|------------| +| Nur Master deadzone | `POST /api/deadzone` `client_id: 0` or CLI `deadzone -set -client 0` | +| Einzelner Slave | `client_id: ` | +| Alle Slaves deadzone | `all_clients` + `slaves_only` on POST | +| Unicast test | `POST /api/unicast-test` | +| Tap notify S/D/T | `PUT /api/clients/{id}/tap-notify` | +| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API | diff --git a/Makefile b/Makefile index 933dc9e..63d3b75 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,3 @@ get_api_desc: - cp ../dev/esp/powerpod/goTool/docs/API_WEBSOCKET.md ./API_WEBSOCKET.md \ No newline at end of file + cp ../dev/esp/powerpod/goTool/docs/API_WEBSOCKET.md ./API_WEBSOCKET.md + cp ../dev/esp/powerpod/goTool/docs/API_REST.md ./API_REST.md \ No newline at end of file diff --git a/scripts/demo.gd b/scripts/demo.gd index 71ed575..56b8b3b 100644 --- a/scripts/demo.gd +++ b/scripts/demo.gd @@ -10,6 +10,9 @@ const PANEL_BORDER_NORMAL := Color(0.22, 0.25, 0.32, 1.0) const PANEL_STYLE_FLASH := Color(0.32, 0.28, 0.06, 1.0) const PANEL_BORDER_FLASH := Color(1.0, 0.82, 0.15, 1.0) +const LED_MODES: PackedStringArray = ["color", "clear", "progress", "digit", "blink", "find-me"] +const LED_MODES_COLOR_INTENSITY: PackedStringArray = ["color", "progress", "digit", "blink"] + @onready var status_label: Label = $UiLayer/TopBar/StatusLabel @onready var log_output: TextEdit = $UiLayer/MainSplit/LogPanel/LogOutput @onready var stream_output: Label = $UiLayer/MainSplit/LogPanel/StreamOutput @@ -210,6 +213,122 @@ func _populate_clients(clients: Array) -> void: tap_row.add_child(tap_double) tap_row.add_child(tap_triple) + var led_label := Label.new() + led_label.text = "LED Ring:" + led_label.add_theme_font_size_override("font_size", 12) + led_label.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85)) + vbox.add_child(led_label) + + var led_row := HBoxContainer.new() + led_row.add_theme_constant_override("separation", 8) + vbox.add_child(led_row) + + var led_mode := OptionButton.new() + for mode in LED_MODES: + led_mode.add_item(mode) + led_mode.disabled = not available + led_row.add_child(led_mode) + + var led_apply := Button.new() + led_apply.text = "Anwenden" + led_apply.disabled = not available + led_apply.pressed.connect(_apply_led_for_client.bind(cid)) + led_row.add_child(led_apply) + + var led_params := VBoxContainer.new() + led_params.add_theme_constant_override("separation", 4) + vbox.add_child(led_params) + + var led_color_row := HBoxContainer.new() + led_color_row.add_theme_constant_override("separation", 8) + led_params.add_child(led_color_row) + var led_color_lbl := Label.new() + led_color_lbl.text = "Farbe:" + led_color_row.add_child(led_color_lbl) + var led_color := ColorPickerButton.new() + led_color.color = Color(1.0, 0.0, 0.0) + led_color.custom_minimum_size = Vector2(36, 28) + led_color.disabled = not available + led_color_row.add_child(led_color) + + var led_intensity_row := HBoxContainer.new() + led_intensity_row.add_theme_constant_override("separation", 8) + led_params.add_child(led_intensity_row) + var led_intensity_lbl := Label.new() + led_intensity_lbl.text = "Intensität:" + led_intensity_row.add_child(led_intensity_lbl) + var led_intensity := SpinBox.new() + led_intensity.min_value = 0 + led_intensity.max_value = 255 + led_intensity.value = 128 + led_intensity.custom_minimum_size = Vector2(80, 0) + _set_spinbox_enabled(led_intensity, available) + led_intensity_row.add_child(led_intensity) + + var led_progress_row := HBoxContainer.new() + led_progress_row.add_theme_constant_override("separation", 8) + led_params.add_child(led_progress_row) + var led_progress_lbl := Label.new() + led_progress_lbl.text = "Fortschritt %:" + led_progress_row.add_child(led_progress_lbl) + var led_progress := SpinBox.new() + led_progress.min_value = 0 + led_progress.max_value = 100 + led_progress.value = 50 + led_progress.custom_minimum_size = Vector2(80, 0) + _set_spinbox_enabled(led_progress, available) + led_progress_row.add_child(led_progress) + + var led_digit_row := HBoxContainer.new() + led_digit_row.add_theme_constant_override("separation", 8) + led_params.add_child(led_digit_row) + var led_digit_lbl := Label.new() + led_digit_lbl.text = "Ziffer:" + led_digit_row.add_child(led_digit_lbl) + var led_digit := SpinBox.new() + led_digit.min_value = 0 + led_digit.max_value = 10 + led_digit.value = 3 + led_digit.custom_minimum_size = Vector2(80, 0) + _set_spinbox_enabled(led_digit, available) + led_digit_row.add_child(led_digit) + + var led_blink_row := HBoxContainer.new() + led_blink_row.add_theme_constant_override("separation", 8) + led_params.add_child(led_blink_row) + var led_blink_ms_lbl := Label.new() + led_blink_ms_lbl.text = "Blink ms:" + led_blink_row.add_child(led_blink_ms_lbl) + var led_blink_ms := SpinBox.new() + led_blink_ms.min_value = 1 + led_blink_ms.max_value = 10000 + led_blink_ms.value = 500 + led_blink_ms.custom_minimum_size = Vector2(80, 0) + _set_spinbox_enabled(led_blink_ms, available) + led_blink_row.add_child(led_blink_ms) + var led_blink_count_lbl := Label.new() + led_blink_count_lbl.text = "Anzahl:" + led_blink_row.add_child(led_blink_count_lbl) + var led_blink_count := SpinBox.new() + led_blink_count.min_value = 1 + led_blink_count.max_value = 100 + led_blink_count.value = 3 + led_blink_count.custom_minimum_size = Vector2(60, 0) + _set_spinbox_enabled(led_blink_count, available) + led_blink_row.add_child(led_blink_count) + + led_mode.item_selected.connect(func(_idx: int) -> void: _update_led_fields(cid)) + + var led_presets := HBoxContainer.new() + led_presets.add_theme_constant_override("separation", 6) + vbox.add_child(led_presets) + + _add_led_preset_btn(led_presets, "Aus", cid, available, func() -> void: _led_apply_preset(cid, "clear")) + _add_led_preset_btn(led_presets, "Rot", cid, available, func() -> void: _led_apply_preset(cid, "color", Color(1, 0.2, 0.2), 128)) + _add_led_preset_btn(led_presets, "Grün", cid, available, func() -> void: _led_apply_preset(cid, "color", Color(0.2, 1, 0.3), 128)) + _add_led_preset_btn(led_presets, "Blau", cid, available, func() -> void: _led_apply_preset(cid, "color", Color(0.2, 0.4, 1), 128)) + _add_led_preset_btn(led_presets, "Find", cid, available, func() -> void: _led_apply_preset(cid, "find-me")) + var values_label := Label.new() values_label.text = "—" values_label.add_theme_font_size_override("font_size", 12) @@ -229,9 +348,22 @@ func _populate_clients(clients: Array) -> void: "tap_single": tap_single, "tap_double": tap_double, "tap_triple": tap_triple, + "led_color": led_color, + "led_mode": led_mode, + "led_intensity": led_intensity, + "led_progress": led_progress, + "led_digit": led_digit, + "led_blink_ms": led_blink_ms, + "led_blink_count": led_blink_count, + "led_color_row": led_color_row, + "led_intensity_row": led_intensity_row, + "led_progress_row": led_progress_row, + "led_digit_row": led_digit_row, + "led_blink_row": led_blink_row, "values_label": values_label, "last_tap": "", } + _update_led_fields(cid) empty_hint.visible = _client_ui.is_empty() start_stream_btn.disabled = _client_ui.is_empty() or not has_available or _streaming @@ -278,6 +410,8 @@ func _handle_message(text: String) -> void: _on_accel(data) "tap": _on_tap(data) + "led_ring_status": + _log("← LED #%s: %s" % [data.get("client_id", "?"), "OK" if data.get("success", false) else data.get("error", "?")]) _: if not _streaming or not msg_type.ends_with("_status"): _log("← %s" % _format_response(data)) @@ -361,6 +495,111 @@ func _on_tap(data: Dictionary) -> void: stream_output.text = "tap\n" + "\n".join(summary) +func _set_spinbox_enabled(spin: SpinBox, enabled: bool) -> void: + spin.editable = enabled + spin.get_line_edit().editable = enabled + + +func _add_led_preset_btn( + row: HBoxContainer, + text: String, + cid: int, + available: bool, + on_pressed: Callable +) -> void: + var btn := Button.new() + btn.text = text + btn.disabled = not available + btn.pressed.connect(on_pressed) + row.add_child(btn) + + +func _update_led_fields(cid: int) -> void: + if not _client_ui.has(cid): + return + var ui: Dictionary = _client_ui[cid] + var mode: String = ui["led_mode"].get_item_text(ui["led_mode"].selected) + + ui["led_color_row"].visible = mode in LED_MODES_COLOR_INTENSITY + ui["led_intensity_row"].visible = mode in LED_MODES_COLOR_INTENSITY + ui["led_progress_row"].visible = mode == "progress" + ui["led_digit_row"].visible = mode == "digit" + ui["led_blink_row"].visible = mode == "blink" + + +func _select_led_mode(ui: Dictionary, mode: String) -> void: + for i in ui["led_mode"].item_count: + if ui["led_mode"].get_item_text(i) == mode: + ui["led_mode"].select(i) + return + + +func _led_apply_preset( + cid: int, + mode: String, + color: Color = Color.WHITE, + intensity: int = 128, + param: int = 50 +) -> void: + if not _client_ui.has(cid): + return + var ui: Dictionary = _client_ui[cid] + _select_led_mode(ui, mode) + if mode in LED_MODES_COLOR_INTENSITY: + ui["led_color"].color = color + ui["led_intensity"].value = intensity + if mode == "progress": + ui["led_progress"].value = param + if mode == "digit": + ui["led_digit"].value = param + if mode == "blink": + ui["led_blink_ms"].value = param + ui["led_blink_count"].value = 3 + _update_led_fields(cid) + _apply_led_for_client(cid) + + +func _apply_led_for_client(cid: int) -> void: + if not _client_ui.has(cid): + return + _send(_build_led_payload(cid)) + + +func _build_led_payload(cid: int) -> Dictionary: + var ui: Dictionary = _client_ui[cid] + var mode: String = ui["led_mode"].get_item_text(ui["led_mode"].selected) + var payload := {"type": "set_led_ring", "client_id": cid, "mode": mode} + + match mode: + "clear", "find-me": + pass + "color": + _payload_add_color_intensity(payload, ui) + "progress": + payload["progress"] = int(ui["led_progress"].value) + _payload_add_color_intensity(payload, ui) + "digit": + payload["digit"] = int(ui["led_digit"].value) + _payload_add_color_intensity(payload, ui) + "blink": + payload["blink_ms"] = int(ui["led_blink_ms"].value) + payload["blink_count"] = int(ui["led_blink_count"].value) + _payload_add_color_intensity(payload, ui) + + return payload + + +func _payload_add_color_intensity(payload: Dictionary, ui: Dictionary) -> void: + _payload_add_rgb(payload, ui["led_color"].color) + payload["intensity"] = int(ui["led_intensity"].value) + + +func _payload_add_rgb(payload: Dictionary, color: Color) -> void: + payload["r"] = int(color.r * 255) + payload["g"] = int(color.g * 255) + payload["b"] = int(color.b * 255) + + func _make_panel_style(bg: Color, border: Color, border_width: int) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = bg