3 Commits

Author SHA1 Message Date
pi
fbd4295302 feat(ui): patterns list and create form layout
Made-with: Cursor
2026-04-12 00:13:58 +12:00
pi
7bdb324ebc feat(patterns): driver_patterns helper, on/off ota guard, drop duplicate py tree
Made-with: Cursor
2026-04-12 00:13:56 +12:00
pi
28b19b5219 docs: zones, transports, pattern ota, and submodule readmes
Made-with: Cursor
2026-04-12 00:13:54 +12:00
22 changed files with 268 additions and 593 deletions

View File

@@ -1,23 +1,26 @@
# led-controller # led-controller
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge. LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
## Run ## Run
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh` - One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
- Start app: `pipenv run run` - Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev` - Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host) - Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
## UI modes ## UI modes
- **Run mode**: focused control view. Select tabs/presets and apply profiles. Editing actions are hidden. - **Run mode**: focused control view. Select zones/presets and apply profiles. Editing actions are hidden.
- **Edit mode**: management view. Shows Tabs, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder. - **Edit mode**: management view. Shows **Zones**, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
## Profiles ## Profiles
- Applying a profile updates session scope and refreshes the active zone content. - Applying a profile updates session scope and refreshes the active zone content.
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete). - In **Run mode**, Profiles supports apply-only behaviour (no create/clone/delete).
- In **Edit mode**, Profiles supports create/clone/delete. - In **Edit mode**, Profiles supports create/clone/delete.
- Creating a profile always creates a populated `default` zone (starter presets). - Creating a profile always creates a populated `default` zone (starter presets).
- Optional **DJ zone** seeding creates: - Optional **DJ zone** seeding creates:
@@ -35,3 +38,6 @@ LED controller web app for managing profiles, tabs, presets, and colour palettes
- Main API reference: `docs/API.md` - Main API reference: `docs/API.md`
## Driver pattern modules
Pattern **`.py`** sources live under **`led-driver/src/patterns`**. The Pi app resolves that path via `util.driver_patterns.driver_patterns_dir()`. If you deploy without that tree next to the app, set **`LED_CONTROLLER_PATTERNS_DIR`** to the directory that contains those files.

View File

@@ -2,10 +2,12 @@
This document covers: This document covers:
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources. 1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
2. **LED driver JSON** — the compact message format sent over the serialESP-NOW bridge to devices (same logical API as ESP-NOW payloads). 2. **LED driver JSON** — the compact **v1** message format. It is sent over the **serialESP-NOW bridge** to ESP32 peers and as **newline-delimited JSON over TCP** to **Wi-Fi** drivers (same logical fields).
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`). Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** the Pi accepts TCP connections on **`tcp_port`** in settings (default **8765**). **UDP discovery** listens on **8766** so drivers can find the controller IP on the LAN.
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted. All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
@@ -16,7 +18,7 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle
The main UI has two modes controlled by the mode toggle: The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (zone/preset selection and profile apply). - **Run mode**: optimized for operation (zone/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools). - **Edit mode**: shows editing/management controls (zones, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
Profiles are available in both modes, but behavior differs: Profiles are available in both modes, but behavior differs:
@@ -50,10 +52,12 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
Connect to **`ws://<host>:<port>/ws`**. Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used. - Send **JSON**: the object is forwarded through the **serial sender** (6-byte MAC prefix + payload to the ESP-NOW bridge). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used.
- Send **non-JSON text**: forwarded as raw bytes with the default address. - Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`. - On send failure, the server may reply with `{"error": "Send failed"}`.
Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**, device routes, or **`POST /patterns/<name>/send`** as appropriate.
--- ---
## HTTP API by resource ## HTTP API by resource
@@ -77,11 +81,11 @@ Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps t
| Field | Description | | Field | Description |
|-------|-------------| |-------|-------------|
| **`id`** | Same as the storage key (stable handle for URLs). | | **`id`** | Same as the storage key (stable handle for URLs). |
| **`name`** | Shown in tabs and used in `select` keys. | | **`name`** | Shown in the UI and used in `select` keys. |
| **`type`** | `led` (only value today; extensible). | | **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` or `wifi`. | | **`transport`** | `espnow` or `wifi`. |
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. | | **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
| **`default_pattern`**, **`tabs`** | Optional, as before. | | **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key). Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
@@ -89,7 +93,7 @@ Existing records without `type` / `transport` / `id` are backfilled on load (`le
|--------|------|-------------| |--------|------|-------------|
| GET | `/devices` | Map of device id → device object. | | GET | `/devices` | Map of device id → device object. |
| GET | `/devices/<id>` | One device, 404 if missing. | | GET | `/devices/<id>` | One device, 404 if missing. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`tabs`**. Returns `{ "<id>": { ... } }`, 201. | | POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. | | PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. | | DELETE | `/devices/<id>` | Remove device. |
@@ -102,7 +106,7 @@ Existing records without `type` / `transport` / `id` are backfilled on load (`le
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. | | GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. | | POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. | | POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. | | POST | `/profiles/<id>/clone` | Clone profile (zones, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). | | PUT | `/profiles/current` | Update the current profile (from session). |
| PUT | `/profiles/<id>` | Update profile by id. | | PUT | `/profiles/<id>` | Update profile by id. |
| DELETE | `/profiles/<id>` | Delete profile. | | DELETE | `/profiles/<id>` | Delete profile. |
@@ -143,11 +147,11 @@ Stored preset records can include:
- `colors`: resolved hex colours for editor/display. - `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index. - `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Tabs — `/zones` ### Zones — `/zones`
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/zones` | `tabs`, `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. | | GET | `/zones` | `zones` (map of zone id → zone object), `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
| GET | `/zones/current` | Current zone from cookie/session. | | GET | `/zones/current` | Current zone from cookie/session. |
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profiles zone list. | | POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profiles zone list. |
| GET | `/zones/<id>` | Zone JSON. | | GET | `/zones/<id>` | Zone JSON. |
@@ -198,20 +202,33 @@ Stored preset records can include:
### Patterns — `/patterns` ### Patterns — `/patterns`
Pattern metadata lives in **`db/pattern.json`**; driver source files live under **`led-driver/src/patterns/`**. Several routes expose a **runtime map** (metadata merged with on-disk `.py` names so new files appear in menus).
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). | | GET | `/patterns` | Runtime pattern map (object keyed by pattern id). |
| GET | `/patterns` | All pattern records. | | GET | `/patterns/definitions` | Same runtime map (intended for UI “definitions” clients). |
| GET | `/patterns/<id>` | One pattern. | | GET | `/patterns/ota/manifest` | JSON `{"files":[{"name":"blink.py","url":"http://<Host>/patterns/ota/file/blink.py"},...]}` for OTA pulls. Requires **`Host`** header. |
| GET | `/patterns/ota/file/<name>` | Raw **`.py`** source for one driver pattern (`name` must be a safe filename, e.g. `rainbow.py`). |
| POST | `/patterns/<name>/send` | Push a **manifest** JSON line to **Wi-Fi** devices so they pull one pattern file over HTTP. Body may include **`device_id`** to target one device; otherwise all Wi-Fi devices with an **`address`** are tried. **`<name>`** may be with or without `.py`. |
| POST | `/patterns/upload` | Body JSON: **`name`**, **`code`**, optional **`overwrite`** (default true). Writes **`led-driver/src/patterns/<name>.py`**. |
| POST | `/patterns/driver` | Body JSON: **`name`** (identifier), **`code`**, optional metadata (`min_delay`, `max_delay`, `max_colors`, `n1``n8`, **`overwrite`**). Creates/updates both the **`.py`** file and **`db/pattern.json`** via the Pattern model. |
| GET | `/patterns/<id>` | One pattern record from the Pattern model (metadata only). |
| POST | `/patterns` | Create (`name`, optional `data`). | | POST | `/patterns` | Create (`name`, optional `data`). |
| PUT | `/patterns/<id>` | Update. | | PUT | `/patterns/<id>` | Update. |
| DELETE | `/patterns/<id>` | Delete. | | DELETE | `/patterns/<id>` | Delete. |
**Devices — pattern OTA push**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/devices/<id>/patterns/push` | Wi-Fi only. Asks the driver at **`address`** to pull pattern files from this server. Optional body **`manifest`**: either a **URL string** pointing at a manifest JSON document, or a **manifest object** (same shape as in driver messages). If omitted, a default manifest is built from the request **`Host`** header. |
--- ---
## LED driver message format (transport / ESP-NOW) ## LED driver message format (transport / ESP-NOW / Wi-Fi)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge. Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge, and the same logical object can be sent as a **single JSON line** to a Wi-Fi driver over TCP.
### Top-level fields ### Top-level fields

View File

@@ -350,7 +350,7 @@ Manage connected devices and create/manage device groups.
#### Layout #### Layout
- **Header:** Title with "Add Device" button - **Header:** Title with "Add Device" button
- **Tabs:** Devices and Groups tabs - **Zones:** Devices and Groups zones (zone buttons / zone strip)
- **Content Area:** Zone-specific content - **Content Area:** Zone-specific content
#### Devices Zone #### Devices Zone
@@ -1774,7 +1774,7 @@ peak_mem = usqlite.mem_peak()
- Buttons respond to clicks - Buttons respond to clicks
- Sliders update values - Sliders update values
- Modals open/close - Modals open/close
- Tabs switch correctly - Zone buttons switch correctly
- Preset selector works - Preset selector works
- Preset creation form validates input - Preset creation form validates input
- Preset cards display correctly - Preset cards display correctly

View File

@@ -1,6 +1,6 @@
# LED controller — user guide # LED controller — user guide
This page describes the **main web UI** served from the Raspberry Pi app: profiles, tabs, presets, colour palettes, and sending commands to LED devices over the serial → ESP-NOW bridge. This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **serial → ESP-NOW bridge** or **Wi-Fi** (TCP to drivers on the LAN), depending on each devices transport.
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**. For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
@@ -12,24 +12,24 @@ Figures below are **schematic** (layout and ideas), not pixel-perfect screenshot
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it. The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
![Schematic: zone buttons on the left; Profiles, Tabs, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg) ![Schematic: zone buttons on the left; Profiles, Zones, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg)
*The active zone is highlighted. Extra management buttons appear only in Edit mode.* *The active zone is highlighted. Extra management buttons appear only in Edit mode.*
| Mode | Purpose | | Mode | Purpose |
|------|--------| |------|--------|
| **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. | | **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. |
| **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. | | **Edit mode** | Full setup: zones, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles. **Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
--- ---
## Tabs ## Zones
- **Select a zone**: click its button in the top bar. The main area shows that zones preset strip and controls. - **Select a zone**: click its button in the top bar. The main area shows that zones preset strip and controls.
- **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver. - **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver.
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder). - **Zones modal** (Edit mode): create new zones from the header **Zones** button. New zones need a name and device ID list (defaults to `1` if you leave a simple placeholder).
- **Brightness slider** (per zone): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link. - **Brightness slider** (per zone): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
--- ---
@@ -68,7 +68,7 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
## Profiles ## Profiles
- **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile. - **Apply**: sets the **current profile** in your session. Zones and presets you see are scoped to that profile.
- **Edit mode — Create**: new profiles always get a populated **default** zone. Optionally tick **DJ zone** to also create a `dj` zone (device name `dj`) with starter DJ-oriented presets. - **Edit mode — Create**: new profiles always get a populated **default** zone. Optionally tick **DJ zone** to also create a `dj` zone (device name `dj`) with starter DJ-oriented presets.
- **Clone** / **Delete**: available in Edit mode from the profile list. - **Clone** / **Delete**: available in Edit mode from the profile list.
@@ -82,7 +82,9 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
## Patterns ## Patterns
The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names and typical **delay** ranges from the pattern definitions. It does not change device behaviour by itself; patterns are chosen inside the preset editor. The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic.
--- ---
@@ -98,7 +100,7 @@ The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names
## Mobile layout ## Mobile layout
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Tabs, Presets, Help, mode toggle, etc.). On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Zones, Presets, Help, mode toggle, etc.).
![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg) ![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg)
@@ -108,5 +110,5 @@ On narrow screens, use **Menu** to reach the same actions as the desktop header
## Further reading ## Further reading
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys). - **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**).
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour. - **README** — `pipenv run run`, port 80 setup, and high-level behaviour.

Binary file not shown.

View File

@@ -1,6 +0,0 @@
from .blink import Blink
from .rainbow import Rainbow
from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle

View File

@@ -1,33 +0,0 @@
import utime
class Blink:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Blink pattern: toggles LEDs on/off using preset delay, cycling through colors."""
# Use provided colors, or default to white if none
colors = preset.c if preset.c else [(255, 255, 255)]
color_index = 0
state = True # True = on, False = off
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
# Re-read delay each loop so live updates to preset.d take effect
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(current_time, last_update) >= delay_ms:
if state:
base_color = colors[color_index % len(colors)]
color = self.driver.apply_brightness(base_color, preset.b)
self.driver.fill(color)
# Advance to next color for the next "on" phase
color_index += 1
else:
# "Off" phase: turn all LEDs off
self.driver.fill((0, 0, 0))
state = not state
last_update = current_time
# Yield once per tick so other logic can run
yield

View File

@@ -1,124 +0,0 @@
import utime
class Chase:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating.
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)"""
colors = preset.c
if len(colors) < 1:
# Need at least 1 color
return
# Access colors, delay, and n values from preset
if not colors:
return
# If only one color provided, use it for both colors
if len(colors) < 2:
color0 = colors[0]
color1 = colors[0]
else:
color0 = colors[0]
color1 = colors[1]
color0 = self.driver.apply_brightness(color0, preset.b)
color1 = self.driver.apply_brightness(color1, preset.b)
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative)
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
segment_length = n1 + n2
# Calculate position from step_count
step_count = self.driver.step
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
position = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position to keep it reasonable
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# If auto is False, run a single step and then stop
if not preset.a:
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step for next beat
self.driver.step = step_count + 1
# Allow tick() to advance the generator once
yield
return
# Auto mode: continuous loop
# Use transition_duration for timing and force the first update to happen immediately
transition_duration = max(10, int(preset.d))
last_update = utime.ticks_ms() - transition_duration
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Calculate current position from step_count
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step
step_count += 1
self.driver.step = step_count
last_update = current_time
# Yield once per tick so other logic can run
yield

View File

@@ -1,96 +0,0 @@
import utime
class Circle:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4"""
head = 0
tail = 0
# Calculate timing from preset
head_rate = max(1, int(preset.n1)) # n1 = head moves per second
tail_rate = max(1, int(preset.n3)) # n3 = tail moves per second
max_length = max(1, int(preset.n2)) # n2 = max length
min_length = max(0, int(preset.n4)) # n4 = min length
head_delay = 1000 // head_rate # ms between head movements
tail_delay = 1000 // tail_rate # ms between tail movements
last_head_move = utime.ticks_ms()
last_tail_move = utime.ticks_ms()
phase = "growing" # "growing", "shrinking", or "off"
# Support up to two colors (like chase). If only one color is provided,
# use black for the second; if none, default to white.
colors = preset.c
if not colors:
base0 = base1 = (255, 255, 255)
elif len(colors) == 1:
base0 = colors[0]
base1 = (0, 0, 0)
else:
base0 = colors[0]
base1 = colors[1]
color0 = self.driver.apply_brightness(base0, preset.b)
color1 = self.driver.apply_brightness(base1, preset.b)
while True:
current_time = utime.ticks_ms()
# Background: use second color during the "off" phase, otherwise clear to black
if phase == "off":
self.driver.n.fill(color1)
else:
self.driver.n.fill((0, 0, 0))
# Calculate segment length
segment_length = (head - tail) % self.driver.num_leds
if segment_length == 0 and head != tail:
segment_length = self.driver.num_leds
# Draw segment from tail to head as a solid color (no per-LED alternation)
current_color = color0
for i in range(segment_length + 1):
led_pos = (tail + i) % self.driver.num_leds
self.driver.n[led_pos] = current_color
# Move head continuously at n1 LEDs per second
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
head = (head + 1) % self.driver.num_leds
last_head_move = current_time
# Tail behavior based on phase
if phase == "growing":
# Growing phase: tail stays at 0 until max length reached
if segment_length >= max_length:
phase = "shrinking"
elif phase == "shrinking":
# Shrinking phase: move tail forward at n3 LEDs per second
if utime.ticks_diff(current_time, last_tail_move) >= tail_delay:
tail = (tail + 1) % self.driver.num_leds
last_tail_move = current_time
# Check if we've reached min length
current_length = (head - tail) % self.driver.num_leds
if current_length == 0 and head != tail:
current_length = self.driver.num_leds
# For min_length = 0, we need at least 1 LED (the head)
if min_length == 0 and current_length <= 1:
phase = "off" # All LEDs off for 1 step
elif min_length > 0 and current_length <= min_length:
phase = "growing" # Cycle repeats
else: # phase == "off"
# Off phase: second color fills the ring for 1 step, then restart
tail = head # Reset tail to head position to start fresh
phase = "growing"
self.driver.n.write()
# Yield once per tick so other logic can run
yield

View File

@@ -1,64 +0,0 @@
import utime
class Pulse:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
self.driver.off()
# Get colors from preset
colors = preset.c
if not colors:
colors = [(255, 255, 255)]
color_index = 0
cycle_start = utime.ticks_ms()
# State machine based pulse using a single generator loop
while True:
# Read current timing parameters from preset
attack_ms = max(0, int(preset.n1)) # Attack time in ms
hold_ms = max(0, int(preset.n2)) # Hold time in ms
decay_ms = max(0, int(preset.n3)) # Decay time in ms
delay_ms = max(0, int(preset.d))
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
if total_ms <= 0:
total_ms = 1
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, cycle_start)
base_color = colors[color_index % len(colors)]
if elapsed < attack_ms and attack_ms > 0:
# Attack: fade 0 -> 1
factor = elapsed / attack_ms
color = tuple(int(c * factor) for c in base_color)
self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < attack_ms + hold_ms:
# Hold: full brightness
self.driver.fill(self.driver.apply_brightness(base_color, preset.b))
elif elapsed < attack_ms + hold_ms + decay_ms and decay_ms > 0:
# Decay: fade 1 -> 0
dec_elapsed = elapsed - attack_ms - hold_ms
factor = max(0.0, 1.0 - (dec_elapsed / decay_ms))
color = tuple(int(c * factor) for c in base_color)
self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < total_ms:
# Delay phase: LEDs off between pulses
self.driver.fill((0, 0, 0))
else:
# End of cycle, move to next color and restart timing
color_index += 1
cycle_start = now
if not preset.a:
break
# Skip drawing this tick, start next cycle
yield
continue
# Yield once per tick
yield

View File

@@ -1,51 +0,0 @@
import utime
class Rainbow:
def __init__(self, driver):
self.driver = driver
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def run(self, preset):
step = self.driver.step % 256
step_amount = max(1, int(preset.n1)) # n1 controls step increment
# If auto is False, run a single step and then stop
if not preset.a:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b)
self.driver.n.write()
# Increment step by n1 for next manual call
self.driver.step = (step + step_amount) % 256
# Allow tick() to advance the generator once
yield
return
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
sleep_ms = max(1, int(preset.d)) # Get delay from preset
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(
self._wheel(rc_index & 255),
preset.b,
)
self.driver.n.write()
step = (step + step_amount) % 256
self.driver.step = step
last_update = current_time
# Yield once per tick so other logic can run
yield

View File

@@ -1,57 +0,0 @@
import utime
class Transition:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Transition between colors, blending over `delay` ms."""
colors = preset.c
if not colors:
self.driver.off()
yield
return
# Only one color: just keep it on
if len(colors) == 1:
while True:
self.driver.fill(self.driver.apply_brightness(colors[0], preset.b))
yield
return
color_index = 0
start_time = utime.ticks_ms()
while True:
if not colors:
break
# Get current and next color based on live list
c1 = colors[color_index % len(colors)]
c2 = colors[(color_index + 1) % len(colors)]
duration = max(10, int(preset.d)) # At least 10ms
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if elapsed >= duration:
# End of this transition step
if not preset.a:
# One-shot: transition from first to second color only
self.driver.fill(self.driver.apply_brightness(c2, preset.b))
break
# Auto: move to next pair
color_index = (color_index + 1) % len(colors)
start_time = now
yield
continue
# Interpolate between c1 and c2
factor = elapsed / duration
interpolated = tuple(
int(c1[i] + (c2[i] - c1[i]) * factor) for i in range(3)
)
self.driver.fill(self.driver.apply_brightness(interpolated, preset.b))
yield

View File

@@ -11,6 +11,7 @@ from models.tcp_clients import (
send_json_line_to_ip, send_json_line_to_ip,
tcp_client_connected, tcp_client_connected,
) )
from util.driver_patterns import driver_patterns_dir
from util.espnow_message import build_message from util.espnow_message import build_message
import asyncio import asyncio
import json import json
@@ -73,11 +74,6 @@ def _device_json_with_live_status(dev_dict):
return row return row
def _driver_patterns_dir():
here = os.path.dirname(__file__)
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
def _safe_pattern_filename(name): def _safe_pattern_filename(name):
if not isinstance(name, str): if not isinstance(name, str):
return False return False
@@ -89,7 +85,7 @@ def _safe_pattern_filename(name):
def _build_patterns_manifest(host): def _build_patterns_manifest(host):
base_dir = _driver_patterns_dir() base_dir = driver_patterns_dir()
names = sorted(os.listdir(base_dir)) names = sorted(os.listdir(base_dir))
files = [] files = []
for name in names: for name in names:

View File

@@ -2,6 +2,11 @@ from microdot import Microdot
from models.pattern import Pattern from models.pattern import Pattern
from models.device import Device from models.device import Device
from models.tcp_clients import send_json_line_to_ip from models.tcp_clients import send_json_line_to_ip
from util.driver_patterns import (
driver_patterns_dir,
is_firmware_builtin_pattern_module,
normalize_pattern_py_filename,
)
import json import json
import re import re
import sys import sys
@@ -17,11 +22,6 @@ def _project_root():
return os.path.abspath(os.path.join(here, "..", "..")) return os.path.abspath(os.path.join(here, "..", ".."))
def _driver_patterns_dir():
here = os.path.dirname(__file__)
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
def _safe_pattern_filename(name): def _safe_pattern_filename(name):
if not isinstance(name, str): if not isinstance(name, str):
return False return False
@@ -75,7 +75,7 @@ def load_driver_pattern_names():
"""List available pattern module names from led-driver/src/patterns.""" """List available pattern module names from led-driver/src/patterns."""
try: try:
names = [] names = []
for filename in os.listdir(_driver_patterns_dir()): for filename in os.listdir(driver_patterns_dir()):
if not _safe_pattern_filename(filename) or filename == "__init__.py": if not _safe_pattern_filename(filename) or filename == "__init__.py":
continue continue
names.append(filename[:-3]) names.append(filename[:-3])
@@ -111,7 +111,7 @@ async def get_pattern_definitions(request):
@controller.get('/ota/manifest') @controller.get('/ota/manifest')
async def ota_manifest(request): async def ota_manifest(request):
"""Manifest of driver pattern source files for OTA pulls.""" """Manifest of driver pattern source files for OTA pulls."""
base_dir = _driver_patterns_dir() base_dir = driver_patterns_dir()
host = request.headers.get("Host", "") host = request.headers.get("Host", "")
if not host: if not host:
return json.dumps({"error": "Missing Host header"}), 400, { return json.dumps({"error": "Missing Host header"}), 400, {
@@ -137,16 +137,32 @@ async def ota_manifest(request):
@controller.get('/ota/file/<name>') @controller.get('/ota/file/<name>')
async def ota_pattern_file(request, name): async def ota_pattern_file(request, name):
"""Serve one driver pattern source file for OTA pulls.""" """Serve one driver pattern source file for OTA pulls."""
if not _safe_pattern_filename(name) or name == "__init__.py": fname = normalize_pattern_py_filename(name)
if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py":
return json.dumps({"error": "Invalid filename"}), 400, { return json.dumps({"error": "Invalid filename"}), 400, {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
path = os.path.join(_driver_patterns_dir(), name) if is_firmware_builtin_pattern_module(fname):
return json.dumps(
{
"error": "on and off are built into the driver firmware; there is no module file to serve.",
}
), 400, {
"Content-Type": "application/json"
}
base = driver_patterns_dir()
path = os.path.join(base, fname)
try: try:
with open(path, "r") as f: with open(path, "r") as f:
content = f.read() content = f.read()
except OSError: except OSError:
return json.dumps({"error": "Pattern file not found"}), 404, { return json.dumps(
{
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
return content, 200, {"Content-Type": "text/plain; charset=utf-8"} return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
@@ -159,19 +175,34 @@ async def send_pattern_to_device(request, name):
return json.dumps({"error": "Invalid pattern name"}), 400, { return json.dumps({"error": "Invalid pattern name"}), 400, {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
filename = name if name.endswith(".py") else (name + ".py") filename = normalize_pattern_py_filename(name)
if not _safe_pattern_filename(filename) or filename == "__init__.py": if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "Invalid pattern filename"}), 400, { return json.dumps({"error": "Invalid pattern filename"}), 400, {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{
"error": "on and off are built into the driver firmware; OTA send does not apply.",
}
), 400, {
"Content-Type": "application/json"
}
devices = Device() devices = Device()
body = request.json or {} body = request.json or {}
requested_device_id = str(body.get("device_id") or "").strip() requested_device_id = str(body.get("device_id") or "").strip()
path = os.path.join(_driver_patterns_dir(), filename) base = driver_patterns_dir()
path = os.path.join(base, filename)
if not os.path.exists(path): if not os.path.exists(path):
return json.dumps({"error": "Pattern file not found"}), 404, { return json.dumps(
{
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
@@ -261,12 +292,18 @@ async def upload_pattern_file(request):
return json.dumps({"error": "invalid pattern filename"}), 400, { return json.dumps({"error": "invalid pattern filename"}), 400, {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
if not isinstance(code, str) or not code.strip(): if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required"}), 400, { return json.dumps({"error": "code is required"}), 400, {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
path = os.path.join(_driver_patterns_dir(), filename) path = os.path.join(driver_patterns_dir(), filename)
exists = os.path.exists(path) exists = os.path.exists(path)
if exists and not overwrite: if exists and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, { return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
@@ -304,6 +341,12 @@ async def create_driver_pattern(request):
return json.dumps({ return json.dumps({
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)", "error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
}), 400, {"Content-Type": "application/json"} }), 400, {"Content-Type": "application/json"}
if is_firmware_builtin_pattern_module(key):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
code = data.get("code") code = data.get("code")
if not isinstance(code, str) or not code.strip(): if not isinstance(code, str) or not code.strip():
@@ -314,7 +357,7 @@ async def create_driver_pattern(request):
overwrite = bool(data.get("overwrite", True)) overwrite = bool(data.get("overwrite", True))
filename = key + ".py" filename = key + ".py"
py_path = os.path.join(_driver_patterns_dir(), filename) py_path = os.path.join(driver_patterns_dir(), filename)
if os.path.exists(py_path) and not overwrite: if os.path.exists(py_path) and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, { return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json" "Content-Type": "application/json"

View File

@@ -55,8 +55,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (mode === 'create') { if (mode === 'create') {
if (labelEl) { if (labelEl) {
labelEl.textContent = ''; labelEl.textContent = `${key}:`;
labelEl.style.display = 'none'; labelEl.style.display = '';
} }
if (inputEl) { if (inputEl) {
inputEl.value = ''; inputEl.value = '';
@@ -203,6 +203,17 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
/** on/off are implemented in driver firmware (presets.py), not as OTA ``.py`` files. */
const FIRMWARE_BUILTIN_PATTERNS = new Set(['on', 'off']);
const isFirmwareBuiltinPattern = (patternName) => {
const id = String(patternName || '')
.trim()
.replace(/\.py$/i, '')
.toLowerCase();
return FIRMWARE_BUILTIN_PATTERNS.has(id);
};
const sendPatternToDevices = async (patternName) => { const sendPatternToDevices = async (patternName) => {
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, { const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
method: 'POST', method: 'POST',
@@ -281,7 +292,9 @@ document.addEventListener('DOMContentLoaded', () => {
} }
try { try {
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, { const raw = String(patternName || '').trim();
const fileSegment = /\.py$/i.test(raw) ? raw : `${raw}.py`;
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(fileSegment)}`, {
headers: { Accept: 'text/plain' }, headers: { Accept: 'text/plain' },
}); });
if (!response.ok) { if (!response.ok) {
@@ -315,39 +328,41 @@ document.addEventListener('DOMContentLoaded', () => {
const label = document.createElement('span'); const label = document.createElement('span');
label.textContent = patternName; label.textContent = patternName;
const details = document.createElement('span');
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
details.textContent = `${minDelay}${maxDelay} ms`;
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary btn-small';
sendBtn.textContent = 'Send';
sendBtn.addEventListener('click', async () => {
try {
await sendPatternToDevices(patternName);
} catch (error) {
console.error('Send pattern failed:', error);
alert(error.message || 'Failed to send pattern.');
}
});
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', async () => {
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
await loadPatternIntoEditor(patternName, data || {});
});
row.appendChild(label); row.appendChild(label);
row.appendChild(details);
row.appendChild(editBtn); if (isFirmwareBuiltinPattern(patternName)) {
row.appendChild(sendBtn); const note = document.createElement('span');
note.className = 'muted-text';
note.style.fontSize = '0.85em';
note.textContent = 'Built-in (no OTA module)';
row.appendChild(note);
} else {
const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary btn-small';
sendBtn.textContent = 'Send';
sendBtn.addEventListener('click', async () => {
try {
await sendPatternToDevices(patternName);
} catch (error) {
console.error('Send pattern failed:', error);
alert(error.message || 'Failed to send pattern.');
}
});
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', async () => {
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
await loadPatternIntoEditor(patternName, data || {});
});
row.appendChild(editBtn);
row.appendChild(sendBtn);
}
patternsList.appendChild(row); patternsList.appendChild(row);
}); });
}; };

View File

@@ -1288,6 +1288,12 @@ body.preset-ui-run .edit-mode-only {
justify-content: stretch; justify-content: stretch;
} }
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) label {
flex: 0 0 auto;
min-width: 2.25rem;
font-weight: 500;
}
#pattern-editor-modal .pattern-n-readable-input { #pattern-editor-modal .pattern-n-readable-input {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;

View File

@@ -259,14 +259,6 @@
<label for="pattern-create-name" style="min-width: 7rem;">Name</label> <label for="pattern-create-name" style="min-width: 7rem;">Name</label>
<input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off"> <input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off">
</div> </div>
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
<input type="number" id="pattern-create-min-delay" min="0" value="10">
<label for="pattern-create-max-delay">Max delay (ms)</label>
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
<label for="pattern-create-max-colors">Max colours</label>
<input type="number" id="pattern-create-max-colors" min="0" value="10">
</div>
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;"> <div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
<h3 class="muted-text">Readable parameter names</h3> <h3 class="muted-text">Readable parameter names</h3>
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p> <p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
@@ -305,6 +297,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
<input type="number" id="pattern-create-min-delay" min="0" value="10">
<label for="pattern-create-max-delay">Max delay (ms)</label>
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
<label for="pattern-create-max-colors">Max colours</label>
<input type="number" id="pattern-create-max-colors" min="0" value="10">
</div>
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;"> <div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
<label for="pattern-create-file">Pattern file</label> <label for="pattern-create-file">Pattern file</label>
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY"> <input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">

View File

@@ -1,6 +1,6 @@
# ESPNow Message Builder # Driver message builder (`espnow_message`)
This utility module provides functions to build ESPNow messages according to the LED Driver API specification. This utility builds **v1** JSON payloads for LED drivers (serial/ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
## Usage ## Usage
@@ -69,7 +69,7 @@ presets = build_presets_dict(presets_data)
## API Specification ## API Specification
See `docs/API.md` for the complete ESPNow API specification. See **`docs/API.md`** for REST routes, session scoping, and the compact preset keys on the wire.
## Key Features ## Key Features

View File

@@ -0,0 +1,53 @@
import os
_ENV_PATTERNS_DIR = "LED_CONTROLLER_PATTERNS_DIR"
def driver_patterns_dir():
"""Absolute path to driver pattern ``.py`` modules.
If ``LED_CONTROLLER_PATTERNS_DIR`` is set to an existing directory, that wins
(for installs where ``led-driver`` is not next to this repo). Otherwise uses
``<project-root>/led-driver/src/patterns``.
"""
env = (os.environ.get(_ENV_PATTERNS_DIR) or "").strip()
if env and os.path.isdir(env):
return os.path.abspath(env)
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.abspath(os.path.join(here, "..", ".."))
return os.path.join(root, "led-driver", "src", "patterns")
def normalize_pattern_py_filename(name):
"""Return a single ``*.py`` basename (no paths), or ``\"\"`` if invalid.
Strips repeated ``.py`` suffixes so ``blink.py.py`` becomes ``blink.py``.
"""
if not isinstance(name, str):
return ""
s = name.strip()
if not s:
return ""
lower = s.lower()
while lower.endswith(".py"):
s = s[:-3]
s = s.strip()
lower = s.lower()
if not s:
return ""
if "/" in s or "\\" in s or ".." in s:
return ""
return s + ".py"
# Implemented in led-driver ``presets.py`` only — no separate ``patterns/*.py``.
FIRMWARE_BUILTIN_PATTERN_IDS = frozenset({"on", "off"})
def is_firmware_builtin_pattern_module(name):
"""True for ``on`` / ``off``, with or without a ``.py`` suffix."""
if not isinstance(name, str):
return False
s = name.strip().lower()
while s.endswith(".py"):
s = s[:-3].strip()
return s in FIRMWARE_BUILTIN_PATTERN_IDS

View File

@@ -1,79 +1,47 @@
# Tests # Tests
This directory contains tests for the LED Controller project. Tests for the LED Controller project live under **`tests/`** (pytest + legacy scripts).
## Directory Structure ## Layout
- `test_endpoints.py` - HTTP endpoint tests that mimic web browser requests (runs against 192.168.4.1) | Path | Role |
- `test_ws.py` - WebSocket tests |------|------|
- `test_p2p.py` - ESP-NOW P2P tests | `test_endpoints.py` | HTTP endpoint checks (often against a running Pi; host configurable in file) |
- `models/` - Model unit tests | `test_endpoints_pytest.py` | Pytest-style endpoint coverage |
- `web.py` - Local development web server | `test_browser.py` | Selenium UI flows |
| `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers |
| `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol |
| `udp_server.py` | UDP discovery / hello test listener (port **8766**) |
| `ws.py` | WebSocket client checks |
| `p2p.py` | ESP-NOWrelated helpers / experiments |
| `web.py` | Local dev static server (not the main app) |
| `conftest.py` | Pytest fixtures |
| `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) |
## Running Tests ## Running tests
### Browser Tests (Real Browser Automation) ### Pytest (recommended)
Tests the web interface in an actual browser using Selenium: From the project root (with dev dependencies installed):
```bash
pipenv run pytest tests/ -q
```
### Browser tests (real browser)
```bash ```bash
python tests/test_browser.py python tests/test_browser.py
``` ```
These tests: Requires **Selenium**, Chrome/Chromium, and a matching **ChromeDriver**.
- Open a real Chrome browser
- Navigate to the device at 192.168.4.1
- Interact with UI elements (buttons, forms, modals)
- Test complete user workflows
- Verify visual elements and interactions
**Requirements:** ### Model tests only
```bash
pip install selenium
# Also need ChromeDriver installed and in PATH
# Download from: https://chromedriver.chromium.org/
```
### Endpoint Tests (Browser-like HTTP)
Tests HTTP endpoints by making requests to the device at 192.168.4.1:
```bash
python tests/test_endpoints.py
```
These tests:
- Mimic web browser requests with proper headers
- Handle cookies for session management
- Test all CRUD operations (GET, POST, PUT, DELETE)
- Verify responses and status codes
**Requirements:**
```bash
pip install requests
```
### WebSocket Tests
```bash
python tests/test_ws.py
```
**Requirements:**
```bash
pip install websockets
```
### Model Tests
```bash ```bash
python tests/models/run_all.py python tests/models/run_all.py
``` ```
### Local Development Server ### Local static server
Run the local development server (port 5000): `tests/web.py` serves files for quick UI experiments; it is **not** the Microdot app. For the real server use **`pipenv run run`** from the repo root.
```bash
python tests/web.py
```