3 Commits

Author SHA1 Message Date
3d6ef5c7b4 chore(git): stop tracking runtime db state files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:35:50 +12:00
78a4ce009c feat(ui): refresh preset data flow and bump driver pointer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:56 +12:00
7ccab6fbc4 feat(zones): persist per-zone brightness and update submodules
Store zone brightness in model/data flow, apply it in the zones UI, and record updated led-driver, led-simulator, and led-tool submodule pointers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:49:06 +12:00
18 changed files with 354 additions and 65 deletions

View File

@@ -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).

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ Thumbs.db
scripts/.led-controller-venv scripts/.led-controller-venv
docs/.help-print.html docs/.help-print.html
settings.json settings.json
db/
*.log *.log
*.db *.db
*.sqlite *.sqlite

12
Pipfile
View File

@@ -22,9 +22,11 @@ pytest = "*"
python_version = "3.11" python_version = "3.11"
[scripts] [scripts]
web = "python /home/pi/led-controller/tests/web.py" web = "python tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles \"python tests/web.py\" src tests"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'" run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src" dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh" test = "python -m pytest"
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"

View File

@@ -1 +0,0 @@
{"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": []}}

View File

@@ -1 +1 @@
{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}} {"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 1030 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1255, higher = more changes)", "n2": "Density (0255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"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"]}}

253
scripts/pi-eth-lan-router.sh Executable file
View File

@@ -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" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
net.ipv4.ip_forward=1
EOF
sysctl --system -q 2>/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" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
interface=$IF_LAN
bind-interfaces
dhcp-range=$DHCP_START,$DHCP_END,$mask,24h
dhcp-option=option:router,$LAN_IP
dhcp-option=option:dns-server,$DNSMASQ_DNS
EOF
}
remove_dnsmasq() {
rm -f "$DNSMASQ_SNIPPET"
}
write_nft() {
mkdir -p /etc/nftables.d
cat >"$NFT_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
table ip pi_eth_wlan_nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname "$IF_WAN" masquerade
}
}
EOF
if [[ -f "$NFTABLES_CONF" ]] && ! grep -qF '50-pi-eth-lan-router.nft' "$NFTABLES_CONF" 2>/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 <<EOF
Usage: sudo $0 install|remove
WAN (Wi-Fi client): $IF_WAN
LAN (Ethernet to AP): $IF_LAN
LAN address: ${LAN_IP}/${LAN_PREFIX}
DHCP range: $DHCP_START $DHCP_END
Override with environment variables (see script header).
EOF
}
case "${1:-}" in
install) do_install ;;
remove) do_remove ;;
*) usage; exit 1 ;;
esac

View File

@@ -367,6 +367,7 @@ async def create_driver_pattern(request):
Body JSON: Body JSON:
name, code (required), name, code (required),
min_delay, max_delay, max_colors (optional numbers), min_delay, max_delay, max_colors (optional numbers),
has_background (optional bool),
n1..n8 (optional string labels), n1..n8 (optional string labels),
overwrite (optional, default true). overwrite (optional, default true).
""" """
@@ -409,6 +410,9 @@ async def create_driver_pattern(request):
"Content-Type": "application/json" "Content-Type": "application/json"
} }
if "has_background" in data:
meta["has_background"] = bool(data.get("has_background"))
for i in range(1, 9): for i in range(1, 9):
nk = "n%d" % i nk = "n%d" % i
if nk not in data: if nk not in data:

View File

@@ -124,15 +124,6 @@ def _register_ws(ip: str, ws) -> None:
_send_locks[key] = asyncio.Lock() _send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True) _schedule_status_broadcast(key, True)
print(f"[WS] driver connected {key!r}") 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: def unregister_tcp_writer(peer_ip: str, ws=None) -> str:

View File

@@ -34,6 +34,7 @@ class Zone(Model):
"names": names if names else [], "names": names if names else [],
"presets": presets if presets else [], "presets": presets if presets else [],
"default_preset": None, "default_preset": None,
"brightness": 255,
} }
self.save() self.save()
return next_id return next_id

View File

@@ -255,6 +255,18 @@ document.addEventListener('DOMContentLoaded', () => {
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
}; };
const patternSupportsBackgroundColor = () => {
if (!presetPatternInput || !presetPatternInput.value) {
return false;
}
const pattern = String(presetPatternInput.value).trim();
const meta =
(cachedPatterns && cachedPatterns[pattern]) ||
(cachedPatterns && cachedPatterns[pattern.toLowerCase()]) ||
null;
return !!(meta && typeof meta === 'object' && meta.has_background === true);
};
const renderPresetColors = (colors, paletteRefs) => { const renderPresetColors = (colors, paletteRefs) => {
if (!presetColorsContainer) return; if (!presetColorsContainer) return;
@@ -296,14 +308,21 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const swatchContainer = document.createElement('div'); const swatchContainer = document.createElement('div');
swatchContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 0.5rem;'; swatchContainer.style.cssText = 'display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;';
swatchContainer.classList.add('color-swatches-container'); swatchContainer.classList.add('color-swatches-container');
const showBackgroundLabel = patternSupportsBackgroundColor() && currentPresetColors.length > 1;
currentPresetColors.forEach((color, index) => { currentPresetColors.forEach((color, index) => {
const isBackgroundColor = showBackgroundLabel && index === currentPresetColors.length - 1;
const swatchWrapper = document.createElement('div'); const swatchWrapper = document.createElement('div');
swatchWrapper.style.cssText = 'position: relative; display: inline-block;'; swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
if (isBackgroundColor) {
// Keep the background color swatch at the far right.
swatchWrapper.style.marginLeft = 'auto';
}
swatchWrapper.draggable = true; swatchWrapper.draggable = true;
swatchWrapper.dataset.colorIndex = index; swatchWrapper.dataset.colorIndex = index;
swatchWrapper.dataset.backgroundColor = isBackgroundColor ? '1' : '0';
const refAtIndex = currentPresetPaletteRefs[index]; const refAtIndex = currentPresetPaletteRefs[index];
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : ''; swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
swatchWrapper.classList.add('draggable-color-swatch'); swatchWrapper.classList.add('draggable-color-swatch');
@@ -424,6 +443,18 @@ document.addEventListener('DOMContentLoaded', () => {
swatchWrapper.appendChild(swatch); swatchWrapper.appendChild(swatch);
swatchWrapper.appendChild(colorPicker); swatchWrapper.appendChild(colorPicker);
swatchWrapper.appendChild(removeBtn); swatchWrapper.appendChild(removeBtn);
if (isBackgroundColor) {
const bgLabel = document.createElement('div');
bgLabel.textContent = 'Background';
bgLabel.style.cssText = `
margin-top: 0.25rem;
text-align: center;
font-size: 0.72rem;
color: #cfcfcf;
letter-spacing: 0.02em;
`;
swatchWrapper.appendChild(bgLabel);
}
swatchContainer.appendChild(swatchWrapper); swatchContainer.appendChild(swatchWrapper);
}); });
@@ -445,6 +476,10 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault(); e.preventDefault();
const dragging = swatchContainer.querySelector('.dragging-color'); const dragging = swatchContainer.querySelector('.dragging-color');
if (!dragging) return; if (!dragging) return;
const backgroundEl = swatchContainer.querySelector('.draggable-color-swatch[data-background-color="1"]');
if (backgroundEl) {
swatchContainer.appendChild(backgroundEl);
}
// Get new order of colors from DOM // Get new order of colors from DOM
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')]; const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];

View File

@@ -550,14 +550,14 @@ body.preset-ui-run .edit-mode-only {
padding: 0; padding: 0;
} }
/* Zone preset selecting area: 3 columns, vertical scroll only */ /* Zone preset selecting area: 8 columns on desktop, vertical scroll only */
#presets-list-zone { #presets-list-zone {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 5rem; grid-auto-rows: 5rem;
column-gap: 0.3rem; column-gap: 0.3rem;
row-gap: 0.3rem; row-gap: 0.3rem;
@@ -1261,8 +1261,8 @@ body.preset-ui-run .edit-mode-only {
.color-swatches-container { .color-swatches-container {
min-height: 80px; min-height: 80px;
} }
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */ /* Presets list: 3 columns on phone-sized screens */
@media (max-width: 1000px) { @media (max-width: 600px) {
#presets-list-zone { #presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem); padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);

View File

@@ -17,24 +17,26 @@ function applyBrightnessSliders(val) {
if (menuSlider) menuSlider.value = String(v); if (menuSlider) menuSlider.value = String(v);
} }
async function saveGlobalBrightnessToServer(val) { async function saveZoneBrightnessToServer(zoneId, val) {
if (!zoneId) return;
try { try {
const res = await fetch("/settings/settings", { const res = await fetch(`/zones/${zoneId}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ global_brightness: val }), body: JSON.stringify({ brightness: val }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); 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) { } 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 val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
const headerSlider = document.getElementById('header-brightness-slider'); const headerSlider = document.getElementById('header-brightness-slider');
const menuSlider = document.getElementById('menu-brightness-slider'); const menuSlider = document.getElementById('menu-brightness-slider');
@@ -50,7 +52,7 @@ function sendZoneBrightness(value) {
brightnessSendTimeout = setTimeout(() => { brightnessSendTimeout = setTimeout(() => {
(async () => { (async () => {
try { try {
await saveGlobalBrightnessToServer(val); await saveZoneBrightnessToServer(zoneId, val);
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const names = typeof window.parseTabDeviceNames === 'function' const names = typeof window.parseTabDeviceNames === 'function'
? window.parseTabDeviceNames(section) ? window.parseTabDeviceNames(section)
@@ -550,11 +552,16 @@ async function loadZoneContent(zoneId) {
`; `;
// Keep header and menu brightness controls in sync. // Keep header and menu brightness controls in sync.
const brightnessSlider = document.getElementById('header-brightness-slider'); const zoneBrightness =
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); typeof zone.brightness === 'number'
if (menuBrightnessSlider && brightnessSlider) { ? zone.brightness
menuBrightnessSlider.value = brightnessSlider.value; : 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 // Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
@@ -1025,38 +1032,17 @@ document.addEventListener('DOMContentLoaded', () => {
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
const headerBrightnessSlider = document.getElementById('header-brightness-slider'); const headerBrightnessSlider = document.getElementById('header-brightness-slider');
(async () => { (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) { if (menuBrightnessSlider) {
menuBrightnessSlider.addEventListener('input', (e) => { menuBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value); if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
}); });
} }
if (headerBrightnessSlider) { if (headerBrightnessSlider) {
headerBrightnessSlider.addEventListener('input', (e) => { headerBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value); if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
}); });
sendZoneBrightness(headerBrightnessSlider.value);
} }
})(); })();

View File

@@ -2,7 +2,7 @@
""" """
Browser automation tests using Selenium. Browser automation tests using Selenium.
Tests run against the device in an actual browser. Target host defaults to Tests run against the device in an actual browser. Target host defaults to
``192.168.4.1``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname, ``127.0.0.1:5000``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname,
or a full ``http://`` / ``https://`` base URL). or a full ``http://`` / ``https://`` base URL).
Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE`` Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE``
@@ -49,7 +49,7 @@ from selenium.common.exceptions import (
ElementNotInteractableException, ElementNotInteractableException,
) )
_DEFAULT_DEVICE_HOST = "192.168.4.1" _DEFAULT_DEVICE_HOST = "127.0.0.1:5000"
def _device_base_url() -> str: def _device_base_url() -> str: