diff --git a/.cursor/rules/submodules-led-driver-tool.mdc b/.cursor/rules/submodules-led-driver-tool.mdc new file mode 100644 index 0000000..1bc3e10 --- /dev/null +++ b/.cursor/rules/submodules-led-driver-tool.mdc @@ -0,0 +1,18 @@ +--- +description: Keep led-driver and led-tool git submodules in sync when updating led-controller +alwaysApply: true +--- + +# Submodule pointers (`led-driver`, `led-tool`) + +This repo tracks **`led-driver`** and **`led-tool`** as git submodules (see `.gitmodules`). + +When you **update led-controller** work that should ship with matching firmware or CLI behaviour—or when you finish changes **inside** those submodule directories—**record the new submodule commits in the parent repo**: + +1. In each submodule, commit and push on its remote if there are local commits (or ensure the checkout is the intended revision). +2. From the **led-controller** root: `git add led-driver led-tool` after their HEADs point at the right commits. +3. Include the parent-repo commit that bumps the gitlinks (so CI and clones get consistent trees). + +**Do not** leave submodule directories dirty or forgotten while presenting the parent repo as “done”: either commit the submodule pointer update in led-controller, or leave an explicit note if the user must push submodule remotes first. + +If the user only asked for a submodule bump with no code edits, a single `chore(submodules): bump led-driver and led-tool` style commit is appropriate (see commit rule). diff --git a/db/device.json b/db/device.json index 7e39ceb..5f87503 100644 --- a/db/device.json +++ b/db/device.json @@ -1 +1 @@ -{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}} \ No newline at end of file +{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "dcb4d99988c8": {"id": "dcb4d99988c8", "name": "outside", "type": "led", "transport": "wifi", "address": "10.1.1.227", "default_pattern": null, "zones": []}} \ No newline at end of file diff --git a/db/zone.json b/db/zone.json index 1552682..6fb2031 100644 --- a/db/zone.json +++ b/db/zone.json @@ -1 +1 @@ -{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}} \ No newline at end of file +{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41", "brightness": 23}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"], "brightness": 167}} \ No newline at end of file diff --git a/led-driver b/led-driver index 2fcaf2f..a79c6f4 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit 2fcaf2f06425cb668d527c525d6d406a372ce4b3 +Subproject commit a79c6f4dd3f1c6509139753e806b2869e3ba78c6 diff --git a/led-simulator b/led-simulator index 7ce56b6..42c1436 160000 --- a/led-simulator +++ b/led-simulator @@ -1 +1 @@ -Subproject commit 7ce56b64df6d154d5d4cf103a9ab8b291e85c248 +Subproject commit 42c14361e8cb82ef5e5c4fd1927b5ccddfe39764 diff --git a/led-tool b/led-tool index d6331a1..580fd11 160000 --- a/led-tool +++ b/led-tool @@ -1 +1 @@ -Subproject commit d6331a105c03821a62af922651b3478f6a491f1f +Subproject commit 580fd11acabf5616888c26cd5090553e30c6fac3 diff --git a/scripts/pi-eth-lan-router.sh b/scripts/pi-eth-lan-router.sh new file mode 100755 index 0000000..da46661 --- /dev/null +++ b/scripts/pi-eth-lan-router.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Configure Raspberry Pi OS: Wi-Fi client on IF_WAN (default wlan0), Ethernet IF_LAN +# (default eth0) toward an external AP. Static LAN IP, DHCP via dnsmasq, NAT masquerade. +# +# Usage: +# sudo ./pi-eth-lan-router.sh install +# sudo ./pi-eth-lan-router.sh remove +# +# Environment overrides (optional): +# IF_WAN=wlan0 IF_LAN=eth0 LAN_IP=192.168.4.1 LAN_PREFIX=24 \ +# DHCP_START=192.168.4.100 DHCP_END=192.168.4.200 \ +# DNSMASQ_DNS=1.1.1.1,8.8.8.8 \ +# sudo ./pi-eth-lan-router.sh install + +set -euo pipefail + +IF_WAN="${IF_WAN:-wlan0}" +IF_LAN="${IF_LAN:-eth0}" +LAN_IP="${LAN_IP:-192.168.4.1}" +LAN_PREFIX="${LAN_PREFIX:-24}" +DHCP_START="${DHCP_START:-192.168.4.100}" +DHCP_END="${DHCP_END:-192.168.4.200}" +# Comma-separated DNS for DHCP clients (Pi does not need to run a resolver). +DNSMASQ_DNS="${DNSMASQ_DNS:-1.1.1.1,8.8.8.8}" + +NM_CON_NAME="pi-eth-lan-router" +MARK_BEGIN="# BEGIN pi-eth-lan-router (scripts/pi-eth-lan-router.sh)" +MARK_END="# END pi-eth-lan-router" +SYSCTL_FILE="/etc/sysctl.d/99-pi-eth-lan-router.conf" +DNSMASQ_SNIPPET="/etc/dnsmasq.d/pi-eth-lan-router.conf" +NFT_SNIPPET="/etc/nftables.d/50-pi-eth-lan-router.nft" +NFT_INCLUDE='include "/etc/nftables.d/50-pi-eth-lan-router.nft"' +NFTABLES_CONF="/etc/nftables.conf" +DHCPCD_CONF="/etc/dhcpcd.conf" + +die() { echo "error: $*" >&2; exit 1; } +log() { echo "$*"; } + +need_root() { + [[ "${EUID:-0}" -eq 0 ]] || die "run as root (sudo)" +} + +have_cmd() { command -v "$1" >/dev/null 2>&1; } + +apt_install() { + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq dnsmasq nftables +} + +write_sysctl() { + cat >"$SYSCTL_FILE" </dev/null || sysctl -p "$SYSCTL_FILE" || true +} + +remove_sysctl() { + rm -f "$SYSCTL_FILE" + sysctl --system -q 2>/dev/null || true +} + +write_dnsmasq() { + local mask="255.255.255.0" + if [[ "$LAN_PREFIX" != "24" ]]; then + die "only LAN_PREFIX=24 is supported by this script (extend dnsmasq netmask manually)" + fi + cat >"$DNSMASQ_SNIPPET" <"$NFT_SNIPPET" </dev/null; then + printf '\n# pi-eth-lan-router\n%s\n' "$NFT_INCLUDE" >>"$NFTABLES_CONF" + elif [[ ! -f "$NFTABLES_CONF" ]]; then + log "warning: $NFTABLES_CONF missing; NAT was not added for boot persistence. Install/configure nftables, or add: $NFT_INCLUDE" + fi +} + +remove_nft() { + rm -f "$NFT_SNIPPET" + if [[ -f "$NFTABLES_CONF" ]]; then + sed -i '/# pi-eth-lan-router/d;/50-pi-eth-lan-router\.nft/d' "$NFTABLES_CONF" || true + fi + nft delete table ip pi_eth_wlan_nat 2>/dev/null || true +} + +apply_nft() { + if have_cmd nft; then + nft delete table ip pi_eth_wlan_nat 2>/dev/null || true + nft -f "$NFT_SNIPPET" + fi +} + +configure_nm_eth() { + have_cmd nmcli || return 1 + systemctl is-active --quiet NetworkManager 2>/dev/null || return 1 + + if nmcli -t -f NAME con show --active 2>/dev/null | grep -qxF "$NM_CON_NAME"; then + nmcli con down "$NM_CON_NAME" || true + fi + if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then + nmcli con mod "$NM_CON_NAME" \ + connection.interface-name "$IF_LAN" \ + ipv4.method manual \ + ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \ + ipv4.gateway "" \ + ipv4.dns "" \ + ipv4.never-default yes \ + ipv6.method ignore + else + nmcli con add type ethernet con-name "$NM_CON_NAME" ifname "$IF_LAN" \ + ipv4.method manual \ + ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \ + ipv4.gateway "" \ + ipv4.dns "" \ + ipv4.never-default yes \ + ipv6.method ignore + fi + if ! nmcli con up "$NM_CON_NAME"; then + log "warning: could not activate '$NM_CON_NAME' (is $IF_LAN connected?); profile saved for next boot." + fi + return 0 +} + +remove_nm_eth() { + have_cmd nmcli || return 0 + if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then + nmcli con delete "$NM_CON_NAME" || true + fi +} + +configure_dhcpcd_eth() { + [[ -f "$DHCPCD_CONF" ]] || return 1 + if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then + sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true + fi + { + echo "$MARK_BEGIN" + echo "interface $IF_LAN" + echo "static ip_address=${LAN_IP}/${LAN_PREFIX}" + echo "nohook wpa_supplicant" + echo "$MARK_END" + } >>"$DHCPCD_CONF" + systemctl restart dhcpcd 2>/dev/null || true + return 0 +} + +remove_dhcpcd_block() { + [[ -f "$DHCPCD_CONF" ]] || return 0 + if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then + sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true + systemctl restart dhcpcd 2>/dev/null || true + fi +} + +configure_eth_static() { + if configure_nm_eth; then + log "configured $IF_LAN via NetworkManager profile '$NM_CON_NAME'" + return 0 + fi + if configure_dhcpcd_eth; then + log "configured $IF_LAN via dhcpcd ($DHCPCD_CONF)" + return 0 + fi + die "neither NetworkManager (active) nor $DHCPCD_CONF found; set $IF_LAN to ${LAN_IP}/${LAN_PREFIX} manually" +} + +remove_eth_static() { + remove_nm_eth + remove_dhcpcd_block +} + +do_install() { + need_root + log "installing packages (dnsmasq, nftables)…" + apt_install + + log "writing sysctl, dnsmasq, nftables snippets…" + write_sysctl + write_dnsmasq + write_nft + + log "setting static IP on $IF_LAN…" + configure_eth_static + + log "restarting dnsmasq…" + systemctl enable dnsmasq + systemctl restart dnsmasq + + log "loading NAT rules and enabling nftables…" + apply_nft + systemctl enable nftables 2>/dev/null || true + systemctl restart nftables 2>/dev/null || true + + log "done. Connect $IF_LAN to the external AP (DHCP off on the AP)." + log "Join Wi-Fi on $IF_WAN to the uplink network and complete any captive portal on the Pi." +} + +do_remove() { + need_root + remove_eth_static + remove_dnsmasq + systemctl restart dnsmasq 2>/dev/null || true + + remove_nft + systemctl restart nftables 2>/dev/null || true + + remove_sysctl + sysctl -w net.ipv4.ip_forward=0 2>/dev/null || true + + log "removed pi-eth-lan-router configuration snippets and NM profile '$NM_CON_NAME' (if present)." +} + +usage() { + cat < None: _send_locks[key] = asyncio.Lock() _schedule_status_broadcast(key, True) print(f"[WS] driver connected {key!r}") - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - - async def _apply_saved_brightness(): - await sync_global_brightness_to_driver(key) - - loop.create_task(_apply_saved_brightness()) def unregister_tcp_writer(peer_ip: str, ws=None) -> str: diff --git a/src/models/zone.py b/src/models/zone.py index fb8f099..14b6547 100644 --- a/src/models/zone.py +++ b/src/models/zone.py @@ -34,6 +34,7 @@ class Zone(Model): "names": names if names else [], "presets": presets if presets else [], "default_preset": None, + "brightness": 255, } self.save() return next_id diff --git a/src/static/zones.js b/src/static/zones.js index 216a3fe..79b1e08 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -17,24 +17,26 @@ function applyBrightnessSliders(val) { if (menuSlider) menuSlider.value = String(v); } -async function saveGlobalBrightnessToServer(val) { +async function saveZoneBrightnessToServer(zoneId, val) { + if (!zoneId) return; try { - const res = await fetch("/settings/settings", { + const res = await fetch(`/zones/${zoneId}`, { method: "PUT", headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "same-origin", - body: JSON.stringify({ global_brightness: val }), + body: JSON.stringify({ brightness: val }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); - console.warn("global_brightness save failed:", err.error || res.status); + console.warn("zone brightness save failed:", err.error || res.status); } } catch (e) { - console.warn("global_brightness save failed:", e); + console.warn("zone brightness save failed:", e); } } -function sendZoneBrightness(value) { +function sendZoneBrightness(zoneId, value) { + if (!zoneId) return; const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0)); const headerSlider = document.getElementById('header-brightness-slider'); const menuSlider = document.getElementById('menu-brightness-slider'); @@ -50,7 +52,7 @@ function sendZoneBrightness(value) { brightnessSendTimeout = setTimeout(() => { (async () => { try { - await saveGlobalBrightnessToServer(val); + await saveZoneBrightnessToServer(zoneId, val); const section = document.querySelector('.presets-section[data-zone-id]'); const names = typeof window.parseTabDeviceNames === 'function' ? window.parseTabDeviceNames(section) @@ -550,11 +552,16 @@ async function loadZoneContent(zoneId) { `; // Keep header and menu brightness controls in sync. - const brightnessSlider = document.getElementById('header-brightness-slider'); - const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); - if (menuBrightnessSlider && brightnessSlider) { - menuBrightnessSlider.value = brightnessSlider.value; - } + const zoneBrightness = + typeof zone.brightness === 'number' + ? zone.brightness + : parseInt(String(zone.brightness ?? ''), 10); + const normalizedBrightness = Number.isFinite(zoneBrightness) + ? Math.max(0, Math.min(255, Math.round(zoneBrightness))) + : 255; + applyBrightnessSliders(normalizedBrightness); + // Apply this zone's saved brightness when switching zones. + sendZoneBrightness(zoneId, normalizedBrightness); // Trigger presets loading if the function exists if (typeof renderTabPresets === 'function') { @@ -1025,38 +1032,17 @@ document.addEventListener('DOMContentLoaded', () => { const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); const headerBrightnessSlider = document.getElementById('header-brightness-slider'); (async () => { - let fromServer = null; - try { - const res = await fetch('/settings', { - headers: { Accept: 'application/json' }, - credentials: 'same-origin', - }); - if (res.ok) { - const data = await res.json(); - const g = data.global_brightness; - if (typeof g === 'number' && g >= 0 && g <= 255) { - fromServer = Math.round(g); - } else if (g != null && g !== '') { - const n = parseInt(String(g), 10); - if (!Number.isNaN(n) && n >= 0 && n <= 255) { - fromServer = n; - } - } - } - } catch (_) {} - if (fromServer !== null) { - applyBrightnessSliders(fromServer); - } if (menuBrightnessSlider) { menuBrightnessSlider.addEventListener('input', (e) => { - sendZoneBrightness(e.target.value); + if (!currentZoneId) return; + sendZoneBrightness(currentZoneId, e.target.value); }); } if (headerBrightnessSlider) { headerBrightnessSlider.addEventListener('input', (e) => { - sendZoneBrightness(e.target.value); + if (!currentZoneId) return; + sendZoneBrightness(currentZoneId, e.target.value); }); - sendZoneBrightness(headerBrightnessSlider.value); } })();