Compare commits
3 Commits
pi
...
fbd4295302
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd4295302 | ||
|
|
7bdb324ebc | ||
|
|
28b19b5219 |
16
README.md
16
README.md
@@ -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.
|
||||||
|
|||||||
49
docs/API.md
49
docs/API.md
@@ -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 serial→ESP-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 **serial → ESP-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 profile’s zone list. |
|
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profile’s 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
20
docs/help.md
20
docs/help.md
@@ -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 device’s 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*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 zone’s preset strip and controls.
|
- **Select a zone**: click its button in the top bar. The main area shows that zone’s 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 device’s **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 device’s **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.).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
BIN
docs/help.pdf
BIN
docs/help.pdf
Binary file not shown.
Submodule led-driver updated: a64457a0d5...ded6e3d360
2
led-tool
2
led-tool
Submodule led-tool updated: 5f7acf38f0...eee9327e15
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
53
src/util/driver_patterns.py
Normal file
53
src/util/driver_patterns.py
Normal 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
|
||||||
@@ -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-NOW–related 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
|
|
||||||
```
|
|
||||||
|
|||||||
Reference in New Issue
Block a user