diff --git a/lib/controller.py b/lib/controller.py index caf46b2..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 @@ -283,6 +300,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(self.MAX_LIGHT, 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: @@ -407,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/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..26ccaee 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..2b56943 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