extends Control const WS_URL := "ws://localhost:8081/ws" const MENU_SCENE := "res://scenes/menu.tscn" const MAX_LOG_LINES := 200 const TAP_FLASH_SEC := 2.0 const PANEL_STYLE_NORMAL := Color(0.11, 0.12, 0.17, 1.0) 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 @onready var back_button: Button = $UiLayer/TopBar/BackButton @onready var interval_spin: SpinBox = $UiLayer/MainSplit/LeftPanel/Toolbar/IntervalSpin @onready var list_clients_btn: Button = $UiLayer/MainSplit/LeftPanel/Toolbar/ListClientsBtn @onready var clients_list: VBoxContainer = $UiLayer/MainSplit/LeftPanel/ClientsScroll/ClientsList @onready var empty_hint: Label = $UiLayer/MainSplit/LeftPanel/ClientsScroll/ClientsList/EmptyHint @onready var start_stream_btn: Button = $UiLayer/MainSplit/LeftPanel/StreamBar/StartStreamBtn @onready var stop_stream_btn: Button = $UiLayer/MainSplit/LeftPanel/StreamBar/StopStreamBtn var _socket := WebSocketPeer.new() var _connected := false var _streaming := false var _log_lines: PackedStringArray = [] var _client_ui: Dictionary = {} func _ready() -> void: back_button.pressed.connect(_return_to_menu) list_clients_btn.pressed.connect(func(): _send({"type": "list_clients"})) start_stream_btn.pressed.connect(_start_stream) stop_stream_btn.pressed.connect(_stop_stream) _connect_ws() func _return_to_menu() -> void: get_tree().change_scene_to_file(MENU_SCENE) func _connect_ws() -> void: _connected = false var err := _socket.connect_to_url(WS_URL) if err != OK: _set_status("Verbindung fehlgeschlagen: %s" % error_string(err)) else: _set_status("Verbinde mit %s…" % WS_URL) func _interval_ms() -> int: return int(interval_spin.value) func _send(payload: Dictionary) -> void: if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: _log("Nicht verbunden – Befehl verworfen: %s" % payload.get("type", "?")) return var text := JSON.stringify(payload) _socket.send_text(text) _log("→ %s" % text) func _start_stream() -> void: if _client_ui.is_empty(): _log("Keine Clients geladen") return var want_accel := false var want_tap := false for cid in _client_ui: var ui: Dictionary = _client_ui[cid] if not ui.get("available", false): continue var accel_on: bool = ui["accel_cb"].button_pressed _send({"type": "set_accel_stream", "client_id": cid, "enable": accel_on}) if accel_on: want_accel = true var tap_single: bool = ui["tap_single"].button_pressed var tap_double: bool = ui["tap_double"].button_pressed var tap_triple: bool = ui["tap_triple"].button_pressed var tap_on := tap_single or tap_double or tap_triple _send({ "type": "set_tap_notify", "client_id": cid, "single": tap_single, "double_tap": tap_double, "triple": tap_triple, }) if tap_on: want_tap = true _send({"type": "set_stream", "enable": want_accel, "interval_ms": _interval_ms()}) _send({"type": "set_tap_stream", "enable": want_tap, "interval_ms": _interval_ms()}) _streaming = true start_stream_btn.disabled = true stop_stream_btn.disabled = false _log("Stream gestartet (accel=%s, tap=%s)" % [want_accel, want_tap]) func _stop_stream() -> void: _send({"type": "set_stream", "enable": false, "interval_ms": _interval_ms()}) _send({"type": "set_tap_stream", "enable": false, "interval_ms": _interval_ms()}) for cid in _client_ui: var ui: Dictionary = _client_ui[cid] if not ui.get("available", false): continue _send({"type": "set_accel_stream", "client_id": cid, "enable": false}) _send({ "type": "set_tap_notify", "client_id": cid, "single": false, "double_tap": false, "triple": false, }) ui["values_label"].text = "—" _streaming = false start_stream_btn.disabled = _client_ui.is_empty() stop_stream_btn.disabled = true stream_output.text = "—" _log("Stream gestoppt") func _clear_client_panels() -> void: _client_ui.clear() for child in clients_list.get_children(): if child != empty_hint: child.queue_free() func _populate_clients(clients: Array) -> void: _clear_client_panels() var has_available := false for entry in clients: if typeof(entry) != TYPE_DICTIONARY: continue var cid := int(entry.get("id", 0)) if cid <= 0: continue var available: bool = entry.get("available", false) if available: has_available = true var panel := PanelContainer.new() panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL var normal_style := _make_panel_style(PANEL_STYLE_NORMAL, PANEL_BORDER_NORMAL, 1) var flash_style := _make_panel_style(PANEL_STYLE_FLASH, PANEL_BORDER_FLASH, 2) panel.add_theme_stylebox_override("panel", normal_style) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 10) margin.add_theme_constant_override("margin_right", 10) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_bottom", 8) panel.add_child(margin) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 6) margin.add_child(vbox) var mac: String = str(entry.get("mac", "?")) var status := "online" if available else "offline" var header := Label.new() header.text = "Client #%d · %s · %s" % [cid, mac, status] header.add_theme_font_size_override("font_size", 14) header.add_theme_color_override("font_color", Color(0.9, 0.92, 0.98) if available else Color(0.55, 0.58, 0.65)) vbox.add_child(header) var accel_cb := CheckBox.new() accel_cb.text = "Accel Stream" accel_cb.button_pressed = entry.get("accel_stream", false) accel_cb.disabled = not available vbox.add_child(accel_cb) var tap_label := Label.new() tap_label.text = "Tap Notify:" tap_label.add_theme_font_size_override("font_size", 12) tap_label.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85)) vbox.add_child(tap_label) var tap_row := HBoxContainer.new() tap_row.add_theme_constant_override("separation", 12) vbox.add_child(tap_row) var tap_single := CheckBox.new() tap_single.text = "Single" tap_single.button_pressed = entry.get("tap_notify_single", false) tap_single.disabled = not available var tap_double := CheckBox.new() tap_double.text = "Double" tap_double.button_pressed = entry.get("tap_notify_double", false) tap_double.disabled = not available var tap_triple := CheckBox.new() tap_triple.text = "Triple" tap_triple.button_pressed = entry.get("tap_notify_triple", false) tap_triple.disabled = not available tap_row.add_child(tap_single) 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) values_label.add_theme_color_override("font_color", Color(0.65, 0.85, 0.75)) values_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART vbox.add_child(values_label) clients_list.add_child(panel) _client_ui[cid] = { "available": available, "panel": panel, "normal_style": normal_style, "flash_style": flash_style, "flash_token": 0, "accel_cb": accel_cb, "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 _log("%d Client(s) geladen" % _client_ui.size()) func _physics_process(_delta: float) -> void: _socket.poll() var state := _socket.get_ready_state() if state == WebSocketPeer.STATE_OPEN: while _socket.get_available_packet_count() > 0: _handle_message(_socket.get_packet().get_string_from_utf8()) match state: WebSocketPeer.STATE_OPEN: if _connected: _set_status("Verbunden · %s" % WS_URL) WebSocketPeer.STATE_CONNECTING: _set_status("Verbinde…") WebSocketPeer.STATE_CLOSING, WebSocketPeer.STATE_CLOSED: _connected = false _streaming = false start_stream_btn.disabled = _client_ui.is_empty() stop_stream_btn.disabled = true _set_status("Getrennt – erneuter Verbindungsversuch…") if state == WebSocketPeer.STATE_CLOSED: _connect_ws() func _handle_message(text: String) -> void: var data = JSON.parse_string(text) if typeof(data) != TYPE_DICTIONARY: _log("← (ungültiges JSON) %s" % text.substr(0, mini(text.length(), 120))) return var msg_type: String = data.get("type", "") match msg_type: "hello": _on_hello(data) "client_list": _on_client_list(data) "accel": _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)) func _on_hello(data: Dictionary) -> void: _connected = true if data.has("interval_ms"): interval_spin.value = int(data["interval_ms"]) _log("← hello (Port: %s)" % data.get("serial_port", "?")) func _on_client_list(data: Dictionary) -> void: if not data.get("success", false): _log("← client_list FAILED: %s" % data.get("error", "?")) return _populate_clients(data.get("clients", [])) func _on_accel(data: Dictionary) -> void: if not data.get("success", false): stream_output.text = "accel FEHLER: %s" % data.get("error", "?") return var summary: PackedStringArray = [] for client in data.get("clients", []): if typeof(client) != TYPE_DICTIONARY: continue var cid := int(client.get("client_id", 0)) if not _client_ui.has(cid): continue var ui: Dictionary = _client_ui[cid] var tap_part: String = ui.get("last_tap", "") if client.get("valid", false): var accel_text := "x=%d y=%d z=%d (age %sms)" % [ int(client.get("x", 0)), int(client.get("y", 0)), int(client.get("z", 0)), str(client.get("age_ms", "?")), ] ui["values_label"].text = accel_text + ("\n" + tap_part if tap_part else "") summary.append("#%d: %s" % [cid, accel_text]) else: ui["values_label"].text = "accel: —" + ("\n" + tap_part if tap_part else "") if summary.is_empty(): stream_output.text = "accel (keine gültigen Samples)" else: stream_output.text = "accel\n" + "\n".join(summary) func _on_tap(data: Dictionary) -> void: if not data.get("success", false): return var summary: PackedStringArray = [] for event in data.get("events", []): if typeof(event) != TYPE_DICTIONARY: continue var cid := int(event.get("client_id", 0)) if not _client_ui.has(cid): continue var tap_text := "tap: %s (age %sms)" % [str(event.get("kind", "?")), str(event.get("age_ms", "?"))] var ui: Dictionary = _client_ui[cid] ui["last_tap"] = tap_text var existing: String = ui["values_label"].text if existing == "—" or existing.begins_with("accel: —"): ui["values_label"].text = tap_text elif "\n" in existing: ui["values_label"].text = existing.split("\n", false, 1)[0] + "\n" + tap_text else: ui["values_label"].text = existing + "\n" + tap_text summary.append("#%d: %s" % [cid, tap_text]) _flash_client_panel(cid) if not summary.is_empty(): 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 style.border_color = border style.set_border_width_all(border_width) style.set_corner_radius_all(6) style.set_content_margin_all(0) return style func _flash_client_panel(cid: int) -> void: if not _client_ui.has(cid): return var ui: Dictionary = _client_ui[cid] var panel: PanelContainer = ui["panel"] panel.add_theme_stylebox_override("panel", ui["flash_style"]) ui["flash_token"] = int(ui.get("flash_token", 0)) + 1 var token: int = ui["flash_token"] get_tree().create_timer(TAP_FLASH_SEC).timeout.connect( func() -> void: if not _client_ui.has(cid): return var current: Dictionary = _client_ui[cid] if int(current.get("flash_token", 0)) != token: return current["panel"].add_theme_stylebox_override("panel", current["normal_style"]), CONNECT_ONE_SHOT ) func _format_response(data: Dictionary) -> String: var msg_type: String = data.get("type", "?") if not data.get("success", true) and data.has("error"): return "%s FAILED: %s" % [msg_type, data["error"]] return JSON.stringify(data) func _set_status(text: String) -> void: status_label.text = text func _log(text: String) -> void: var line := "[%s] %s" % [_time_str(), text] _log_lines.append(line) while _log_lines.size() > MAX_LOG_LINES: _log_lines.remove_at(0) log_output.text = "\n".join(_log_lines) log_output.set_caret_line(log_output.get_line_count() - 1) func _time_str() -> String: var t := Time.get_time_dict_from_system() return "%02d:%02d:%02d" % [t.hour, t.minute, t.second]