Compare commits
4 Commits
3cca0cffc5
...
beta-1.01
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d6ef5c7b4 | |||
| 78a4ce009c | |||
| 7ccab6fbc4 | |||
|
|
827eb97203 |
18
.cursor/rules/submodules-led-driver-tool.mdc
Normal file
18
.cursor/rules/submodules-led-driver-tool.mdc
Normal 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
1
.gitignore
vendored
@@ -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
12
Pipfile
@@ -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'"
|
||||||
|
|
||||||
|
|||||||
@@ -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": []}}
|
|
||||||
@@ -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 10–30 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1–255, higher = more changes)", "n2": "Density (0–255, 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
@@ -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"]}}
|
|
||||||
@@ -42,7 +42,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/` | Main UI (`templates/index.html`) |
|
| 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 | `/favicon.ico` | Empty response (204) |
|
||||||
| GET | `/static/<path>` | Static files under `src/static/` |
|
| 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"}`. |
|
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
|
||||||
| GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
| GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
||||||
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. |
|
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). 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`
|
### Devices — `/devices`
|
||||||
|
|
||||||
|
|||||||
Submodule led-driver updated: 3b38264b70...fbebe9f4f9
Submodule led-simulator updated: 7ce56b64df...42c14361e8
2
led-tool
2
led-tool
Submodule led-tool updated: d6331a105c...580fd11aca
253
scripts/pi-eth-lan-router.sh
Executable file
253
scripts/pi-eth-lan-router.sh
Executable 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from microdot import Microdot, send_file
|
import asyncio
|
||||||
from settings import Settings
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from microdot import Microdot, send_file
|
||||||
|
|
||||||
|
from models import wifi_ws_clients
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
@@ -63,17 +67,36 @@ def _validate_wifi_channel(value):
|
|||||||
return ch
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_global_brightness(value):
|
||||||
|
"""Return int 0–255 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')
|
@controller.put('/settings')
|
||||||
async def update_settings(request):
|
async def update_settings(request):
|
||||||
"""Update general settings."""
|
"""Update general settings."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
data = request.json
|
||||||
|
global_brightness_changed = False
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key == 'wifi_channel' and value is not None:
|
if key == 'wifi_channel' and value is not None:
|
||||||
settings[key] = _validate_wifi_channel(value)
|
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:
|
else:
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
settings.save()
|
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'}
|
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
@@ -285,12 +285,6 @@ async def main(port=80):
|
|||||||
"""Serve the main web UI."""
|
"""Serve the main web UI."""
|
||||||
return send_file('templates/index.html')
|
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)
|
# Favicon: avoid 404 in browser console (no file needed)
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon(request):
|
def favicon(request):
|
||||||
|
|||||||
@@ -84,6 +84,36 @@ def prune_stale_tcp_writers() -> None:
|
|||||||
_schedule_status_broadcast(ip, False)
|
_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:
|
def _register_ws(ip: str, ws) -> None:
|
||||||
key = normalize_tcp_peer_ip(ip)
|
key = normalize_tcp_peer_ip(ip)
|
||||||
if not key:
|
if not key:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ class Settings(dict):
|
|||||||
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
||||||
if 'serial_enabled' not in self:
|
if 'serial_enabled' not in self:
|
||||||
self['serial_enabled'] = False
|
self['serial_enabled'] = False
|
||||||
|
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||||
|
if 'global_brightness' not in self:
|
||||||
|
self['global_brightness'] = 255
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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')];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -2,32 +2,12 @@
|
|||||||
let currentZoneId = null;
|
let currentZoneId = null;
|
||||||
let brightnessSendTimeout = null;
|
let brightnessSendTimeout = null;
|
||||||
|
|
||||||
const UI_BRIGHTNESS_STORAGE_KEY = "led_controller_ui_brightness";
|
|
||||||
|
|
||||||
function clamp255(n) {
|
function clamp255(n) {
|
||||||
const v = parseInt(n, 10);
|
const v = parseInt(n, 10);
|
||||||
if (Number.isNaN(v)) return null;
|
if (Number.isNaN(v)) return null;
|
||||||
return Math.max(0, Math.min(255, v));
|
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) {
|
function applyBrightnessSliders(val) {
|
||||||
const v = clamp255(val);
|
const v = clamp255(val);
|
||||||
if (v === null) return;
|
if (v === null) return;
|
||||||
@@ -37,9 +17,27 @@ function applyBrightnessSliders(val) {
|
|||||||
if (menuSlider) menuSlider.value = String(v);
|
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));
|
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
|
||||||
persistUiBrightness(val);
|
|
||||||
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');
|
||||||
if (headerSlider && String(headerSlider.value) !== String(val)) {
|
if (headerSlider && String(headerSlider.value) !== String(val)) {
|
||||||
@@ -54,6 +52,7 @@ function sendZoneBrightness(value) {
|
|||||||
brightnessSendTimeout = setTimeout(() => {
|
brightnessSendTimeout = setTimeout(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
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)
|
||||||
@@ -553,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') {
|
||||||
@@ -1027,22 +1031,20 @@ 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');
|
||||||
const savedBr = loadSavedUiBrightness();
|
(async () => {
|
||||||
if (savedBr !== null) {
|
if (menuBrightnessSlider) {
|
||||||
applyBrightnessSliders(savedBr);
|
menuBrightnessSlider.addEventListener('input', (e) => {
|
||||||
}
|
if (!currentZoneId) return;
|
||||||
if (menuBrightnessSlider) {
|
sendZoneBrightness(currentZoneId, e.target.value);
|
||||||
menuBrightnessSlider.addEventListener('input', (e) => {
|
});
|
||||||
sendZoneBrightness(e.target.value);
|
}
|
||||||
});
|
if (headerBrightnessSlider) {
|
||||||
}
|
headerBrightnessSlider.addEventListener('input', (e) => {
|
||||||
if (headerBrightnessSlider) {
|
if (!currentZoneId) return;
|
||||||
headerBrightnessSlider.addEventListener('input', (e) => {
|
sendZoneBrightness(currentZoneId, e.target.value);
|
||||||
sendZoneBrightness(e.target.value);
|
});
|
||||||
});
|
}
|
||||||
// Apply saved (or default) level to devices once the page is ready.
|
})();
|
||||||
sendZoneBrightness(headerBrightnessSlider.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -347,6 +347,15 @@ def test_settings_controller(server):
|
|||||||
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
|
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
|
||||||
assert resp.status_code == 400
|
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):
|
def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||||
c: requests.Session = server["client"]
|
c: requests.Session = server["client"]
|
||||||
|
|||||||
Reference in New Issue
Block a user