From 07f6c6c8e4b865d640e1472a81b96cbb2722ecaa Mon Sep 17 00:00:00 2001 From: mringsby Date: Fri, 5 Jun 2026 15:06:36 +0200 Subject: [PATCH 1/2] Add web light brightness control (home + debug) sharing the controller light The controller already owns the light level and resends it at 60 Hz, so the web must drive the same value rather than fight it. Add Controller.set_light/get_light (also pushing straight to the bitmask so it works when no joystick is connected), expose GET/POST /api/lights, and add a brightness slider on both the home dashboard and the debug page. GET /api/lights now returns the real level (percent), which also feeds the pilot HUD readout. Pairs with K2-Zephyr PR feature/light-pwm (firmware PWM on D6/PA8). Co-Authored-By: Claude Opus 4.8 --- lib/controller.py | 18 ++++++++++++ routes.py | 25 ++++++++++++++-- static/js/debug.js | 43 +++++++++++++++++++++++++++ static/js/lights.js | 56 ++++++++++++++++++++++++++++++++---- static/templates/debug.html | 18 ++++++++++++ static/templates/layout.html | 7 ++++- tests/test_controller.py | 20 +++++++++++++ 7 files changed, 179 insertions(+), 8 deletions(-) diff --git a/lib/controller.py b/lib/controller.py index caf46b2..082f503 100644 --- a/lib/controller.py +++ b/lib/controller.py @@ -283,6 +283,24 @@ def _apply_gain(self, axis_name, value): with self._gain_lock: return value * self._axis_gains.get(axis_name, 1.0) * self._master_gain + # --- Light API --- + def set_light(self, level): + """Set light brightness from a normalized 0.0-1.0 level. + + The controller loop owns the light value and resends it every cycle, so + the web UI drives this same value rather than fighting it. Also pushes + straight to the bitmask so the change applies even when no joystick is + connected (and the loop is not calling set_from_axes). + """ + level = max(0.0, min(1.0, float(level))) + self.light = level + if self.bm: + self.bm.set_command(light=int(round(level * 255))) + + def get_light(self): + """Return current light brightness as a normalized 0.0-1.0 level.""" + return self.light + def _reset_command(self): """Reset all axes to neutral/zero.""" if self.bm: diff --git a/routes.py b/routes.py index 2937a58..5e33c19 100644 --- a/routes.py +++ b/routes.py @@ -283,8 +283,29 @@ def get_sensors(): @app.route("/api/lights", methods=["GET"]) def get_lights(): - """API route for lights data.""" - return jsonify(data_handler.get_section("lights")) + """Return the current light brightness (percent) the controller is sending.""" + ctrl = current_app.config.get("CONTROLLER") + level = ctrl.get_light() if ctrl else 0.0 + pct = round(level * 100) + return jsonify({"level": pct, "light": pct}) + + @app.route("/api/lights", methods=["POST"]) + def set_lights(): + """Set light brightness. JSON body: {"level": 0..100} (percent).""" + data = request.get_json(force=True, silent=True) or {} + ctrl = current_app.config.get("CONTROLLER") + if not ctrl: + return jsonify({"ok": False, "error": "Controller not available"}), 503 + if "level" not in data: + return jsonify({"ok": False, "error": "Missing 'level'"}), 400 + try: + pct = float(data["level"]) + except (TypeError, ValueError): + return jsonify({"ok": False, "error": "Invalid 'level'"}), 400 + pct = max(0.0, min(100.0, pct)) + ctrl.set_light(pct / 100.0) + pct = round(pct) + return jsonify({"ok": True, "level": pct, "light": pct}) @app.route("/api/battery", methods=["GET"]) def get_battery(): diff --git a/static/js/debug.js b/static/js/debug.js index 2cf556e..a228a38 100644 --- a/static/js/debug.js +++ b/static/js/debug.js @@ -971,4 +971,47 @@ } btnAttitudeClear.disabled = false; }); + + // --- Lights --- + const lightSlider = document.getElementById("debug-light-slider"); + const lightValue = document.getElementById("debug-light-value"); + let lightPostTimer = null; + + async function postLight(level) { + try { + await fetch("/api/lights", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level: level }), + }); + } catch (e) { + /* ignore transient send errors */ + } + } + + if (lightSlider) { + lightSlider.addEventListener("input", function () { + var level = parseInt(lightSlider.value, 10); + if (lightValue) lightValue.textContent = level; + if (!lightPostTimer) { + lightPostTimer = setTimeout(function () { + lightPostTimer = null; + }, 100); + postLight(level); + } + }); + lightSlider.addEventListener("change", function () { + postLight(parseInt(lightSlider.value, 10)); + }); + + // Initialize from current server value. + fetch("/api/lights") + .then(function (r) { return r.json(); }) + .then(function (d) { + var pct = d.level != null ? d.level : (d.light != null ? d.light : 0); + lightSlider.value = pct; + if (lightValue) lightValue.textContent = pct; + }) + .catch(function () {}); + } })(); diff --git a/static/js/lights.js b/static/js/lights.js index e82fb65..4e3f4ea 100644 --- a/static/js/lights.js +++ b/static/js/lights.js @@ -1,14 +1,60 @@ +// lights.js – home dashboard light brightness slider. +// updateLights() is also driven on an interval by configuration.js. + +let _lightPostTimer = null; + +async function postLight(level) { + try { + await fetch("/api/lights", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level }), + }); + } catch (error) { + console.error("Error setting lights:", error); + } +} + +// Throttle POSTs while the slider is being dragged. +function queueLightPost(level) { + if (_lightPostTimer) return; + _lightPostTimer = setTimeout(() => { + _lightPostTimer = null; + }, 100); + postLight(level); +} + async function updateLights() { try { const response = await fetch("/api/lights"); const data = await response.json(); - const lightData = document.getElementById("light-data"); - lightData.innerHTML = Object.entries(data) - .map(([name, brightness]) => `

${name}: ${brightness}%

`) - .join(""); + const pct = data.level ?? data.light ?? 0; + const value = document.getElementById("light-value"); + const slider = document.getElementById("light-slider"); + if (value) value.textContent = pct; + // Don't yank the slider out from under the user while they drag it. + if (slider && document.activeElement !== slider) { + slider.value = pct; + } } catch (error) { console.error("Error fetching lights:", error); } } -// setInterval(updateLights, 500); +document.addEventListener("DOMContentLoaded", () => { + const slider = document.getElementById("light-slider"); + const value = document.getElementById("light-value"); + if (!slider) return; + + slider.addEventListener("input", () => { + const level = parseInt(slider.value, 10); + if (value) value.textContent = level; + queueLightPost(level); + }); + // Guarantee the final position is sent even if the last input was throttled. + slider.addEventListener("change", () => { + postLight(parseInt(slider.value, 10)); + }); + + updateLights(); +}); diff --git a/static/templates/debug.html b/static/templates/debug.html index 54a955c..2435999 100644 --- a/static/templates/debug.html +++ b/static/templates/debug.html @@ -116,6 +116,24 @@
System Controls
+ +
+
+
+
+
Lights
+

+ Dimmable light on D6/PA8. Drives the same value the controller D-pad sets. +

+ + +
+
+
+
+
diff --git a/static/templates/layout.html b/static/templates/layout.html index 8513820..145a2d1 100644 --- a/static/templates/layout.html +++ b/static/templates/layout.html @@ -31,7 +31,12 @@
Depth
Lights
-
Loading...
+
+ + +
diff --git a/tests/test_controller.py b/tests/test_controller.py index 4f7e142..1d8ea19 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -13,10 +13,14 @@ class FakeBitmask: def __init__(self): self.calls = [] + self.commands = [] def set_from_axes(self, **kwargs): self.calls.append(kwargs) + def set_command(self, **kwargs): + self.commands.append(kwargs) + class FakeSdlController: def __init__(self, axes=None, buttons=None, attached=True): @@ -178,6 +182,22 @@ def test_raw_joystick_mapping_remains_available_for_unsupported_devices(monkeypa assert status["buttons"][7] == 1.0 +def test_set_light_clamps_and_pushes_to_bitmask(): + ctrl = build_controller() + + ctrl.set_light(0.5) + assert ctrl.get_light() == pytest.approx(0.5) + assert ctrl.bm.commands[-1]["light"] == 128 + + ctrl.set_light(2.0) # clamps to 1.0 -> 255 + assert ctrl.get_light() == 1.0 + assert ctrl.bm.commands[-1]["light"] == 255 + + ctrl.set_light(-1.0) # clamps to 0.0 -> 0 + assert ctrl.get_light() == 0.0 + assert ctrl.bm.commands[-1]["light"] == 0 + + def test_non_linux_connection_uses_raw_joystick_without_sdl_probe(monkeypatch): class FailingSdlController: @staticmethod From c05ad8cd1d0c9988cef8d28445965f83460e3783 Mon Sep 17 00:00:00 2001 From: mringsby Date: Sat, 6 Jun 2026 17:51:39 +0200 Subject: [PATCH 2/2] fixed controller light control --- lib/controller.py | 25 +++++++++++++++++++++---- static/templates/debug.html | 2 +- static/templates/layout.html | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/controller.py b/lib/controller.py index 082f503..ca2a717 100644 --- a/lib/controller.py +++ b/lib/controller.py @@ -45,6 +45,16 @@ class Controller: CONTROLLER_AXIS_MIN = 32768.0 VISUALIZER_BUTTON_COUNT = 16 + # Hard cap on light brightness (normalized 0.0-1.0). Enforced for every + # input path (D-pad, web slider, debug slider) so brightness can never + # exceed this regardless of where the request comes from. + MAX_LIGHT = 0.8 + + # Raw-joystick D-pad fallback: some controllers (e.g. DualShock 4 on + # Windows) expose the D-pad as buttons instead of a hat. Verified mapping. + DPAD_UP_BUTTON = 11 + DPAD_DOWN_BUTTON = 12 + def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): self.bm = bitmask_client # Use injected bitmask client from app.py self.delay_ms = int(1000 / rate_hz) if rate_hz > 0 else 16 # ~60 Hz default @@ -211,8 +221,15 @@ def _read_dpad_up_down(self): bool(self.controller.get_button(pygame.CONTROLLER_BUTTON_DPAD_DOWN)), ) - hat = self.joystick.get_hat(0) if self.joystick.get_numhats() > 0 else (0, 0) - return hat[1] > 0, hat[1] < 0 + if self.joystick.get_numhats() > 0: + hat = self.joystick.get_hat(0) + return hat[1] > 0, hat[1] < 0 + + # No hat (e.g. DualShock 4 on Windows): D-pad is exposed as buttons. + num_buttons = self.joystick.get_numbuttons() + up = self.DPAD_UP_BUTTON < num_buttons and bool(self.joystick.get_button(self.DPAD_UP_BUTTON)) + down = self.DPAD_DOWN_BUTTON < num_buttons and bool(self.joystick.get_button(self.DPAD_DOWN_BUTTON)) + return up, down def _read_visualizer_buttons(self, l2=0.0, r2=0.0): buttons = [0.0] * self.VISUALIZER_BUTTON_COUNT @@ -292,7 +309,7 @@ def set_light(self, level): straight to the bitmask so the change applies even when no joystick is connected (and the loop is not calling set_from_axes). """ - level = max(0.0, min(1.0, float(level))) + level = max(0.0, min(self.MAX_LIGHT, float(level))) self.light = level if self.bm: self.bm.set_command(light=int(round(level * 255))) @@ -425,7 +442,7 @@ def update(self): buttons = self._read_visualizer_buttons(l2=l2, r2=r2) if dpad_up and not self._prev_dpad_up: # Just pressed - self.light = min(1.0, self.light + 0.1) # +10% per press + self.light = min(self.MAX_LIGHT, self.light + 0.1) # +10% per press if dpad_down and not self._prev_dpad_down: # Just pressed self.light = max(0, self.light - 0.1) # -10% per press diff --git a/static/templates/debug.html b/static/templates/debug.html index 2435999..26ccaee 100644 --- a/static/templates/debug.html +++ b/static/templates/debug.html @@ -128,7 +128,7 @@
Lights
- +
diff --git a/static/templates/layout.html b/static/templates/layout.html index 145a2d1..2b56943 100644 --- a/static/templates/layout.html +++ b/static/templates/layout.html @@ -35,7 +35,7 @@
Lights
- +