4 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
pi
827eb97203 feat(settings): server global brightness and Wi-Fi driver resync
- Serve GET /settings as JSON by removing duplicate HTML route (use /settings/page for the standalone UI).

- Save global_brightness via PUT; broadcast to connected drivers; push saved level when outbound WS connects.

- Zones UI loads brightness from GET /settings only (no localStorage).

- Bump led-driver submodule for settings.save on brightness with save flag.

- Extend API doc and endpoint tests for global_brightness.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:30 +12:00
23 changed files with 445 additions and 72 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
docs/.help-print.html
settings.json
db/
*.log
*.db
*.sqlite

12
Pipfile
View File

@@ -22,9 +22,11 @@ pytest = "*"
python_version = "3.11"
[scripts]
web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install"
web = "python tests/web.py"
watch = "python -m watchfiles \"python tests/web.py\" src tests"
run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh"
dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
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"]}}

View File

@@ -42,7 +42,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) |
| GET | `/settings/page` | Standalone settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` |
@@ -72,7 +72,7 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
| GET | `/settings/page` | Serves `templates/settings.html`. |
### Devices — `/devices`

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

View File

@@ -1,7 +1,11 @@
from microdot import Microdot, send_file
from settings import Settings
import asyncio
import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import Settings
controller = Microdot()
settings = Settings()
@@ -63,17 +67,36 @@ def _validate_wifi_channel(value):
return ch
def _validate_global_brightness(value):
"""Return int 0255 or raise ValueError."""
v = int(value)
if v < 0 or v > 255:
raise ValueError("global_brightness must be between 0 and 255")
return v
@controller.put('/settings')
async def update_settings(request):
"""Update general settings."""
try:
data = request.json
global_brightness_changed = False
for key, value in data.items():
if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value)
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
global_brightness_changed = True
else:
settings[key] = value
settings.save()
if global_brightness_changed:
try:
asyncio.get_running_loop().create_task(
wifi_ws_clients.broadcast_global_brightness_to_tcp_drivers()
)
except RuntimeError:
pass
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400

View File

@@ -284,12 +284,6 @@ async def main(port=80):
def index(request):
"""Serve the main web UI."""
return send_file('templates/index.html')
# Serve settings page
@app.route('/settings')
def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico')

View File

@@ -84,6 +84,36 @@ def prune_stale_tcp_writers() -> None:
_schedule_status_broadcast(ip, False)
def _global_brightness_message_text() -> str | None:
"""v1 JSON line for saved zone UI brightness; works with shipping driver firmware (applies ``b`` in RAM)."""
global _settings
if _settings is None:
return None
try:
b = int(_settings.get("global_brightness", 255))
except (TypeError, ValueError):
b = 255
b = max(0, min(255, b))
return json.dumps({"v": "1", "b": b})
async def sync_global_brightness_to_driver(ip: str) -> bool:
"""Push Pi-stored global brightness to one Wi-Fi driver over the outbound WebSocket."""
text = _global_brightness_message_text()
if not text:
return False
return await send_json_line_to_ip(ip, text)
async def broadcast_global_brightness_to_tcp_drivers() -> None:
"""Push saved global brightness to every connected Wi-Fi driver."""
text = _global_brightness_message_text()
if not text:
return
for ip in list_connected_ips():
await send_json_line_to_ip(ip, text)
def _register_ws(ip: str, ws) -> None:
key = normalize_tcp_peer_ip(ip)
if not key:

View File

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

View File

@@ -73,6 +73,9 @@ class Settings(dict):
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
if 'serial_enabled' not in self:
self['serial_enabled'] = False
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
def save(self):
try:

View File

@@ -255,6 +255,18 @@ document.addEventListener('DOMContentLoaded', () => {
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) => {
if (!presetColorsContainer) return;
@@ -296,14 +308,21 @@ document.addEventListener('DOMContentLoaded', () => {
}
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');
const showBackgroundLabel = patternSupportsBackgroundColor() && currentPresetColors.length > 1;
currentPresetColors.forEach((color, index) => {
const isBackgroundColor = showBackgroundLabel && index === currentPresetColors.length - 1;
const swatchWrapper = document.createElement('div');
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.dataset.colorIndex = index;
swatchWrapper.dataset.backgroundColor = isBackgroundColor ? '1' : '0';
const refAtIndex = currentPresetPaletteRefs[index];
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
swatchWrapper.classList.add('draggable-color-swatch');
@@ -424,6 +443,18 @@ document.addEventListener('DOMContentLoaded', () => {
swatchWrapper.appendChild(swatch);
swatchWrapper.appendChild(colorPicker);
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);
});
@@ -445,6 +476,10 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault();
const dragging = swatchContainer.querySelector('.dragging-color');
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
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];

View File

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

View File

@@ -2,32 +2,12 @@
let currentZoneId = null;
let brightnessSendTimeout = null;
const UI_BRIGHTNESS_STORAGE_KEY = "led_controller_ui_brightness";
function clamp255(n) {
const v = parseInt(n, 10);
if (Number.isNaN(v)) return null;
return Math.max(0, Math.min(255, v));
}
function loadSavedUiBrightness() {
try {
const raw = localStorage.getItem(UI_BRIGHTNESS_STORAGE_KEY);
if (raw == null) return null;
return clamp255(raw);
} catch (_) {
return null;
}
}
function persistUiBrightness(value) {
const v = clamp255(value);
if (v === null) return;
try {
localStorage.setItem(UI_BRIGHTNESS_STORAGE_KEY, String(v));
} catch (_) {}
}
function applyBrightnessSliders(val) {
const v = clamp255(val);
if (v === null) return;
@@ -37,9 +17,27 @@ function applyBrightnessSliders(val) {
if (menuSlider) menuSlider.value = String(v);
}
function sendZoneBrightness(value) {
async function saveZoneBrightnessToServer(zoneId, val) {
if (!zoneId) return;
try {
const res = await fetch(`/zones/${zoneId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "same-origin",
body: JSON.stringify({ brightness: val }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.warn("zone brightness save failed:", err.error || res.status);
}
} catch (e) {
console.warn("zone brightness save failed:", e);
}
}
function sendZoneBrightness(zoneId, value) {
if (!zoneId) return;
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
persistUiBrightness(val);
const headerSlider = document.getElementById('header-brightness-slider');
const menuSlider = document.getElementById('menu-brightness-slider');
if (headerSlider && String(headerSlider.value) !== String(val)) {
@@ -54,6 +52,7 @@ function sendZoneBrightness(value) {
brightnessSendTimeout = setTimeout(() => {
(async () => {
try {
await saveZoneBrightnessToServer(zoneId, val);
const section = document.querySelector('.presets-section[data-zone-id]');
const names = typeof window.parseTabDeviceNames === 'function'
? window.parseTabDeviceNames(section)
@@ -553,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') {
@@ -1027,22 +1031,20 @@ document.addEventListener('DOMContentLoaded', () => {
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
const savedBr = loadSavedUiBrightness();
if (savedBr !== null) {
applyBrightnessSliders(savedBr);
}
if (menuBrightnessSlider) {
menuBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value);
});
}
if (headerBrightnessSlider) {
headerBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value);
});
// Apply saved (or default) level to devices once the page is ready.
sendZoneBrightness(headerBrightnessSlider.value);
}
(async () => {
if (menuBrightnessSlider) {
menuBrightnessSlider.addEventListener('input', (e) => {
if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
});
}
if (headerBrightnessSlider) {
headerBrightnessSlider.addEventListener('input', (e) => {
if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
});
}
})();
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {

View File

@@ -2,7 +2,7 @@
"""
Browser automation tests using Selenium.
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).
Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE``
@@ -49,7 +49,7 @@ from selenium.common.exceptions import (
ElementNotInteractableException,
)
_DEFAULT_DEVICE_HOST = "192.168.4.1"
_DEFAULT_DEVICE_HOST = "127.0.0.1:5000"
def _device_base_url() -> str:

View File

@@ -347,6 +347,15 @@ def test_settings_controller(server):
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42})
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
assert resp.json().get("global_brightness") == 42
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300})
assert resp.status_code == 400
def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"]