Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions lib/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
25 changes: 23 additions & 2 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
43 changes: 43 additions & 0 deletions static/js/debug.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {});
}
})();
56 changes: 51 additions & 5 deletions static/js/lights.js
Original file line number Diff line number Diff line change
@@ -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]) => `<p>${name}: ${brightness}%</p>`)
.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();
});
18 changes: 18 additions & 0 deletions static/templates/debug.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ <h5 class="card-title mb-0">System Controls</h5>
</div>
</div>

<!-- Lights -->
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card custom-card">
<div class="card-body">
<h5 class="card-title mb-3">Lights</h5>
<p class="text-muted small mb-3">
Dimmable light on D6/PA8. Drives the same value the controller D-pad sets.
</p>
<label for="debug-light-slider" class="form-label small text-light-muted">
Brightness: <span id="debug-light-value">0</span>%
</label>
<input type="range" class="form-range" id="debug-light-slider" min="0" max="80" step="1" value="0">
</div>
</div>
</div>
</div>

<!-- Accelerometer Axis Mapping -->
<div class="row g-4 mt-1">
<div class="col-12">
Expand Down
7 changes: 6 additions & 1 deletion static/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ <h5 class="card-title">Depth</h5>
<div class="card custom-card h-100">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Lights</h5>
<div id="light-data" class="mt-3">Loading...</div>
<div class="mt-3">
<label for="light-slider" class="form-label">
Brightness: <span id="light-value">0</span>%
</label>
<input type="range" class="form-range" id="light-slider" min="0" max="80" step="1" value="0">
</div>
</div>
</div>
</div>
Expand Down
20 changes: 20 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading