diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..dfe0770424 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 0000000000..21c96f7202 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,592 @@ +# AGENTS.md — WLED AI Coding Agent & AI Code Review Reference + +WLED is C++ firmware for ESP32/ESP8266 microcontrollers controlling addressable LEDs, +with a web UI (HTML/JS/CSS). Built with PlatformIO (Arduino framework) and Node.js tooling. + +See also: `.github/copilot-instructions.md`, `.github/agent-build.instructions.md`, +`docs/cpp.instructions.md`, `docs/web.instructions.md`, `docs/cicd.instructions.md`, +`docs/hardening.instructions.md`, `docs/securecode.instructions.md`. + +Always reference these instructions — including coding guidelines in `docs/` — first and +fallback to search or bash commands only when you encounter unexpected information that +does not match the info here. + +## Build Commands + +| Command | Purpose | Timeout | +|---|---|---| +| `npm ci` | Install Node.js deps (required first) | 30s | +| `npm run build` | Build web UI into `wled00/html_*.h` / `wled00/js_*.h` | 30s | +| `npm test` | Run test suite (Node.js built-in `node --test`) | 2 min | +| `npm run dev` | Watch mode — auto-rebuilds web UI on changes | continuous | +| `pio run -e ulanzi_tc001` | Build firmware (primary target for this project) | 5 min | +| `pio run -e esp32dev` | Build firmware (generic ESP32 validation) | 5 min | +| `pio run -e nodemcuv2` | Build firmware (ESP8266) | 5 min | + +**Always run `npm ci && npm run build` before `pio run`.** The web UI build generates +required C headers for firmware compilation. + +### Upload Command + +```bash +cd "/Users/yourmom/Library/CloudStorage/GoogleDrive-i@dubpixel.tv/My Drive/_.DUBPIXEL/_...CIRCUIT_PROJECTS/dpx_tc002_frm" && ~/.platformio/penv/bin/pio run -t upload -e ulanzi_tc001 +``` + +- **Never use `--upload-port`** — breaks auto-detection +- **Never pipe through `| tail` or `| grep`** — hides progress bars, makes it look frozen +- OTA upload: `-e ulanzi_tc001_ota` + +### Running a Single Test + +Tests use Node.js built-in test runner (`node:test`). The single test file is +`tools/cdata-test.js`. Run it with: + +```bash +npm test # runs all tests via `node --test` +node --test tools/cdata-test.js # run just that file directly +``` + +There are no C++ unit tests. Firmware is validated by successful compilation across +target environments. Always build after code changes: `pio run -e ulanzi_tc001`. + +### Common Firmware Environments + +`ulanzi_tc001` (primary), `ulanzi_tc001_ota` (OTA), `esp32dev`, `nodemcuv2`, +`esp8266_2m`, `esp32c3dev`, `esp32s3dev_8MB_opi`, `lolin_s2_mini` + +### Recovery / Troubleshooting + +```bash +npm run build -- -f # force web UI rebuild +rm -f wled00/html_*.h wled00/js_*.h && npm run build # clean + rebuild UI +pio run --target clean # clean PlatformIO build artifacts +rm -rf node_modules && npm ci # reinstall Node.js deps +``` + +## Project Structure + +```text +wled00/ # Main firmware source (C++) + data/ # Web UI source (HTML/JS/CSS) — tabs for indentation + html_*.h, js_*.h # Auto-generated (NEVER edit or commit) + src/ # Sub-modules: fonts, bundled dependencies (ArduinoJSON) +usermods/ # Community usermods (each has library.json + .cpp/.h) + dpx_matrix/ # PRIMARY custom usermod — 32x8 matrix display system +platformio.ini # Build configuration and environments +pio-scripts/ # PlatformIO build scripts (Python) +tools/ # Node.js build tools (cdata.js) and tests +docs/ # Coding convention docs + HANDOFF_TODO.md (feature roadmap) +.github/workflows/ # CI/CD (GitHub Actions) +``` + +### Branch / Release Structure + +```text +main # Main development trunk. Target branch for all work. + feature/* # Feature branches — always branch from main +16_x # upstream WLED maintenance branch (do not use for dpx work) +0_15_x # upstream WLED maintenance branch (do not use for dpx work) +``` + +> **Note:** This repo is a private fork of WLED. Do NOT open PRs to upstream +> `wled/WLED` unless the change is generic WLED code with no dpx_tc002-specific content. + +## C++ Code Style (wled00/, usermods/) + +For full detail see `docs/cpp.instructions.md` and `docs/esp-idf.instructions.md`. +Key rules: + +- **2-space indentation** (no tabs in C++ files) +- K&R brace style; single-statement `if` bodies may omit braces +- camelCase functions/variables, PascalCase classes/structs, UPPER_CASE macros, _camelCase private members +- `// AI: below section was generated by an AI` / `// AI: end` markers required on AI-generated blocks +- `d_malloc()` / `p_malloc()` for heap allocation (DRAM-preferred / PSRAM-preferred) +- No VLAs, no `String` in hot paths, `reserve()` before appending +- `const &` for read-only parameters +- `sin8_t()` / `cos8_t()` — NOT `sin8()` / `cos8()` (removed, won't compile) +- `sin_approx()` / `cos_approx()` instead of `sinf()` / `cosf()` +- `perlin8` / `perlin16` instead of `inoise8` / `inoise16` +- `DEBUG_PRINTF()` / `DEBUG_PRINTLN()` for diagnostics (compiled out unless `-D WLED_DEBUG`) +- No C++ exceptions — use return codes (`false`, `-1`) and global flags + +### PSRAM +- Check `psramFound() && ESP.getPsramSize() > 0` at runtime — never rely on `BOARD_HAS_PSRAM` alone +- Classic ESP32: PSRAM is NOT DMA-capable +- `CONFIG_SPIRAM_MODE_OCT` on S3 reserves GPIO 33–37 — do not allocate those pins + +## Web UI Code Style (wled00/data/) + +- **Tab indentation** for HTML, JS, and CSS +- camelCase for JS functions/variables +- Reuse helpers from `common.js` — do not duplicate utilities +- After editing, run `npm run build` to regenerate headers +- **Never edit** `wled00/html_*.h` or `wled00/js_*.h` directly + +## Usermod Pattern + +Usermods live in `usermods//` with a `.cpp`, optional `.h`, `library.json`, and `readme.md`. + +```cpp +class MyUsermod : public Usermod { + private: + bool enabled = false; + static const char _name[]; + public: + void setup() override { } + void loop() override { } + void addToConfig(JsonObject& root) override { } + bool readFromConfig(JsonObject& root) override { } + uint16_t getId() override { return USERMOD_ID_MYMOD; } + void addToJsonInfo(JsonObject& root) override { } + void appendConfigData() override { } +}; +const char MyUsermod::_name[] PROGMEM = "MyUsermod"; +static MyUsermod myUsermod; +REGISTER_USERMOD(myUsermod); +``` + +- Activate via `custom_usermods =` in platformio build config +- Base new usermods on `usermods/EXAMPLE/` (never edit the example directly) +- Unique `USERMOD_ID_*` only required for inter-usermod comms, pin ownership, or JSON info ID + +## CI/CD + +CI runs on every push/PR via GitHub Actions (`.github/workflows/wled-ci.yml`): +1. `npm test` (web UI build validation) +2. Firmware compilation for all default environments (~22 targets) +3. Post-link validation of usermod linkage (`validate_modules.py`) + +## General Rules + +- Repository language is **English** +- Never edit or commit `wled00/html_*.h` / `wled00/js_*.h` +- No force-push on open PRs +- **Changes to `platformio.ini` require maintainer approval** +- Remove dead/unused code — justify or delete it +- Verify `WLED_ENABLE_*` / `WLED_DISABLE_*` flag names exactly — typos silently break builds + +### Security Hardening + +Consult `docs/hardening.instructions.md` (concise checklist) and +`docs/securecode.instructions.md` (detailed OWASP rules). Key constraints: +- Validate and clamp ALL HTTP/JSON/UDP input at ingress (FW4 — CRITICAL) +- Bounded string ops: `strlcpy`/`snprintf` — no `strcpy`/`sprintf` with untrusted data +- No `innerHTML` in web UI JS — use `textContent` +- Fail closed on parse/allocation errors + +### Attribution for AI-generated code + +- `// AI: below section was generated by an AI` / `// AI: end` around generated blocks +- Document sources/inspiration with links +- Every non-trivial AI function needs a brief purpose comment +- AI-generated code must explain intent, assumptions, and non-obvious logic + +--- + +## PROJECT: dpx_tc002_frm + +**Status:** Active development +**Branch:** `main` (feature branches: `feature/brief-description`) +**Version File:** `VERSION` + `package.json` + +### Architecture (2-minute summary) + +dpx_tc002_frm is a private WLED fork targeting the **Ulanzi TC001** — an ESP32 device +with a 32x8 WS2812B LED matrix (256 pixels). The core firmware is stock WLED; all +device-specific display logic lives in the `dpx_matrix` usermod. The device runs as a +standalone network-connected display with an HTTP/MQTT/OSC API for showing notifications, +custom apps, timecode, icons, GIFs, and overlay effects — matching the behavioral +contract defined in `SPEC.md`. + +| Component | Tech / Location | Purpose | Notes | +|-----------|-----------------|---------|-------| +| WLED core | C++ / `wled00/` | LED control, effects, palettes, OTA, MQTT | Do not modify unless change is generic WLED | +| dpx_matrix | C++ / `usermods/dpx_matrix/` | 32x8 display system — text, apps, TC, overlays | Primary development target | +| Web UI | HTML/JS / `wled00/data/` | WLED interface + dpx_matrix custom pages | `npm run build` required after any edit | +| SPEC.md | Markdown / `dpx_reference/` | Behavioral contract: API schema, all JSON keys | **Source of truth** | +| dpx_tc002.md | Markdown / `dpx_reference/` | Build plan: phase order, usermod structure, GPIO map | Read before firmware work | +| dpx_tc002_server.md | Markdown / `dpx_reference/` | Companion server plan (Friendster/CueMaster) | Read before any server work | +| HANDOFF_TODO.md | Markdown / `docs/` | Feature roadmap: 6 items, status, implementation notes | Read before starting new features | + +> Reference docs absolute path: `/Users/yourmom/Library/CloudStorage/GoogleDrive-i@dubpixel.tv/My Drive/_.DUBPIXEL/_...CIRCUIT_PROJECTS/dpx_tc001/dpx_reference/` + +### dpx_matrix Usermod Files + +| File | Purpose | +|------|---------| +| `dpx_apps.h` | App loop, `DpxCustomApp` struct, JSON parse, render dispatch | +| `dpx_text.h` | Text rendering, `dpxDrawProgressBar()`, scroll engine | +| `dpx_overlay.h` | Pixel overlay effects (rain, sparkle, twinkle, strobe, blink) | +| `dpx_api.h` | HTTP endpoints: `/notify`, `/app`, `/tc`, `/browse` | +| `dpx_html.h` | Embedded web UI: ctrl page, icon browser, GIF browser | +| `dpx_tc.h` | Timecode display (both render modes), frame bar | +| `dpx_font.h` | AwtrixFont 3x5 TomThumb bitmap font | +| `dpx_icons.h` | Icon load + render (planned — not yet created) | + +### Agent Rules (for this repo) + +**Before ANY code change:** +1. Read `dpx_reference/SPEC.md` + `dpx_reference/dpx_tc002.md` for behavioral contract +2. Read `docs/HANDOFF_TODO.md` for feature status and planned implementation approach +3. Work from `main`; create feature branch: `feature/brief-description` +4. Bump `VERSION` file (semver) and commit the bump separately + +**While coding:** +- Never touch `wled00/` for dpx_matrix features — keep custom code in `usermods/dpx_matrix/` +- Always run `npm run build` before `pio run` +- Build with `pio run -e ulanzi_tc001` to validate +- Mark AI-generated blocks: `// AI: below section was generated by an AI` / `// AI: end` +- Use `p_malloc()` for GIF/icon pixel buffers; check `psramFound()` first +- Validate all HTTP input at ingress before using as lengths/indices + +**When done:** +- Update `docs/HANDOFF_TODO.md` to reflect new status of any completed features +- Update `CHANGELOG.md` +- Build must pass: `npm run build && pio run -e ulanzi_tc001` +- Create PR per §1 template + +### Critical Constraints + +**MUST HAVE:** +- ✅ All dpx_matrix API matches SPEC.md JSON schema exactly +- ✅ Build succeeds on `ulanzi_tc001` environment +- ✅ `npm run build` runs before any `pio run` +- ✅ `// AI:` markers on all AI-generated code blocks + +**DO NOT:** +- ❌ Edit `wled00/html_*.h` or `wled00/js_*.h` (auto-generated) +- ❌ Modify `platformio.ini` without maintainer approval +- ❌ Force-push to open PRs +- ❌ Open PRs to upstream `wled/WLED` with dpx_tc002-specific code +- ❌ Use `--upload-port` or pipe upload through `| tail`/`| grep` +- ❌ Use `strcpy`/`sprintf` with untrusted HTTP/JSON data — use `strlcpy`/`snprintf` +- ❌ Use `innerHTML` in web UI JS — use `textContent` + +### Key Decisions + +- **WLED base:** MIT license, commercially clean, 100+ effects, mature OTA/MQTT. All display logic in `dpx_matrix` usermod keeps WLED core untouched. +- **ulanzi_tc001 environment:** Specific build env with correct matrix size, pin definitions, and flash/partition config for TC001 hardware. +- **SPEC.md is law:** `dpx_reference/SPEC.md` defines the behavioral contract. Any API field, endpoint, or behavior not matching SPEC is a bug. +- **Private fork:** Not PRed to upstream WLED. Generic improvements may be upstreamed separately. + +### Gotchas & Landmines + +1. **Upload command:** Never use `--upload-port` — breaks auto-detection. Never pipe output. See upload command in Build Commands above. +2. **GIF/PNG on ESP32 classic:** PSRAM is NOT DMA-capable on classic ESP32. GIF frame buffers and icon pixel data must use `p_malloc()` but must never be passed directly to DMA. +3. **Font math:** `sin8_t()`/`cos8_t()` only — `sin8()`/`cos8()` were removed and won't compile. +4. **Overlay name mismatch bug:** Effect names in `dpxRenderPixelEffect()` don't match JSON schema names in `dpx_html.h`. Fix both sides when adding new effects. +5. **Progress bar is done:** `progress`/`progressC`/`progressBC` fields are fully implemented in `dpx_apps.h` + `dpx_text.h`. Do not reimplement. +6. **Icons/GIFs are stubs:** Web UI downloads to LittleFS but zero firmware rendering exists. See `docs/HANDOFF_TODO.md` items 2 and 3. +7. **Reference docs are outside this repo:** `dpx_reference/` is a separate workspace folder — not inside `dpx_tc002_frm`. + +### Feature Roadmap + +See `docs/HANDOFF_TODO.md` for full detail. Summary: + +| # | Feature | Status | +|---|---------|--------| +| 1 | cpt-city gradient integration | ❌ Not started | +| 2 | Animated GIFs (bigtime) | ❌ Stub — web UI only, zero firmware | +| 3 | LaMetric icons on matrix | 🟡 Partial — downloads to LittleFS, no LED rendering | +| 4 | Font pixels as WLED LED effects | ❌ Not started — architecture work required | +| 5 | Text overlay effects | 🟡 Partial — 5 of 7 effects unimplemented + name bug | +| 6 | Progress bar | ✅ Done | + +--- + +## 1. Automatic Workflow (MANDATORY) + +These actions are **required** and must happen automatically. **NEVER ask permission** for these workflow steps. + +### Branching Strategy + +**BEFORE starting ANY code changes:** + +1. Create a new branch from the default branch (main) +2. Never work directly on default branch +3. Branch naming conventions: + +| Type | Format | Example | +|------|--------|---------| +| New feature | `feature/brief-description` | `feature/gif-playback` | +| Bug fix | `fix/issue-description` | `fix/overlay-name-mismatch` | +| Documentation | `docs/what-changed` | `docs/update-handoff-todo` | +| Refactor | `refactor/component-name` | `refactor/dpx-overlay` | + +### Version Bumping + +**BEFORE the first code change:** + +Bump the version number according to semantic versioning: + +| Change Type | Version Bump | Example | +|-------------|--------------|---------| +| Bug fix, typo fix, documentation update | Patch (0.0.X) | 1.2.3 → 1.2.4 | +| New feature, new endpoint, new capability | Minor (0.X.0) | 1.2.3 → 1.3.0 | +| Breaking change, API removal, incompatible change | Major (X.0.0) | 1.2.3 → 2.0.0 | + +#### Semantic Versioning Principles + +**Format:** `MAJOR.MINOR.PATCH` (e.g., `2.4.7`) + +- **MAJOR**: Incompatible API changes, breaking existing functionality +- **MINOR**: New functionality added in a backwards-compatible manner +- **PATCH**: Backwards-compatible bug fixes, docs, typos + +**Pre-1.0 versions (0.x.y):** +- Anything goes — breaking changes allowed in minor bumps +- Move to 1.0.0 when API is stable and production-ready + +**Pre-release versions:** +- Alpha: `1.0.0-alpha.1` | Beta: `1.0.0-beta.2` | RC: `1.0.0-rc.1` + +#### Version Bump Workflow + +1. **Determine bump type** based on changes planned +2. **Update version number** in `VERSION` and `package.json` +3. **Create git commit**: `bump version to X.Y.Z` +4. **Tag the commit**: `git tag vX.Y.Z` +5. **Push with tags**: `git push && git push --tags` +6. **Update CHANGELOG.md** with version and changes +7. **Proceed with feature/fix implementation** + +**Version commit should be standalone** — don't mix version bump with other changes. + +#### Changelog Integration + +```markdown +## [1.2.0] - 2026-07-18 + +### Added +- New feature description + +### Fixed +- Bug fix description + +### Changed +- Breaking change description +``` + +**If no CHANGELOG.md exists:** Create one in the root. + +### Pull Request Creation + +**AFTER completing the task:** + +```markdown +## Changes +- [Brief list of what changed] +- [One item per significant change] + +## Testing +- `npm run build && pio run -e ulanzi_tc001` — must succeed +- [How to verify the changes work] +- [Commands to run or steps to follow] + +## User Prompt +[The original request from the user — verbatim] +``` + +**PR Title Format:** `[Component] Brief description` + +Examples: +- `[dpx_matrix] Add snow/frost overlay effects` +- `[dpx_matrix] Implement LaMetric icon rendering` +- `[WLED] Fix palette loader` + +**NEVER ask permission to create the PR — just do it.** + +--- + +## 2. Progress Tracking for Multi-Step Work + +When working on tasks that span **more than 3 files** OR **more than 30 minutes of work**: + +### Checkpoint Progress + +Provide a status update using this template: + +```markdown +## Progress Checkpoint + +✅ **Completed:** +- Item 1 description +- Item 2 description + +⬜ **Remaining:** +- Item 3 description +- Item 4 description + +→ **Next Action:** [Specific next step you will take] +``` + +### When to Checkpoint + +- After completing a logical phase of work +- Before switching to a different component +- When encountering a blocker or decision point +- Every 3-5 file edits in large refactors + +--- + +## 3. File Header Standards + +All code files must include a header comment: + +```cpp +// ================================================================================ +// dpx_matrix — [filename] — [brief purpose] +// ================================================================================ +// PROJECT: dpx_tc002_frm +// File: [filename.h] +// Purpose: [what this file does] +// Dependencies: [key dependencies] +// ================================================================================ +``` + +Use `//` for C++, `#` for Python/bash. Separator lines are 80 characters of `=`. + +--- + +## 4. Documentation Standards + +### Project Context Documentation + +Project-specific architecture lives in the **PROJECT section** above. Keep it current +when architecture changes. + +**When to use a separate context file:** Only if reference data becomes too large +(long tables, full API examples, hardware pinouts). If so, reference it from the +PROJECT section. + +### How to Document Project Context + +**DO:** +- ✅ Keep it clean, factual, and scannable +- ✅ Update when architecture changes +- ✅ Use tables, code blocks, and clear headings +- ✅ Write in present tense, authoritative voice + +**DON'T:** +- ❌ Append conversation transcripts +- ❌ Include timestamps like "On Feb 12 we discussed..." +- ❌ Make it a session log or diary +- ❌ Duplicate content from README.md (link instead) + +--- + +## 5. Core Principles + +### No Modifications to Working Code + +- Do not refactor, optimize, or "improve" code that is working unless explicitly requested +- If you see potential improvements, mention them but don't implement without approval + +### Comprehensive Commenting + +- Document all code with clear, meaningful comments +- Preserve existing comments unless they become obsolete +- Document WHY, not just WHAT + +### Small, Incremental Changes + +- Make one logical change per commit +- Break large tasks into smaller steps +- Test each change before moving to the next + +### Stay Focused + +- Complete the current task before suggesting next steps +- Answer only what is asked +- Don't anticipate or propose additional work unless requested + +--- + +## 6. Documentation Maintenance + +- `README.md` — user-facing; confirm changes with user before committing +- `CHANGELOG.md` — keep automated in background; confirm changes to existing line items +- `docs/HANDOFF_TODO.md` — update feature status whenever a roadmap item changes +- Inline comments — update when code changes; document WHY not WHAT + +--- + +## 7. Code Quality Guidelines + +- Clear, readable code with meaningful names +- Proper error handling — no bare `except:` or `catch` +- For C++: follow `docs/cpp.instructions.md` exactly +- For web UI: follow `docs/web.instructions.md` exactly +- For security: follow `docs/hardening.instructions.md` + `docs/securecode.instructions.md` +- For chip-specific code: follow `docs/esp-idf.instructions.md` + +--- + +## 8. Change Management + +### Commit Practices + +- **Commit message format:** Short, plain English, lowercase verb + - Examples: `add gif playback loop`, `fix overlay name mismatch`, `update handoff todo` +- Make one logical change per commit +- Commit functional units — don't commit broken code + +### Before Committing + +- `npm run build && pio run -e ulanzi_tc001` must succeed +- Update all relevant documentation +- Remove debug `Serial.print` / leftover `DEBUG_PRINTF` from development +- No credentials or secrets included + +### After Committing + +- Push to the feature branch +- Create PR per §1 template +- Include verification steps in PR description + +--- + +## 9. Collaboration Standards + +- Understand existing architecture before changing it +- Keep `wled00/` clean — dpx_matrix features stay in `usermods/dpx_matrix/` +- Suggest alternatives when appropriate, but don't insist +- Explain the reasoning behind suggested changes +- Be transparent about limitations or uncertainties + +--- + +## 10. Configuration & Secrets + +- Never commit `.env` files or credentials +- `platformio_override.ini` is gitignored — never commit it +- No hardcoded credentials or secrets +- If secrets are accidentally committed, notify the user immediately + +--- + +## Summary: Agent Checklist + +Before starting work: +- [ ] Read `dpx_reference/SPEC.md` + `dpx_reference/dpx_tc002.md` +- [ ] Read `docs/HANDOFF_TODO.md` for feature context +- [ ] Create feature branch from `main` +- [ ] Bump `VERSION` + `package.json`, commit separately + +While working: +- [ ] Custom code in `usermods/dpx_matrix/` — don't modify `wled00/` for display features +- [ ] `// AI:` markers on all AI-generated blocks +- [ ] `strlcpy`/`snprintf` with untrusted data — never `strcpy`/`sprintf` +- [ ] Validate HTTP/JSON input at ingress before use as lengths/indices +- [ ] Checkpoint progress if task spans >3 files or >30 min + +After completing work: +- [ ] `npm run build && pio run -e ulanzi_tc001` — must succeed +- [ ] Update `docs/HANDOFF_TODO.md` if a feature status changed +- [ ] Update `CHANGELOG.md` +- [ ] Create PR with build verification in testing section +- [ ] No credentials committed, no auto-generated headers committed + +--- + +*These standards ensure consistent, high-quality AI assistance on the dpx_tc002_frm project.* diff --git a/.github/ISSUE_TEMPLATE/bug-report---.md b/.github/ISSUE_TEMPLATE/bug-report---.md new file mode 100644 index 0000000000..1398711665 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report---.md @@ -0,0 +1,22 @@ +--- +name: "Bug report \U0001F41E" +about: Create a bug report +labels: bug + +--- + +## Describe the bug +* A clear and concise description of what the bug is. + +### Steps to reproduce +* Steps to reproduce the behavior. + +### Expected behavior +* A clear and concise description of what you expected to happen. + +### Environment + - OS: [e.g. Arch Linux] + - Other details that you think may affect. + +### Additional context +* Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature-request---.md b/.github/ISSUE_TEMPLATE/feature-request---.md new file mode 100644 index 0000000000..ea98d963a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request---.md @@ -0,0 +1,17 @@ +--- +name: "Feature request \U0001F680" +about: Suggest an idea +labels: enhancement + +--- + +## Summary +* Brief explanation of the feature. + +### Basic example +* Include a basic example or links here. Photos and diagrams are great! + +### Motivation +* _Why are we doing this?_ +* _What use cases does it support?_ +* _What is the expected outcome?_ \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..acc0efd6f9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## What does this PR do? + + + +## Why? + + + + +## How was it tested? + + + +## Screenshots / recordings + + + +## Anything to call out? + + + +## Checklist + +- [ ] Tested locally +- [ ] No debug/temp code left in +- [ ] Docs updated if needed \ No newline at end of file diff --git a/.gitignore b/.gitignore index a2f10883a6..bb9ea9c796 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,37 @@ wled-update.sh /wled00/wled00.ino.cpp /wled00/html_*.h /wled00/js_*.h +### DPX KICAD GIT IGNORE v0.5.4 +# Generic backup files +*~ +\#* +.\#* +# Any dir that ends in -old or some that start wit zz +**/*_old/** +**/zz_reference/** +**/ZZ_IGNORE/** +**/zz_archive/** +# other stuff +**/.DS_Store +*.gsheet +##########------ KICAD SPECIFIC STUFF ------###### +# KiCad backup files +*.bak +*.bck +*.kicad_pcb-bak +**/*.kicad_pcb-bak +**/*-backups/*.zip +**/*-backups/** +*-backups/** +**/template-inspiration/** +# KiCad generated files +*.erc +*.net +*-cache.lib +*-rescue.lib +*.lck +# all the footprints cache +fp-info-cache +*/fp-info-cache +# kicad local profile +*.kicad_prl diff --git a/AGENTS.md b/AGENTS.md index c1ce6a510f..79c9c54311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,6 +234,7 @@ No automated linting is configured. Match existing code style in files you edit. ## General Rules - Important: Repository language is **English**. This applies to source code (including comments), commit messages and any kind of documentation for developer or users. +- **Check if WLED already does it before implementing it in a usermod.** WLED has built-in handling for: buttons, NTP time sync, MQTT, HTTP API, OTA, effects, segments, presets, and more. Adding parallel implementations will fight WLED and cause double-actions, wrong state, and bugs. Always check `wled00/button.cpp`, `wled00/ntp.cpp`, `wled00/fcn_declare.h`, and the Usermod base class hooks (`handleButton()`, `connected()`, `onMqttConnect()`, etc.) before writing new handling code. Use WLED's internal APIs (`toggleOnOff()`, `stateUpdated()`, `localTime`, `hour()`, `minute()`) rather than reimplementing the same functionality. - The `docs/` folder is for developer/contributor information (coding conventions, architecture, etc.). User documentation is maintained in the [wled/WLED-Docs](https://github.com/wled/WLED-Docs) repository. - Never edit or commit auto-generated `wled00/html_*.h` / `wled00/js_*.h`. - When updating an existing PR, retain the original description. Only modify it to ensure technical accuracy. Add change logs after the existing description. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000000..296db05ff4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,111 @@ +# dpx_tc002 — Architecture & Decisions + +**Date:** 2026-07-17 +**Status:** Firmware repo pending setup + +--- + +## Repo Structure + +| Repo | What it is | +|------|------------| +| `dpx_tc002` | Product repo — hardware, docs, tools, this file | +| `dpx_tc002_frm` | Firmware — fork of `wled/WLED` (MIT), separate GitHub repo | +| `dpx_tc001/dpx_reference` | Reference only — AWTRIX-derived, do not copy license-unsafe files | + +`dpx_tc002_frm` is a clean fork of WLED mainline (`wled/WLED`, `main` branch). No nested git repos, no submodules. + +--- + +## Why WLED Fork (not AWTRIX) + +dpx_tc001 was a fork of AWTRIX 3 (CC BY-NC-SA 4.0) — that license blocks commercial use. +dpx_tc002_frm is a WLED fork (MIT) — commercially clean, no share-alike requirement. + +### Why mainline WLED and not MoonModules/WLED-MM + +MoonModules adds 2D effects and experimental features, but dpx_tc002 writes its own display layer on top. The extra effect catalog adds maintenance burden with no benefit. Mainline 2D matrix support is sufficient for a 32×8 pixel array. + +--- + +## What dpx_tc002_frm Will Contain + +Full WLED source with one custom usermod added: + +**`usermods/dpx_matrix/`** — original work, MIT: +- 5×7 pixel bitmap font (new — NOT the AWTRIX font) +- Text rendering + scrolling (`renderText`, `renderScroll`) +- App loop system (named display slots, auto-rotation timer) +- Notification queue (interrupts loop, auto-dismisses) +- OSC receiver on UDP port 4210 — d3 disguise integration, `/tc` timecode +- HTTP API matching dpx_tc001 contract (`/api/custom`, `/api/notify`, `/api/tc`, etc.) +- Web UI pages served from PROGMEM: `/ctrl`, `/browse`, `/api-ref` +- TC display with frame-accurate progress bar (two render modes) +- 3 indicator pixels (corner dots, addressable via `/api/indicator1/2/3`) +- dev.json persistence (temp offsets, LDR config, TC dwell settings) +- OSC Listener Registry (d3 monitoring path → display channel mappings) + +--- + +## What Gets Stripped / Disabled + +Handled via compile-time flags in `platformio.ini` — no source deletion needed: + +```ini +-D WLED_DISABLE_ALEXA +-D WLED_DISABLE_LOXONE +-D WLED_DISABLE_INFRARED +-D WLED_DISABLE_HUESYNC +-D WLED_DISABLE_ADALIGHT +-D WLED_DISABLE_ESPNOW +``` + +Most of the 100+ WLED effects can be pruned later if flash is tight. The WLED stock web UI (`wled00/data/`) is superseded by the custom pages served from the usermod — keep it in but redirect `/` to `/ctrl`. + +--- + +## Hardware Target — Ulanzi TC001 + +ESP32-WROOM-32D · 240MHz · 4MB flash (1.75MB app + 256KB LittleFS) · CH340 USB-serial + +| GPIO | Function | +|------|----------| +| 32 | LED matrix data (256× WS2812B, 32 cols × 8 rows) | +| 26 | Left button (active low) | +| 27 | Middle button (active low, inverted) | +| 14 | Right button (active low) | +| 15 | Passive buzzer — RTTTL via PWM | +| 34 | Battery ADC (read-only voltage divider) | +| 35 | LDR — auto-brightness (GL5516) | +| 21 / 22 | I²C SDA / SCL — SHT3x temp + humidity (addr 0x44) | +| 23 / 18 | DFPlayer Mini RX / TX (optional) | + +**Matrix layout:** row-major, left-to-right, top-to-bottom. Pixel index = `row * 32 + col`. + +--- + +## License Boundaries + +**Do NOT copy (AWTRIX-derived, CC BY-NC-SA 4.0):** +- `AwtrixFont.h` +- `MatrixDisplayUi.cpp` +- `MQTTManager.cpp` +- The `/setup` page from `htmls.h` + +**Safe to port (original work from dpx_tc001 session):** +- `tools/ltc_osc_bridge/` — copy verbatim, 100% original Python, MIT-clean +- `/ctrl`, `/browse`, `/api-ref` HTML pages from `htmls.h` +- OSC `handleOSC()` logic from `ServerManager.cpp` + +--- + +## Next Steps + +1. Fork `wled/WLED` → `dubpixel/dpx_tc002_frm` on GitHub, clone locally +2. Update `dpx_tc002.code-workspace` to include `dpx_tc002_frm` as the firmware folder +3. Verify WLED builds clean: `npm ci && npm run build && pio run -e esp32dev` +4. Create `usermods/dpx_matrix/` skeleton (header files only, empty stubs) +5. Configure `platformio.ini` env for Ulanzi TC001 with `custom_usermods = dpx_matrix` +6. Build Phase 1 — font + text rendering + +See `⊘ dpx_reference/dpx_tc002.md` for the full phased build plan (Phases 1–8). diff --git a/CHANGELOG.md b/CHANGELOG.md index f591fc2b2c..971c61c074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1626,3 +1626,62 @@ - Created changelog.md - make tracking changes to code easier - Merged pull request #766 by @pille: Fix E1.31 out-of sequence detection +# Changelog +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- +### Changed +- +### Deprecated +- +### Removed +- +### Fixed +- +### Security +- +--- +## [0.1.0] - YYYY-MM-DD +- Initial release +- Core functionality implementation +--- +## Version Guidelines +### Semantic Versioning (MAJOR.MINOR.PATCH) +- **MAJOR**: Breaking changes, incompatible API modifications +- **MINOR**: New features, backwards-compatible additions +- **PATCH**: Bug fixes, documentation updates, typos +### Change Categories +- **Added**: New features or capabilities +- **Changed**: Changes to existing functionality +- **Deprecated**: Features marked for future removal (still working) +- **Removed**: Removed features or functionality +- **Fixed**: Bug fixes +- **Security**: Security patches or vulnerability fixes +### Example Entry Format +```markdown +## [1.2.0] - 2026-03-15 +- New authentication system with JWT tokens +- Export functionality for CSV and JSON formats +- Dark mode toggle in user preferences +- Improved database query performance by 40% +- Updated UI library from v2.1 to v3.0 +- Fixed memory leak in background worker process +- Corrected timezone handling in date picker component +- Patched XSS vulnerability in user input validation +``` +### Version Comparison Links +Add these at the bottom of the file (replace with your repo owner/name): +[Unreleased]: https://github.com/owner/repo/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/owner/repo/releases/tag/v0.1.0 +--- +## Tips for Maintaining This Changelog +1. **Update as you work**: Add entries when making changes, not at release time +2. **Keep it scannable**: Use clear, concise descriptions +3. **Link to issues/PRs**: Include `(#123)` references when relevant +4. **Date format**: Use ISO 8601 (YYYY-MM-DD) +5. **Group by type**: Keep all Added items together, all Fixed items together, etc. +6. **User perspective**: Write what changed for users, not implementation details +7. **Unreleased section**: Keep active changes here, move to version section on release diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..e9931dedbf --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "github-pages", group: :jekyll_plugins +gem "jekyll-commonmark-ghpages" # This should work without needing a local build diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000..6e8bf73aa5 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000000..17f43ffb2c --- /dev/null +++ b/_config.yml @@ -0,0 +1,10 @@ +title: dpx_readme_template +description: a readme template +remote_theme: pages-themes/midnight@v0.2.0 +plugins: +- jekyll-remote-theme # add this line to the plugins list if you already have one +theme: jekyll-theme-minimal +markdown: kramdown +kramdown: + parse_block_html: true + diff --git a/docs/HANDOFF_TODO.md b/docs/HANDOFF_TODO.md new file mode 100644 index 0000000000..386e9a0c18 --- /dev/null +++ b/docs/HANDOFF_TODO.md @@ -0,0 +1,393 @@ +# dpx_tc002_frm — Master Roadmap & TODO + +Last updated: 2026-07-18 + +Reference files (read before working on this project): +- `⊘ dpx_reference/SPEC.md` — full behavioral spec, API contract, all JSON keys +- `⊘ dpx_reference/dpx_tc002.md` — firmware build plan, phase order, GPIO map +- `⊘ dpx_reference/dpx_tc002_server.md` — Friendster/CueMaster server plan + +--- + +## Quick Reference + +**MQTT topic for text:** `[PREFIX]/notify` → `{"text":"hello","rainbow":true,"duration":8}` +PREFIX = device hostname. Read from `GET /api/settings` → `MQTT_PREFIX`. + +**Server project (Friendster/CueMaster):** separate Python FastAPI repo, build after firmware is stable. +See Part 2 of this file. + +--- + +## Part 1 — Firmware (dpx_tc002_frm) + +### Phase 1 — Bug Fixes ✅ DONE (2026-07-18) + +- [x] **1.1** Overlay name case mismatch — overlay dropdowns in `/ctrl` sent uppercase (`"RAIN"`) but firmware matched lowercase. Fixed: JS option values now lowercase; firmware already does `toLowerCase()`. +- [x] **1.2** 5 missing overlay effects — `snow`, `drizzle`, `storm`, `thunder`, `frost` all implemented in `dpx_overlay.h` +- [x] **1.3** `icon` and `pushIcon` JSON fields not parsed — added to `DpxCustomApp` struct and `dpxParseApp()` in `dpx_apps.h`. Rendering is Phase 2. +- [x] **1.4** `deleteCustomApp()` JS bug — was calling `.filter()` on `{name:index}` object. Removed broken reorder logic; now just refreshes the loop list. +- [x] **1.5** Native app toggles (`TIM`/`DAT`) — `POST /api/settings` now handles `TIM` and `DAT` keys; `DPX_SHOW_TIME` / `DPX_SHOW_DATE` flags control `dpxRebuildLoop()`. +- [x] **1.6** `/api/transitions` stub — was returning `[]`; now returns `["fade","slide"]` +- [x] **1.7** `/api/sleep` not implemented — added ESP32 deep sleep with optional timer wakeup (`{"sleep":N}`) +- [x] **1.8** `save` flag — `save: true` in custom app JSON now persists to `/CUSTOMAPPS/.json` on LittleFS; delete also removes the file +- [x] **1.9** Per-app overlay activation — added hook in `handleOverlayDraw()` in `dpx_matrix.h`; overlay activates automatically when app/notification is shown, clears on transition + +--- + +### Phase 2 — Icon Rendering + +> **Key insight:** pre-convert PNG→raw in-browser using a Canvas at download time. No PNGdec library needed on device. + +- [ ] **2.1** Browser-side PNG→raw conversion `dpx_html.h` + - At download time in the icon browser, convert PNG to raw RGB888 using `` + - Upload as `.raw` to `/ICONS/` instead of PNG + - Update the "installed icons" dropdown loader to look for `.raw` files + +- [ ] **2.2** Icon load + render — new `dpx_icons.h` + - `dpxLoadIcon(name)` — reads `/ICONS/.raw`, returns 64-byte (8×8 RGB888) array + - `dpxRenderIcon(pixels, x, y)` — blits 8×8 to matrix at column x + +- [ ] **2.3** Text layout with icon `dpx_apps.h` + `dpx_text.h` + - When `icon` is set: render icon in cols 0–7, scroll/render text in cols 8–31 (24px wide) + - `pushIcon` modes: 0=icon fixed, 1=icon scrolls with text and disappears, 2=icon scrolls and loops + - Apply to both custom apps and notifications + +--- + +### Phase 3 — Animated GIF Playback + +> **First:** check if WLED's existing GIF engine (`wled00/image_loader.cpp`, `AnimatedGIF` library already linked via `-D WLED_ENABLE_GIF`) can be hooked directly — avoid writing a second GIF decoder. + +- [ ] **3.1** Investigate WLED GIF hook + - Can `image_loader.cpp` render a GIF to an arbitrary pixel buffer (not just WLED segments)? + - If yes, use it. If no, use `AnimatedGIF` library directly in `dpx_gif.h`. + +- [ ] **3.2** Frame extraction `dpx_apps.h` or new `dpx_gif.h` + - AWTRIX convention: 32×8 GIF = N frames of 8×8, laid out horizontally + - Slice frames by x-offset using GIF frame metadata + +- [ ] **3.3** Playback state machine `dpx_apps.h` + - Frame index, loop control, GIF frame delay timing + - Trigger: if `icon` field ends in `.gif`, enter GIF playback mode + +- [ ] **3.4** Render path + - GIF frame renders to cols 0–7 (same position as static icon) + - Text continues in cols 8–31 alongside + +--- + +### Phase 4 — Font Scaling / Larger Font + +> Check `wled00/src/fonts/` for existing GFX-format fonts before writing a new one. + +- [ ] **4.1** Second font `dpx_font.h` + - Add a 6×8 or 8×8 large font for close-viewing / big clock mode + - Look for GFX-format bitmap fonts already in the repo first + +- [ ] **4.2** Font selector `dpx_apps.h` + - New JSON field: `"font":"small"` (default 3×5) | `"font":"large"` (new 6×8) + - Or: `"textScale":2` for 2× pixel-doubling of existing font (simpler, no new font data) + +- [ ] **4.3** UI control `dpx_html.h` + - Add font size selector to Notification and Custom App cards + +--- + +### Phase 5 — Font Pixels as WLED Effects + +> Most architecturally complex item. Design the bounding-box layer carefully before building. + +- [ ] **5.1** Bounding box tracking `dpx_text.h` + - During glyph render, record `{x, y, w, h}` per character into a `CharBBox[]` out-param + - Only computed when `textEffect` field is present (no perf cost on normal renders) + +- [ ] **5.2** Per-region effect application `dpx_overlay.h` + - `dpxApplyEffectToRegion(effect, x, y, w, h)` — applies an effect only to pixels within bbox + - Use existing WLED effect functions on a local pixel sub-region + +- [ ] **5.3** JSON field + dispatch `dpx_apps.h` + - Add `"textEffect":"rainbow"` (global) or per-fragment in colored text arrays + - Map effect name strings to implementations + +--- + +### Phase 6 — cpt-city Gradient Integration + +> WLED already has 58+ cpt-city-sourced gradients in `wled00/palettes.cpp`. The custom palette system in `wled00/colors.cpp` → `loadCustomPalettes()` loads JSON from LittleFS. Palette editor at `/cpal/cpal.htm` already exists. + +- [ ] **6.1** Curated offline bundle + - Pick 20–30 popular cpt-city gradients NOT already in `wled00/palettes.cpp` + - Convert `.cpt` format → WLED gradient byte arrays via a Node.js build script in `tools/` + - Append to `wled00/palettes.cpp` alongside existing set + +- [ ] **6.2** Palette editor label `wled00/data/cpal/cpal.htm` (optional) + - Label the new cpt-city additions in a distinct group in the palette picker + +--- + +### Phase 7 — LaMetric Icon Generator Tool + +- [ ] **7.1** 8×8 pixel editor — new tab in `/browse` or standalone page + - Click-to-paint 8×8 grid canvas + - Color picker, fill, clear, eyedropper + - Export: saves as `.raw` to `/ICONS/` on device via `POST /edit` + +- [ ] **7.2** Generative / rule-based icons (stretch) + - Simple JS generators for common shapes: arrows, checkmarks, letters, numerals + - No external API needed for basic set + +--- + +### Phase 8 — Hardware Abstraction (Other Pixel Clocks) + +- [ ] **8.1** Hardware config header `dpx_hw.h` + - Pull GPIO pins, matrix W×H, button layout out of hardcoded values into one place + - Currently Ulanzi TC001 config is scattered across `platformio_override.ini` build flags and inline defines + +- [ ] **8.2** Alternative hardware profiles + - Ulanzi TC001: 32×8, GPIO32 data, 3 buttons (current — keep as default) + - Ulanzi TC004: different dimensions — define profile + - Generic ESP32 + WS2812B matrix: user-configurable size via `dev.json` + +--- + +## Part 2 — Server (friendster / CueMaster) + +Full spec: `⊘ dpx_reference/dpx_tc002_server.md` +Predecessor: `matrix-blast` service in `dpx_showsite_ops` (port 8090) + +**Build order:** firmware Phase 1 must be done first (already is). Server is a separate Python repo. + +**Tech stack:** Python 3.11+ · FastAPI · paho-mqtt · influxdb-client · Docker Compose +**Deployment:** new service in `dpx_showsite_ops/docker-compose.yml` on port 8091 + +### Phase S1 — Core Messaging + +- [ ] Create `friendster` repo + Docker service skeleton +- [ ] MQTT client subscribing to `+/presence` and `+/info` wildcards +- [ ] In-memory device registry + InfluxDB `friendster` measurement for persistence +- [ ] `GET /api/devices` — list with online/offline status +- [ ] `POST /api/send {"to":"name","text":"...","palette":"rainbow"}` — publishes to `[PREFIX]/notify` +- [ ] `POST /api/broadcast {"text":"..."}` — sends to all online device prefixes +- [ ] `GET /api/messages` — recent message history from InfluxDB +- [ ] `GET /sse/devices` — Server-Sent Events for live presence changes +- [ ] `GET /sse/messages` — SSE for live message feed +- [ ] **Friendster UI** — dark-themed buddy list + send form + SSE-driven updates + +### Phase S2 — CueMaster + +- [ ] Saved cues stored in YAML/JSON config file + - Schema: `{name, text, color/palette, targets[], duration}` +- [ ] Target groups (ALL, stage, foh, etc.) +- [ ] `POST /api/cue` — create/save named cue +- [ ] `GET /api/cues` — list saved cues +- [ ] `POST /api/cue/:name/fire` — fire a named cue +- [ ] **CueMaster UI** — device grid, large tap targets, quick-fire buttons, log panel +- [ ] Keyboard shortcuts (1-9 for quick cues, tablet-optimized) + +### Phase S3 — Integration + +- [ ] OSC receive (UDP) — `/friendster/cue/` fires named cue; allows QLab trigger +- [ ] Set Schedule hook — auto-fire cue on scene advance events from `dpx_showsite_ops` +- [ ] HTTP callback API — external systems can fire cues via REST +- [ ] Multi-site support — multiple broker connections + +### Phase S4 — Social Polish + +- [ ] Mobile-optimized Friendster UI +- [ ] PWA manifest + offline support +- [ ] Message reactions +- [ ] Presence icons / avatars per device + +--- + +## /ctrl Page Status (audit 2026-07-18) + +| Card | Status | Notes | +|------|--------|-------| +| Notification | ✅ Works | icon/pushIcon parsed but not rendered (Phase 2); overlays now functional | +| Custom App | ✅ Works | same icon note; deleteCustomApp fixed | +| Indicators | 🟡 Partial | `fade` field still ignored in backend handler | +| Moodlight | ✅ Works | — | +| Display | ✅ Works | — | +| App Channels | ✅ Works | — | +| OSC Listeners | ✅ Works | — | +| TC Settings | ✅ Works | — | +| Native Apps | 🟡 Partial | TIM/DAT toggle implemented; TEMP/HUM/BAT rendering not built yet | +| Time | ✅ Works | — | +| Sensors | ✅ Works | — | +| Sound | 🟡 Partial | SOUND/VOL settings keys are cosmetic (passive buzzer = no volume) | + +--- + +## 1 — cpt-city Gradient Integration + +**Status:** ❌ Not started + +**What it is:** +Load gradient palettes from the cpt-city archive (http://soliton.vm.bytemark.co.uk/pub/cpt-city/). +cpt-city `.cpt` files use a simple `x R G B` text format and cover hundreds of named +color gradients (terrain, scientific, artistic). + +**Current state:** +- WLED already has 59 hardcoded gradient palettes in `wled00/palettes.cpp` +- No references to cpt-city anywhere in the codebase +- `dpx_matrix` supports two-color gradient text (`gradient: [c1, c2]` JSON param) but + only for custom app text rendering, not WLED-wide palettes + +**Integration points:** +- `wled00/palettes.cpp` — add parsed cpt-city palettes alongside existing ones +- `wled00/data/cpal/cpal.htm` — existing palette editor; extend to preview cpt-city entries +- `usermods/dpx_matrix/dpx_text.h` — gradient text rendering already accepts color arrays + +**Approach options (choose one):** +- A) Bundle a curated offline set of popular `.cpt` files, converted to WLED palette format at build time +- B) Device fetches `.cpt` files from cpt-city CDN at runtime (requires HTTP client on device) + +--- + +## 2 — Bigtime Animated GIF Playback* + +**Status:** ❌ Stub — web UI tooling only, zero firmware implementation + +**What it is:** +Display 32×8 animated GIFs on the matrix. The AWTRIX convention stores animations as +a horizontal strip of 8×8 frames in a single GIF (e.g., 4 frames = 32×8 px image). + +**Current state:** +- `usermods/dpx_matrix/dpx_html.h` — web UI tab "Bigtime GIFs" (~220 lines): + - Fetches `.gif` filenames from the Blueforcer/AWTRIX3 GitHub repo + - Converts GIF → frame images in-browser + - Uploads to device LittleFS `/ANIMS/` folder +- **Zero firmware code**: no GIF decoder, no frame extraction, no playback loop, no render mode + +**What needs to be built:** +1. GIF decoder library for ESP32/Arduino (e.g., `AnimatedGIF` by Larry Bank — MIT license) +2. Frame extraction: slice 32×8 GIF into 8×8 frames by x-offset +3. Playback engine: frame timing, loop control, frame index state +4. New render mode `TMODE_GIF` in `usermods/dpx_matrix/dpx_apps.h` +5. Integration with custom app system (`icon` field accepting a `.gif` filename) + +**Files to modify:** +- `usermods/dpx_matrix/dpx_apps.h` — add GIF app type + playback state +- `usermods/dpx_matrix/dpx_text.h` — add GIF frame render path +- `usermods/dpx_matrix/dpx_html.h` — already handles upload side + +--- + +## 3 — LaMetric Icons on Matrix* + +**Status:** 🟡 Partial — icon browser + LittleFS download works; no display rendering + +**What it is:** +Fetch a named LaMetric icon (8×8 PNG from `developer.lametric.com`), decode it to +RGB pixels, and render it on the left 8 columns of the matrix alongside scrolling text. + +**Current state:** +- `usermods/dpx_matrix/dpx_html.h` — icon browser (~120 lines): + - Fetches thumbnails from `developer.lametric.com/content/apps/icon_thumbs/{id}_icon_thumb.png` + - Downloads full PNG to device `/ICONS/` LittleFS folder +- App JSON `"icon": "87"` field is parsed and stored +- **Zero firmware rendering**: no PNG decoder, no pixel extraction, icons never appear on display + +**What needs to be built:** +1. PNG decoder for ESP32 — options: + - `PNGdec` library by Larry Bank (MIT, ESP32-compatible) + - Pre-convert PNGs to raw RGB565/RGB888 at download time (browser-side, avoids on-device decode) +2. Icon render function: 8×8 RGB888 → left 8 columns of matrix +3. Text layout adjustment: when icon present, scroll text in columns 9–32 only +4. Wire into `usermods/dpx_matrix/dpx_apps.h` render loop + +**Files to modify:** +- `usermods/dpx_matrix/dpx_apps.h` — icon render call in app draw loop +- `usermods/dpx_matrix/dpx_html.h` — optionally pre-convert PNG→raw at download time +- New file: `usermods/dpx_matrix/dpx_icons.h` — icon load + render functions + +--- + +## 4 — Font Pixels as WLED LED Effects + +**Status:** ❌ Not started — requires architectural change to text renderer + +**What it is:** +Every pixel of every rendered font character behaves like a pixel in a WLED LED strip. +WLED effects (rainbow, sparkle, pulse, fire, etc.) can be applied per-character or +per-glyph, so text itself animates with the full WLED effect engine rather than just +being a static or single-color block. + +**Current architecture problem:** +`usermods/dpx_matrix/dpx_text.h` rasterizes glyphs directly into the final LED pixel +buffer as RGB888 values. There is no intermediate representation — no per-character +bounding boxes, no "which pixels belong to which glyph" metadata. + +**What needs to be built:** +1. **Bounding box tracking** in `dpx_text.h`: during render, record `{x, y, w, h}` for + each character drawn into a `CharBBox[]` array +2. **Per-character effect mask** in `dpx_overlay.h`: given a bounding box, run a WLED + effect only on those pixels (using a local pixel buffer slice) +3. **JSON field** in the custom app schema: e.g., `"textEffect": "rainbow"` per character + fragment in multi-color text arrays, or `"textEffect": "sparkle"` globally +4. **Effect hookup**: map effect names to existing WLED FX functions or re-implement + lightweight versions that work on arbitrary pixel sub-regions + +**Files to modify:** +- `usermods/dpx_matrix/dpx_text.h` — add bbox tracking output +- `usermods/dpx_matrix/dpx_overlay.h` — add per-region effect application +- `usermods/dpx_matrix/dpx_apps.h` — add `textEffect` JSON field + per-char effect dispatch + +--- + +## 5 — Text Overlay Effects* + +**Status:** 🟡 Partial — 5 effects implemented, 5 others unimplemented, 1 name-mismatch bug + +**What it is:** +Pixel-level effects rendered on top of (or composited with) displayed text. +Defined in the spec as 7 weather-themed overlays plus additional effects. + +**Currently implemented** (`usermods/dpx_matrix/dpx_overlay.h`): +- ✅ `rain` — column-based falling pixels +- ✅ `sparkle` — random pixel lighting +- ✅ `twinkle` — blended random pixels +- ✅ `strobe` — full-matrix flash +- ✅ `blink` — full matrix on/off + +**Not implemented (spec-required):** +- ❌ `snow` — slow drifting white pixels, accumulate at bottom +- ❌ `drizzle` — lighter version of rain (fewer, slower drops) +- ❌ `storm` — heavy rain + occasional flash +- ❌ `thunder` — periodic full-screen white flash +- ❌ `frost` — static blue-white pixel scatter over text + +**Bug — name mismatch:** +The effect name strings in `dpxRenderPixelEffect()` (API parser) do not match the +names documented in the web UI JSON schema (`dpx_html.h`). Fix both sides to agree. + +**Files to modify:** +- `usermods/dpx_matrix/dpx_overlay.h` — add 5 missing effects + fix name constants + +--- + +## 6 — Progress Bar* ✅ DONE + +**Status:** ✅ Fully implemented + +**Implementation:** +- `usermods/dpx_matrix/dpx_text.h` — `dpxDrawProgressBar()` function +- `usermods/dpx_matrix/dpx_apps.h` — JSON parsing + render call in app loop +- `usermods/dpx_matrix/dpx_tc.h` — reuses bar for timecode frame-progress display + +**JSON fields:** `progress` (int 0–100, -1 = hidden), `progressC` (fill color), `progressBC` (background color) + +**Behavior:** Renders in the bottom 2 rows of the 8-row matrix, fills left-to-right proportionally. + +--- + +## Implementation Priority (suggested order) + +1. **#5 Overlay effects** — lowest effort, high spec compliance impact; fix the name bug first +2. **#3 LaMetric icons** — browser-side PNG→raw conversion avoids on-device decoder complexity +3. **#2 Animated GIFs** — depends on choosing + integrating a GIF decoder library +4. **#1 cpt-city gradients** — standalone, no dependencies; start with offline bundle approach +5. **#4 Font pixel effects** — most architectural complexity; design the bbox layer carefully diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000000..648029df0c --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,416 @@ +# dpx_tc002_frm — Master Roadmap & TODO + +Last updated: 2026-07-18 + +Reference files (read before working on this project): +- `⊘ dpx_reference/SPEC.md` — full behavioral spec, API contract, all JSON keys +- `⊘ dpx_reference/dpx_tc002.md` — firmware build plan, phase order, GPIO map +- `⊘ dpx_reference/dpx_tc002_server.md` — Friendster/CueMaster server plan + +--- + +## Quick Reference + +**MQTT topic for text:** `[PREFIX]/notify` → `{"text":"hello","rainbow":true,"duration":8}` +PREFIX = device hostname. Read from `GET /api/settings` → `MQTT_PREFIX`. + +**Server project (Friendster/CueMaster):** separate Python FastAPI repo, build after firmware is stable. +See Part 2 of this file. + +--- + +## Part 1 — Firmware (dpx_tc002_frm) + +### Phase 1 — Bug Fixes ✅ DONE (2026-07-18) +- [x] **1.1** Overlay name case mismatch — overlay dropdowns in `/ctrl` sent uppercase (`"RAIN"`) but firmware matched lowercase. Fixed: JS option values now lowercase; firmware already does `toLowerCase()`. +- [x] **1.2** 5 missing overlay effects — `snow`, `drizzle`, `storm`, `thunder`, `frost` all implemented in `dpx_overlay.h` +- [x] **1.3** `icon` and `pushIcon` JSON fields not parsed — added to `DpxCustomApp` struct and `dpxParseApp()` in `dpx_apps.h`. Rendering is Phase 2. +- [x] **1.4** `deleteCustomApp()` JS bug — was calling `.filter()` on `{name:index}` object. Removed broken reorder logic; now just refreshes the loop list. +- [x] **1.5** Native app toggles (`TIM`/`DAT`) — `POST /api/settings` now handles `TIM` and `DAT` keys; `DPX_SHOW_TIME` / `DPX_SHOW_DATE` flags control `dpxRebuildLoop()`. +- [x] **1.6** `/api/transitions` stub — was returning `[]`; now returns `["fade","slide"]` +- [x] **1.7** `/api/sleep` not implemented — added ESP32 deep sleep with optional timer wakeup (`{"sleep":N}`) +- [x] **1.8** `save` flag — `save: true` in custom app JSON now persists to `/CUSTOMAPPS/.json` on LittleFS; delete also removes the file +- [x] **1.9** Per-app overlay activation — added hook in `handleOverlayDraw()` in `dpx_matrix.h`; overlay activates automatically when app/notification is shown, clears on transition +- [ ] **1.10** Serial config dump `dpx_matrix.h` + - Add `c` command to the serial debug handler that dumps key LittleFS config files to serial + - Print `/dev.json`, `/cfg.json` (WLED wifi/mqtt config), `/osc_listeners.json` — pretty-printed with section headers + - Format: `─── /dev.json ───` header, then indented JSON, then a separator line + - Also print active runtime globals (DPX_TIMEZONE, DPX_ATIME, DPX_SHOW_TIME, etc.) below the raw file + - Update help string: `s=status c=config dump r=reboot h=help` + +--- + +### Phase 1.5 — `/ctrl` UI Refactor `feature/ctrl-ui-refactor` + +> The current `/ctrl` page (`dpx_html.h`) is ~90 KB of ported AWTRIX HTML/JS that references +> AWTRIX endpoints which no longer exist. Nothing connects to the actual dpx_matrix API. +> Needs a complete replacement. + +**What to remove:** all AWTRIX-derived HTML in `dpx_html.h`; dead routes in `dpx_api.h`. + +**New `/ctrl` (vanilla JS, no framework, target <50 KB uncompressed):** + +- [ ] **1.5.1** App channel strip — `GET /dpx/apps` list; Go / Mute / Remove per row; add-back hidden natives; add custom app form +- [ ] **1.5.2** Notification panel — one-shot send + dismiss +- [ ] **1.5.3** Overlay / effects panel — text overlay fields; pixel effect picker + intensity +- [ ] **1.5.4** Device status strip — IP, SSID, RSSI, heap, uptime from `GET /dpx` +- [ ] **1.5.5** API reference — regenerated from actual routes/JSON keys; replaces AWTRIX stub + +--- + +### Phase 2 — Icon Rendering + +> **Key insight:** pre-convert PNG→raw in-browser using a Canvas at download time. No PNGdec library needed on device. + +- [ ] **2.1** Browser-side PNG→raw conversion `dpx_html.h` + - At download time in the icon browser, convert PNG to raw RGB888 using `` + - Upload as `.raw` to `/ICONS/` instead of PNG + - Update the "installed icons" dropdown loader to look for `.raw` files + +- [ ] **2.2** Icon load + render — new `dpx_icons.h` + - `dpxLoadIcon(name)` — reads `/ICONS/.raw`, returns 64-byte (8×8 RGB888) array + - `dpxRenderIcon(pixels, x, y)` — blits 8×8 to matrix at column x + +- [ ] **2.3** Text layout with icon `dpx_apps.h` + `dpx_text.h` + - When `icon` is set: render icon in cols 0–7, scroll/render text in cols 8–31 (24px wide) + - `pushIcon` modes: 0=icon fixed, 1=icon scrolls with text and disappears, 2=icon scrolls and loops + - Apply to both custom apps and notifications + +--- + +### Phase 3 — Animated GIF Playback + +> **First:** check if WLED's existing GIF engine (`wled00/image_loader.cpp`, `AnimatedGIF` library already linked via `-D WLED_ENABLE_GIF`) can be hooked directly — avoid writing a second GIF decoder. + +- [ ] **3.1** Investigate WLED GIF hook + - Can `image_loader.cpp` render a GIF to an arbitrary pixel buffer (not just WLED segments)? + - If yes, use it. If no, use `AnimatedGIF` library directly in `dpx_gif.h`. + +- [ ] **3.2** Frame extraction `dpx_apps.h` or new `dpx_gif.h` + - AWTRIX convention: 32×8 GIF = N frames of 8×8, laid out horizontally + - Slice frames by x-offset using GIF frame metadata + +- [ ] **3.3** Playback state machine `dpx_apps.h` + - Frame index, loop control, GIF frame delay timing + - Trigger: if `icon` field ends in `.gif`, enter GIF playback mode + +- [ ] **3.4** Render path + - GIF frame renders to cols 0–7 (same position as static icon) + - Text continues in cols 8–31 alongside + +--- + +### Phase 4 — Font Scaling / Larger Font + +> Check `wled00/src/fonts/` for existing GFX-format fonts before writing a new one. + +- [ ] **4.1** Second font `dpx_font.h` + - Add a 6×8 or 8×8 large font for close-viewing / big clock mode + - Look for GFX-format bitmap fonts already in the repo first + +- [ ] **4.2** Font selector `dpx_apps.h` + - New JSON field: `"font":"small"` (default 3×5) | `"font":"large"` (new 6×8) + - Or: `"textScale":2` for 2× pixel-doubling of existing font (simpler, no new font data) + +- [ ] **4.3** UI control `dpx_html.h` + - Add font size selector to Notification and Custom App cards + +--- + +### Phase 5 — Font Pixels as WLED Effects + +> Most architecturally complex item. Design the bounding-box layer carefully before building. + +- [ ] **5.1** Bounding box tracking `dpx_text.h` + - During glyph render, record `{x, y, w, h}` per character into a `CharBBox[]` out-param + - Only computed when `textEffect` field is present (no perf cost on normal renders) + +- [ ] **5.2** Per-region effect application `dpx_overlay.h` + - `dpxApplyEffectToRegion(effect, x, y, w, h)` — applies an effect only to pixels within bbox + - Use existing WLED effect functions on a local pixel sub-region + +- [ ] **5.3** JSON field + dispatch `dpx_apps.h` + - Add `"textEffect":"rainbow"` (global) or per-fragment in colored text arrays + - Map effect name strings to implementations + +--- + +### Phase 6 — cpt-city Gradient Integration + +> WLED already has 58+ cpt-city-sourced gradients in `wled00/palettes.cpp`. The custom palette system in `wled00/colors.cpp` → `loadCustomPalettes()` loads JSON from LittleFS. Palette editor at `/cpal/cpal.htm` already exists. + +- [ ] **6.1** Curated offline bundle + - Pick 20–30 popular cpt-city gradients NOT already in `wled00/palettes.cpp` + - Convert `.cpt` format → WLED gradient byte arrays via a Node.js build script in `tools/` + - Append to `wled00/palettes.cpp` alongside existing set + +- [ ] **6.2** Palette editor label `wled00/data/cpal/cpal.htm` (optional) + - Label the new cpt-city additions in a distinct group in the palette picker + +--- + +### Phase 7 — LaMetric Icon Generator Tool + +- [ ] **7.1** 8×8 pixel editor — new tab in `/browse` or standalone page + - Click-to-paint 8×8 grid canvas + - Color picker, fill, clear, eyedropper + - Export: saves as `.raw` to `/ICONS/` on device via `POST /edit` + +- [ ] **7.2** Generative / rule-based icons (stretch) + - Simple JS generators for common shapes: arrows, checkmarks, letters, numerals + - No external API needed for basic set + +--- + +### Phase 8 — Hardware Abstraction (Other Pixel Clocks) + +- [ ] **8.1** Hardware config header `dpx_hw.h` + - Pull GPIO pins, matrix W×H, button layout out of hardcoded values into one place + - Currently Ulanzi TC001 config is scattered across `platformio_override.ini` build flags and inline defines + +- [ ] **8.2** Alternative hardware profiles + - Ulanzi TC001: 32×8, GPIO32 data, 3 buttons (current — keep as default) + - Ulanzi TC004: different dimensions — define profile + - Generic ESP32 + WS2812B matrix: user-configurable size via `dev.json` + +--- + +## Part 2 — Server (friendster / CueMaster) + +Full spec: `⊘ dpx_reference/dpx_tc002_server.md` +Predecessor: `matrix-blast` service in `dpx_showsite_ops` (port 8090) + +**Build order:** firmware Phase 1 must be done first (already is). Server is a separate Python repo. + +**Tech stack:** Python 3.11+ · FastAPI · paho-mqtt · influxdb-client · Docker Compose +**Deployment:** new service in `dpx_showsite_ops/docker-compose.yml` on port 8091 + +### Phase S1 — Core Messaging + +- [ ] Create `friendster` repo + Docker service skeleton +- [ ] MQTT client subscribing to `+/presence` and `+/info` wildcards +- [ ] In-memory device registry + InfluxDB `friendster` measurement for persistence +- [ ] `GET /api/devices` — list with online/offline status +- [ ] `POST /api/send {"to":"name","text":"...","palette":"rainbow"}` — publishes to `[PREFIX]/notify` +- [ ] `POST /api/broadcast {"text":"..."}` — sends to all online device prefixes +- [ ] `GET /api/messages` — recent message history from InfluxDB +- [ ] `GET /sse/devices` — Server-Sent Events for live presence changes +- [ ] `GET /sse/messages` — SSE for live message feed +- [ ] **Friendster UI** — dark-themed buddy list + send form + SSE-driven updates + +### Phase S2 — CueMaster + +- [ ] Saved cues stored in YAML/JSON config file + - Schema: `{name, text, color/palette, targets[], duration}` +- [ ] Target groups (ALL, stage, foh, etc.) +- [ ] `POST /api/cue` — create/save named cue +- [ ] `GET /api/cues` — list saved cues +- [ ] `POST /api/cue/:name/fire` — fire a named cue +- [ ] **CueMaster UI** — device grid, large tap targets, quick-fire buttons, log panel +- [ ] Keyboard shortcuts (1-9 for quick cues, tablet-optimized) + +### Phase S3 — Integration + +- [ ] OSC receive (UDP) — `/friendster/cue/` fires named cue; allows QLab trigger +- [ ] Set Schedule hook — auto-fire cue on scene advance events from `dpx_showsite_ops` +- [ ] HTTP callback API — external systems can fire cues via REST +- [ ] Multi-site support — multiple broker connections + +### Phase S4 — Social Polish + +- [ ] Mobile-optimized Friendster UI +- [ ] PWA manifest + offline support +- [ ] Message reactions +- [ ] Presence icons / avatars per device + +--- + +## /ctrl Page Status (audit 2026-07-18) + +| Card | Status | Notes | +|------|--------|-------| +| Notification | ✅ Works | icon/pushIcon parsed but not rendered (Phase 2); overlays now functional | +| Custom App | ✅ Works | same icon note; deleteCustomApp fixed | +| Indicators | 🟡 Partial | `fade` field still ignored in backend handler | +| Moodlight | ✅ Works | — | +| Display | ✅ Works | — | +| App Channels | ✅ Works | — | +| OSC Listeners | ✅ Works | — | +| TC Settings | ✅ Works | — | +| Native Apps | 🟡 Partial | TIM/DAT toggle implemented; TEMP/HUM/BAT rendering not built yet | +| Time | ✅ Works | — | +| Sensors | ✅ Works | — | +| Sound | 🟡 Partial | SOUND/VOL settings keys are cosmetic (passive buzzer = no volume) | + +--- + +## 1 — cpt-city Gradient Integration + +**Status:** ❌ Not started + +**What it is:** +Load gradient palettes from the cpt-city archive (http://soliton.vm.bytemark.co.uk/pub/cpt-city/). +cpt-city `.cpt` files use a simple `x R G B` text format and cover hundreds of named +color gradients (terrain, scientific, artistic). + +**Current state:** +- WLED already has 59 hardcoded gradient palettes in `wled00/palettes.cpp` +- No references to cpt-city anywhere in the codebase +- `dpx_matrix` supports two-color gradient text (`gradient: [c1, c2]` JSON param) but + only for custom app text rendering, not WLED-wide palettes + +**Integration points:** +- `wled00/palettes.cpp` — add parsed cpt-city palettes alongside existing ones +- `wled00/data/cpal/cpal.htm` — existing palette editor; extend to preview cpt-city entries +- `usermods/dpx_matrix/dpx_text.h` — gradient text rendering already accepts color arrays + +**Approach options (choose one):** +- A) Bundle a curated offline set of popular `.cpt` files, converted to WLED palette format at build time +- B) Device fetches `.cpt` files from cpt-city CDN at runtime (requires HTTP client on device) + +--- + +## 2 — Bigtime Animated GIF Playback* + +**Status:** ❌ Stub — web UI tooling only, zero firmware implementation + +**What it is:** +Display 32×8 animated GIFs on the matrix. The AWTRIX convention stores animations as +a horizontal strip of 8×8 frames in a single GIF (e.g., 4 frames = 32×8 px image). + +**Current state:** +- `usermods/dpx_matrix/dpx_html.h` — web UI tab "Bigtime GIFs" (~220 lines): + - Fetches `.gif` filenames from the Blueforcer/AWTRIX3 GitHub repo + - Converts GIF → frame images in-browser + - Uploads to device LittleFS `/ANIMS/` folder +- **Zero firmware code**: no GIF decoder, no frame extraction, no playback loop, no render mode + +**What needs to be built:** +1. GIF decoder library for ESP32/Arduino (e.g., `AnimatedGIF` by Larry Bank — MIT license) +2. Frame extraction: slice 32×8 GIF into 8×8 frames by x-offset +3. Playback engine: frame timing, loop control, frame index state +4. New render mode `TMODE_GIF` in `usermods/dpx_matrix/dpx_apps.h` +5. Integration with custom app system (`icon` field accepting a `.gif` filename) + +**Files to modify:** +- `usermods/dpx_matrix/dpx_apps.h` — add GIF app type + playback state +- `usermods/dpx_matrix/dpx_text.h` — add GIF frame render path +- `usermods/dpx_matrix/dpx_html.h` — already handles upload side + +--- + +## 3 — LaMetric Icons on Matrix* + +**Status:** 🟡 Partial — icon browser + LittleFS download works; no display rendering + +**What it is:** +Fetch a named LaMetric icon (8×8 PNG from `developer.lametric.com`), decode it to +RGB pixels, and render it on the left 8 columns of the matrix alongside scrolling text. + +**Current state:** +- `usermods/dpx_matrix/dpx_html.h` — icon browser (~120 lines): + - Fetches thumbnails from `developer.lametric.com/content/apps/icon_thumbs/{id}_icon_thumb.png` + - Downloads full PNG to device `/ICONS/` LittleFS folder +- App JSON `"icon": "87"` field is parsed and stored +- **Zero firmware rendering**: no PNG decoder, no pixel extraction, icons never appear on display + +**What needs to be built:** +1. PNG decoder for ESP32 — options: + - `PNGdec` library by Larry Bank (MIT, ESP32-compatible) + - Pre-convert PNGs to raw RGB565/RGB888 at download time (browser-side, avoids on-device decode) +2. Icon render function: 8×8 RGB888 → left 8 columns of matrix +3. Text layout adjustment: when icon present, scroll text in columns 9–32 only +4. Wire into `usermods/dpx_matrix/dpx_apps.h` render loop + +**Files to modify:** +- `usermods/dpx_matrix/dpx_apps.h` — icon render call in app draw loop +- `usermods/dpx_matrix/dpx_html.h` — optionally pre-convert PNG→raw at download time +- New file: `usermods/dpx_matrix/dpx_icons.h` — icon load + render functions + +--- + +## 4 — Font Pixels as WLED LED Effects + +**Status:** ❌ Not started — requires architectural change to text renderer + +**What it is:** +Every pixel of every rendered font character behaves like a pixel in a WLED LED strip. +WLED effects (rainbow, sparkle, pulse, fire, etc.) can be applied per-character or +per-glyph, so text itself animates with the full WLED effect engine rather than just +being a static or single-color block. + +**Current architecture problem:** +`usermods/dpx_matrix/dpx_text.h` rasterizes glyphs directly into the final LED pixel +buffer as RGB888 values. There is no intermediate representation — no per-character +bounding boxes, no "which pixels belong to which glyph" metadata. + +**What needs to be built:** +1. **Bounding box tracking** in `dpx_text.h`: during render, record `{x, y, w, h}` for + each character drawn into a `CharBBox[]` array +2. **Per-character effect mask** in `dpx_overlay.h`: given a bounding box, run a WLED + effect only on those pixels (using a local pixel buffer slice) +3. **JSON field** in the custom app schema: e.g., `"textEffect": "rainbow"` per character + fragment in multi-color text arrays, or `"textEffect": "sparkle"` globally +4. **Effect hookup**: map effect names to existing WLED FX functions or re-implement + lightweight versions that work on arbitrary pixel sub-regions + +**Files to modify:** +- `usermods/dpx_matrix/dpx_text.h` — add bbox tracking output +- `usermods/dpx_matrix/dpx_overlay.h` — add per-region effect application +- `usermods/dpx_matrix/dpx_apps.h` — add `textEffect` JSON field + per-char effect dispatch + +--- + +## 5 — Text Overlay Effects* + +**Status:** 🟡 Partial — 5 effects implemented, 5 others unimplemented, 1 name-mismatch bug + +**What it is:** +Pixel-level effects rendered on top of (or composited with) displayed text. +Defined in the spec as 7 weather-themed overlays plus additional effects. + +**Currently implemented** (`usermods/dpx_matrix/dpx_overlay.h`): +- ✅ `rain` — column-based falling pixels +- ✅ `sparkle` — random pixel lighting +- ✅ `twinkle` — blended random pixels +- ✅ `strobe` — full-matrix flash +- ✅ `blink` — full matrix on/off + +**Not implemented (spec-required):** +- ❌ `snow` — slow drifting white pixels, accumulate at bottom +- ❌ `drizzle` — lighter version of rain (fewer, slower drops) +- ❌ `storm` — heavy rain + occasional flash +- ❌ `thunder` — periodic full-screen white flash +- ❌ `frost` — static blue-white pixel scatter over text + +**Bug — name mismatch:** +The effect name strings in `dpxRenderPixelEffect()` (API parser) do not match the +names documented in the web UI JSON schema (`dpx_html.h`). Fix both sides to agree. + +**Files to modify:** +- `usermods/dpx_matrix/dpx_overlay.h` — add 5 missing effects + fix name constants + +--- + +## 6 — Progress Bar* ✅ DONE + +**Status:** ✅ Fully implemented + +**Implementation:** +- `usermods/dpx_matrix/dpx_text.h` — `dpxDrawProgressBar()` function +- `usermods/dpx_matrix/dpx_apps.h` — JSON parsing + render call in app loop +- `usermods/dpx_matrix/dpx_tc.h` — reuses bar for timecode frame-progress display + +**JSON fields:** `progress` (int 0–100, -1 = hidden), `progressC` (fill color), `progressBC` (background color) + +**Behavior:** Renders in the bottom 2 rows of the 8-row matrix, fills left-to-right proportionally. + +--- + +## Implementation Priority (suggested order) + +1. **#5 Overlay effects** — lowest effort, high spec compliance impact; fix the name bug first +2. **#3 LaMetric icons** — browser-side PNG→raw conversion avoids on-device decoder complexity +3. **#2 Animated GIFs** — depends on choosing + integrating a GIF decoder library +4. **#1 cpt-city gradients** — standalone, no dependencies; start with offline bundle approach +5. **#4 Font pixel effects** — most architectural complexity; design the bbox layer carefully diff --git a/dpx_release_note_template.md b/dpx_release_note_template.md new file mode 100644 index 0000000000..d85149a706 --- /dev/null +++ b/dpx_release_note_template.md @@ -0,0 +1,24 @@ + +--- +## [1.0.0](https://github.com/dubpixel/projectname/compare/1.0.0...1.0.0. ) (releasedate) + +> Description + +### Upgrade Steps +* [ACTION REQUIRED] +* + +### Breaking Changes +* + +### New Features +* + +### Bug Fixes +* + +### Performance Improvements +* + +### Other Changes + diff --git a/dpx_tc002_frm.code-workspace b/dpx_tc002_frm.code-workspace new file mode 100644 index 0000000000..f5b8daea67 --- /dev/null +++ b/dpx_tc002_frm.code-workspace @@ -0,0 +1,24 @@ +{ + "folders": [ + { + "name": "dpx_tc002_frm", + "path": "." + }, + { + "name": "⊘ dpx_reference", + "path": "../dpx_tc001/dpx_reference" + }, + { + "name": "⊘ dpx_showsite_ops", + "path": "/Users/yourmom/Code/DPX_SHOWSITE_OPS" + } + ], + "settings": { + "files.readonlyInclude": { + "**/dpx_reference/**": true, + "**/WLED/**": true, + "**/dpx_showsite_ops/**": true + }, + "platformio-ide.activeProjectPioEnv": "ulanzi_tc001" + } +} \ No newline at end of file diff --git a/images/dubpixel_identicon.png b/images/dubpixel_identicon.png new file mode 100644 index 0000000000..b2309a8520 Binary files /dev/null and b/images/dubpixel_identicon.png differ diff --git a/images/front.png b/images/front.png new file mode 100644 index 0000000000..1b07d3c8b7 Binary files /dev/null and b/images/front.png differ diff --git a/images/front_render.png b/images/front_render.png new file mode 100644 index 0000000000..d38fbec63b Binary files /dev/null and b/images/front_render.png differ diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000000..3775920b70 Binary files /dev/null and b/images/logo.png differ diff --git a/images/logo_HF.png b/images/logo_HF.png new file mode 100644 index 0000000000..3cfe116b1d Binary files /dev/null and b/images/logo_HF.png differ diff --git a/images/pcb_front.png b/images/pcb_front.png new file mode 100644 index 0000000000..d62c49a33f Binary files /dev/null and b/images/pcb_front.png differ diff --git a/images/pcb_rear.png b/images/pcb_rear.png new file mode 100644 index 0000000000..4c97f3dbfc Binary files /dev/null and b/images/pcb_rear.png differ diff --git a/images/rear.png b/images/rear.png new file mode 100644 index 0000000000..077b3118b2 Binary files /dev/null and b/images/rear.png differ diff --git a/images/rear_render.png b/images/rear_render.png new file mode 100644 index 0000000000..2b7e4595af Binary files /dev/null and b/images/rear_render.png differ diff --git a/platformio_override.ini b/platformio_override.ini new file mode 100644 index 0000000000..983fb51343 --- /dev/null +++ b/platformio_override.ini @@ -0,0 +1,70 @@ +; ================================================================================ +; platformio_override.ini — dpx_tc002 / Ulanzi TC001 Build Config +; ================================================================================ +; Ulanzi TC001 hardware: +; ESP32-WROOM-32D · 240MHz · 4MB flash · CH340 USB-serial +; LED matrix: GPIO32, 256× WS2812B, 32×8 rows, row-major +; LDR: GPIO35 I²C: GPIO21/22 Buttons: GPIO26/27/14 +; +; Usage: +; pio run -e ulanzi_tc001 # build + flash via USB +; pio run -e ulanzi_tc001_ota # build + flash via WiFi OTA +; ================================================================================ + +; ── Shared hardware + build flags ───────────────────────────────────────────── +[dpx_tc001_common] +board = esp32dev +board_build.mcu = esp32 +board_build.f_cpu = 240000000L +custom_usermods = dpx_matrix +build_flags = + ${env:esp32dev.build_flags} + -D WLED_DISABLE_ALEXA + -D WLED_DISABLE_LOXONE + -D WLED_DISABLE_INFRARED + -D WLED_DISABLE_HUESYNC + -D WLED_DISABLE_ADALIGHT + -D WLED_DISABLE_ESPNOW + -D LEDPIN=32 + -D DEFAULT_LED_COUNT=256 + -D BTNPIN=26 + -D RLYPIN=-1 + -D IRPIN=-1 + -D STATUSPIN=-1 + -D DPX_BTN_LEFT=26 + -D DPX_BTN_MID=14 + -D DPX_BTN_RIGHT=27 + -D WLED_USE_LITTLEFS + -D WLED_NTP_ENABLED=true + -D WLED_VERSION='"dpx_tc002-dev"' + -D WLED_PRODUCT_NAME='"dpx_tc002"' + -D WLED_INSTANCE_NAME='"dpx_tc002"' +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +; ── USB flash ───────────────────────────────────────────────────────────────── +[env:ulanzi_tc001] +extends = env:esp32dev +board = ${dpx_tc001_common.board} +board_build.mcu = ${dpx_tc001_common.board_build.mcu} +board_build.f_cpu = ${dpx_tc001_common.board_build.f_cpu} +custom_usermods = ${dpx_tc001_common.custom_usermods} +build_flags = ${dpx_tc001_common.build_flags} +monitor_speed = ${dpx_tc001_common.monitor_speed} +monitor_filters = ${dpx_tc001_common.monitor_filters} +upload_speed = 460800 + +; ── OTA / WiFi flash ────────────────────────────────────────────────────────── +; OTA password: WLED → Config → Security & Updates (leave blank if unset) +[env:ulanzi_tc001_ota] +extends = env:esp32dev +board = ${dpx_tc001_common.board} +board_build.mcu = ${dpx_tc001_common.board_build.mcu} +board_build.f_cpu = ${dpx_tc001_common.board_build.f_cpu} +custom_usermods = ${dpx_tc001_common.custom_usermods} +build_flags = ${dpx_tc001_common.build_flags} +monitor_speed = ${dpx_tc001_common.monitor_speed} +monitor_filters = ${dpx_tc001_common.monitor_filters} +upload_protocol = espota +upload_port = 10.10.11.200 +; upload_flags = --auth=wledota diff --git a/readme.md b/readme.md index 762be9bd43..c55247e879 100644 --- a/readme.md +++ b/readme.md @@ -1,103 +1,297 @@ -

- - - - - - - - -

+ + -# Welcome to WLED! ✨ + -A fast and feature-rich firmware for ESP32 microcontrollers to control addressable LEDs — from simple strips to large 2D matrices and HUB75 panels. -Originally created by [Aircoookie](https://github.com/Aircoookie), now maintained by a community of contributors. -## ⚙️ Features + + + -### Effects & Visuals -- [**200+ built-in effects**](https://kno.wled.ge/features/effects/) including classic animations, audio-reactive, and 2D/matrix effects -- [50+ color palettes](https://kno.wled.ge/features/palettes/) plus a built-in **custom palette editor** (PixelForge) -- [**2D LED matrix support**](https://kno.wled.ge/advanced/mapping/) with dedicated 2D effects and flexible panel mapping -- [**HUB75 RGB matrix panel support**](https://kno.wled.ge/advanced/HUB75/) (ESP32) -- [**AudioReactive**](https://kno.wled.ge/advanced/audio-reactive/) effects — included by default, responding to sound via microphone, line-in, or network audio source -- Effect blending for smooth transitions between animations -- Antialiased drawing functions for smooth graphics + + + -### Segments & Control -- [**Segments**](https://kno.wled.ge/features/segments/) — apply different effects, colors and palettes to independent parts of your LED setup simultaneously -- Up to **250 presets** to save and recall colors, effects and segment configurations — supports [playlists](https://kno.wled.ge/features/presets/) for automated cycling -- Nightlight function with configurable dimming curve -- Configurable **Auto Brightness Limiter** (per output) for safe operation -### Hardware Support -- **ESP32** (all variants: original, S2, S3, C3) -- [**Up to 17 LED outputs**](https://kno.wled.ge/features/multi-strip/) on ESP32 using parallel I2S + RMT -- [Addressable LED support](https://kno.wled.ge/basics/compatible-led-strips/): WS2812B, WS2811, WS2815, SK6812, WS2805, TM1914, APA102, WS2801, LPD8806, and many more -- RGBW, [RGB+CCT](https://kno.wled.ge/features/cct/) and white-only strips -- PWM outputs for analog LEDs and dimmers -- [**Ethernet** support](https://kno.wled.ge/features/ethernet-lan/) for a wide range of boards (QuinLED, LILYGO, Olimex, and more) -- Filesystem-based config for easy backup and restore of presets and settings -- Full OTA firmware updates (HTTP + ArduinoOTA), password-protectable -### Connectivity & Integrations -- **WLED app** for [Android](https://play.google.com/store/apps/details?id=ca.cgagnier.wlednativeandroid) and [iOS](https://apps.apple.com/gb/app/wled-native/id6446207239) -- [JSON](https://kno.wled.ge/interfaces/json-api/) and [HTTP request](https://kno.wled.ge/interfaces/http-api/) APIs -- **Multi-WiFi** — connect to up to 3 networks with automatic AP fallback -- **ESP-NOW** wireless sync between devices (no WiFi router required) -- [**MQTT**](https://kno.wled.ge/interfaces/mqtt/) with Home Assistant discovery -- [**E1.31, Art-Net**](https://kno.wled.ge/interfaces/e1.31-dmx/), [DDP](https://kno.wled.ge/interfaces/ddp/) and [TPM2.net](https://kno.wled.ge/interfaces/udp-realtime/) for DMX/professional lighting control -- [UDP realtime sync](https://kno.wled.ge/interfaces/udp-notifier/) across multiple WLED devices -- Alexa voice control (on/off, brightness, color) -- [Philips Hue sync](https://kno.wled.ge/interfaces/philips-hue/) -- [diyHue](https://github.com/diyhue/diyHue) and [Hyperion](https://github.com/hyperion-project/hyperion.ng) integration -- [Adalight / TPM2](https://kno.wled.ge/interfaces/serial/) (PC ambilight via serial) -- [Infrared remote control](https://kno.wled.ge/interfaces/infrared/) (24-key RGB, receiver required) -- Timers and schedules (NTP time sync, full timezone and DST support) + + +
-### Developer-Friendly -- **Usermod system** — extend WLED with community or custom modules without modifying core code -- Large and active [usermod library](https://kno.wled.ge/advanced/community-usermods/) including AudioReactive, temperature sensors, rotary encoders, displays, and much more -- Well-documented [JSON API](https://kno.wled.ge/interfaces/json-api/) -- Licensed under the **EUPL v1.2** +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![License][license-shield]][license-url] +[![LinkedIn][linkedin-shield]][linkedin-url] +
+ +
+ + Logo + +

dpx_tc002_frm

+

a sassy project tag line here

+

+ ...a short description to tease interest +
+ » + Project Here! + » + BOM Here! + » + Interactive BOM Here! +
+ Report Bug + · + Request Feature +

+
+
+ +
+

Table of Contents

+
    +
  1. About The Project
  2. +
  3. Getting Started
  4. +
  5. First Boot Defaults
  6. +
  7. Usage
  8. +
  9. Roadmap
  10. +
  11. Contributing
  12. +
  13. License
  14. +
  15. Contact
  16. +
  17. Acknowledgments
  18. +
+
+ +
+

About The Project

-## 📲 Quick start guide and documentation +**dpx_tc002_frm** is a custom WLED firmware build for the [Ulanzi TC001](https://www.ulanzi.com/products/ulanzi-pixel-smart-clock-2882) pixel clock — a battery-powered ESP32 device with a 32×8 WS2812B LED matrix. It replaces the stock Awtrix firmware with a WLED base plus the `dpx_matrix` usermod, adding timecode display, OSC/MQTT control, scrolling text apps, pixel overlay effects, and first-boot hardware configuration. -See the [documentation at kno.wled.ge](https://kno.wled.ge)! +Built on top of [WLED](https://github.com/wled/WLED) (EUPL v1.2) — all WLED features remain fully functional. -[Tutorials and getting-started guides](https://kno.wled.ge/basics/tutorials/) to help you get your project running quickly. +### What WLED brings -## 🖼️ User interface +- **200+ built-in LED effects** including 2D/matrix effects, audio-reactive, and palettes +- **Segments** — independent effects/colors on parts of the strip simultaneously +- **Up to 250 presets** with playlist support +- **JSON + HTTP APIs**, MQTT with Home Assistant discovery, E1.31/Art-Net/DDP +- **Multi-WiFi** with automatic AP fallback, OTA firmware updates +- **NTP time sync** with full timezone + DST support +- Full [WLED documentation at kno.wled.ge](https://kno.wled.ge) +- [WLED mobile app](https://kno.wled.ge/basics/getting-started/) for Android and iOS - +### What dpx_matrix adds -## 💾 Compatible hardware +- Scrolling/static **text app loop** (Time, Date, custom apps) with AwtrixFont +- **Timecode display** (LTC via OSC) with frame progress bar +- **Notifications** — one-shot priority messages +- **Text overlay** + pixel effects (sparkle, strobe, rain, twinkle, blink) on top of any WLED effect +- **OSC receiver** (UDP 4210) — compatible with dpx_tc001 and AWTRIX OSC senders +- **MQTT** via WLED's broker connection +- **First-boot config** — device is usable out of the box, no wizard required -See the [compatible hardware list](https://kno.wled.ge/basics/compatible-hardware) on the wiki. +*author: [Joshua Fleitell](https://www.dubpixel.tv) — i@dubpixel.tv* -## ✌️ Other +> ⚠️ If you are prone to photosensitive epilepsy, avoid strobe/lightning effects and high speed settings. -Licensed under the [EUPL v1.2](https://raw.githubusercontent.com/wled-dev/WLED/main/LICENSE). -Credits to all [contributors](https://kno.wled.ge/about/contributors/)! -CORS proxy by [Corsfix](https://corsfix.com/). +### Images -Join the Discord server to discuss everything about WLED! +### FRONT +![FRONT][product-front] +### REAR +![REAR][product-rear] +### FRONT Rendering +![FRONT][product-front-rendering] +### REAR Rendering +![REAR][product-rear-rendering] +
+

(back to top)

- +### Built With -Check out the WLED [Discourse forum](https://wled.discourse.group)! +* [![WLED][WLED-badge]][WLED-url] +* [![PlatformIO][PlatformIO-badge]][PlatformIO-url] +* [![ESP32][ESP32-badge]][ESP32-url] +

(back to top)

+ -If you'd like to reach the original creator privately: [dev.aircoookie@gmail.com](mailto:dev.aircoookie@gmail.com). +## Getting Started -If WLED brightens up your day, you can [send a gift to Aircoookie via PayPal](https://paypal.me/aircoookie). + ### Prerequisites + * PlatformIO (VS Code extension or CLI) + * Node.js 20+ (`npm ci` before first build) ---- + ### Installation -*Disclaimer:* + 1. Clone the repo and open in VS Code + 2. `npm ci && npm run build` — generate web UI headers + 3. Hit **Upload** (PlatformIO sidebar → `ulanzi_tc001`) or run `pio run -t upload -e ulanzi_tc001` + 4. On first boot the device writes its own config — see [First Boot Defaults](#first-boot-defaults) below -If you are prone to photosensitive epilepsy, we recommend you do **not** use this software. -If you still want to try, avoid strobe, lightning or noise modes and high effect speed settings. +

(back to top)

-As per the EUPL license, no liability is assumed for any damage to you or any other person or equipment. + +## First Boot Defaults + +When you flash this firmware the device is **not stock WLED** — it boots into a pre-configured state. Here's what to expect: + +### Connecting for the first time + +1. Power the device +2. Look for WiFi network **`dpx-tc002`** (not `WLED-AP`) +3. Password: **`dubpixel1`** (not `wled1234`) +4. Connect and go to **`http://4.3.2.1`** in a browser +5. Set your WiFi network under **WiFi Setup** +6. The device joins your network and is accessible at **`http://dpx-tc002.local`** + +> This AP stays open indefinitely whenever the device is not connected to WiFi — no 5-minute timeout. + +### Default hardware config (written once on first boot) + +| Setting | Value | Notes | +|---|---|---| +| **AP SSID** | `dpx-tc002` | | +| **AP Password** | `dubpixel1` | | +| **mDNS** | `dpx-tc002.local` | | +| **LED GPIO** | 32 | | +| **LED Count** | 256 | 32×8 matrix | +| **LED Type** | WS2812B GRB | | +| **2D Panel** | 32×8, non-serpentine | Toggle serpentine in WLED → LED Preferences | +| **Buttons** | GPIO 26 / 14 / 27 | Configurable macros in WLED | +| **Transitions** | 0 ms | Instant — better for text | +| **WiFi** | Not saved | Configure on first connect | + +> Reflashing **preserves** your saved WiFi credentials. To reset to factory defaults, delete `/cfg.json` via WLED → File Manager, then reboot. + +### Control interfaces + +| Interface | How | +|---|---| +| **Web UI** | `http://dpx-tc002.local` | +| **OSC** | UDP 4210 — `/dpx/notify`, `/dpx/tc`, `/dpx/app/`, `/dpx/overlay`, `/dpx/effect` | +| **MQTT** | `{deviceTopic}/dpx/#` — same structure as OSC | +| **JSON** | `POST /json {"dpx":{...}}` — WLED standard JSON API | +| **Serial** | 115200 baud — prints IP on connect | + +

(back to top)

+ + +## Usage + +Send a notification via OSC: +``` +/dpx/notify {"text":"HELLO","color":"#FF0000","duration":5} +``` + +Send timecode via OSC (compatible with dpx_tc001 and AWTRIX senders): +``` +/dpx/tc 01:23:45:12 +``` + +Set a custom scrolling app via MQTT: +``` +{deviceTopic}/dpx/app/mylabel {"text":"LIVE","color":"#FF4400","duration":10} +``` + +Via WLED JSON API: +```json +POST /json +{"dpx": {"notify": {"text": "HELLO", "color": "#FF0000"}}} +``` + +See [First Boot Defaults](#first-boot-defaults) for all control interfaces. + +

(back to top)

+ + +## Roadmap + +- [ ] Button macros (prev/next app, dismiss notification, show IP) +- [ ] LDR-based auto brightness +- [ ] Temperature/humidity sensor display (I²C) +- [ ] Preset integration — trigger dpx apps from WLED presets +- [ ] 2D effect groups in web UI + +See the [open issues](https://github.com/dubpixel/dpx_tc002_frm/issues) for a full list of proposed features and known issues. + + +## Contributing + +_Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**._ + +If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". +Don't forget to give the project a star! Thanks again! + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +### Top contributors: + + contrib.rocks image + + + +## License + +This project is a fork of [WLED](https://github.com/wled/WLED) and is distributed under the **[EUPL v1.2](https://github.com/dubpixel/dpx_tc002_frm/blob/main/LICENSE)** (European Union Public Licence). + +Key points: +- **Source must be provided** with any distribution — this GitHub repo fulfils that requirement +- Derivative works must be licensed under EUPL or a [compatible license](https://joinup.ec.europa.eu/collection/eupl/matrix-eupl-compatible-open-source-licences) (GPL, LGPL, AGPL, MPL, etc.) +- Attribution to WLED contributors is required — see [kno.wled.ge/about/contributors](https://kno.wled.ge/about/contributors/) + +The `dpx_matrix` usermod is original work by dubpixel, but as part of this compiled firmware distribution it is covered by EUPL v1.2. + +AwtrixFont (`dpx_font.h`) is BSD 3-Clause — see file header for full attribution. + +## Contact + + ### Joshua Fleitell — i@dubpixel.tv + + Project Link: [https://github.com/dubpixel/dpx_tc002_frm](https://github.com/dubpixel/dpx_tc002_frm) + + +## Acknowledgments + +* [WLED](https://github.com/wled/WLED) — the firmware base. Originally by [Aircoookie](https://github.com/Aircoookie), now community-maintained. EUPL v1.2. +* [AwtrixFont](https://github.com/Blueforcer/awtrix3) — TomThumb-derived 3×5 pixel font by Blueforcer et al. BSD 3-Clause. +* [Ulanzi TC001](https://www.ulanzi.com/products/ulanzi-pixel-smart-clock-2882) — the hardware platform. + +

(back to top)

+ + +[contributors-shield]: https://img.shields.io/github/contributors/dubpixel/dpx_tc002_frm.svg?style=flat-square +[contributors-url]: https://github.com/dubpixel/dpx_tc002_frm/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/dubpixel/dpx_tc002_frm.svg?style=flat-square +[forks-url]: https://github.com/dubpixel/dpx_tc002_frm/network/members +[stars-shield]: https://img.shields.io/github/stars/dubpixel/dpx_tc002_frm.svg?style=flat-square +[stars-url]: https://github.com/dubpixel/dpx_tc002_frm/stargazers +[issues-shield]: https://img.shields.io/github/issues/dubpixel/dpx_tc002_frm.svg?style=flat-square +[issues-url]: https://github.com/dubpixel/dpx_tc002_frm/issues +[license-shield]: https://img.shields.io/github/license/dubpixel/dpx_tc002_frm.svg?style=flat-square +[license-url]: https://github.com/dubpixel/dpx_tc002_frm/blob/main/LICENSE +[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555 +[linkedin-url]: https://linkedin.com/in/jfleitell +[product-front]: images/front.png +[product-rear]: images/rear.png +[product-front-rendering]: images/front_render.png +[product-rear-rendering]: images/rear_render.png +[WLED-badge]: https://img.shields.io/badge/WLED-17.0.0--dev-blue?style=flat-square +[WLED-url]: https://github.com/wled/WLED +[PlatformIO-badge]: https://img.shields.io/badge/PlatformIO-ESP32-orange?style=flat-square +[PlatformIO-url]: https://platformio.org +[ESP32-badge]: https://img.shields.io/badge/ESP32--WROOM--32D-240MHz-green?style=flat-square +[ESP32-url]: https://www.espressif.com/en/products/socs/esp32 diff --git a/tools/ltc_osc_bridge/LTC_01000000_5mins_30_NDF_FPS_48000x16.wav b/tools/ltc_osc_bridge/LTC_01000000_5mins_30_NDF_FPS_48000x16.wav new file mode 100644 index 0000000000..aaa4a75643 Binary files /dev/null and b/tools/ltc_osc_bridge/LTC_01000000_5mins_30_NDF_FPS_48000x16.wav differ diff --git a/tools/ltc_osc_bridge/Launch LTC Bridge.bat b/tools/ltc_osc_bridge/Launch LTC Bridge.bat new file mode 100644 index 0000000000..ee430f3f0c --- /dev/null +++ b/tools/ltc_osc_bridge/Launch LTC Bridge.bat @@ -0,0 +1,5 @@ +@echo off +cd /d "%~dp0" +python -m pip install -r requirements.txt --quiet +python ltc_osc_bridge_gui.py +pause diff --git a/tools/ltc_osc_bridge/Launch LTC Bridge.command b/tools/ltc_osc_bridge/Launch LTC Bridge.command new file mode 100755 index 0000000000..14f3e959c8 --- /dev/null +++ b/tools/ltc_osc_bridge/Launch LTC Bridge.command @@ -0,0 +1,6 @@ +#!/bin/bash +# LTC → OSC Bridge — double-click launcher (macOS) +# Make executable once: chmod +x "Launch LTC Bridge.command" +cd "$(dirname "$0")" +python3 -m pip install -r requirements.txt --quiet +python3 ltc_osc_bridge_gui.py diff --git a/tools/ltc_osc_bridge/OSC_BRIDGE_HANDOFF.md b/tools/ltc_osc_bridge/OSC_BRIDGE_HANDOFF.md new file mode 100644 index 0000000000..20aa84f091 --- /dev/null +++ b/tools/ltc_osc_bridge/OSC_BRIDGE_HANDOFF.md @@ -0,0 +1,233 @@ +# LTC → OSC Bridge — Agent Handoff + +**Task:** Build a small cross-platform Python app that listens for LTC (Linear Timecode) on a system audio input, decodes it, and sends the decoded timecode string via OSC to a dpx_tc001 pixel clock device (or any OSC receiver). + +**Status:** Not started. Directory created. No code exists yet. + +--- + +## What It Needs To Do + +1. List available audio input devices so user can pick the right one +2. Capture audio from a selected input channel (LTC arrives as audio — typically on one channel of a stereo or multi-channel interface) +3. Decode the LTC biphase mark signal in real time +4. Format the timecode as `HH:MM:SS:FF` (or `HH:MM:SS,FF` for drop frame) +5. Send via OSC UDP to a configurable target IP/port/address +6. Print current TC to terminal so user can confirm it's working + +--- + +## Target OSC Receiver (dpx_tc001 firmware) + +The receiving device is an ESP32 pixel clock running custom firmware. OSC is received on **UDP port 4210**. + +Relevant OSC addresses: + +| Address | Args | Effect | +|---------|------|--------| +| `/dpx_tc001/custom/tc` | `(s)` string | Creates/updates a **persistent custom app** named `tc` that stays in the display rotation — best for ongoing TC display | +| `/dpx_tc001/notify` | `(s)` string | **One-shot notification** — pops up, scrolls, disappears | +| `/notify` | `(s)` string | Same as above, no namespace prefix | + +**Recommendation:** Use `/dpx_tc001/custom/tc` as the default address. The custom app persists and updates live. The display name is "tc" and it will scroll the timecode string continuously. + +The device also accepts `/awtrix/custom/tc` as a compatibility alias. + +**OSC packet format:** Standard OSC 1.0 over UDP. No TCP, no bundles needed. + +--- + +## LTC Technical Spec + +**LTC = Linear Timecode (SMPTE 12M)** + +LTC encodes SMPTE timecode as an audio signal using **biphase mark code (BMC)**: +- The signal is an audio waveform — looks like a square wave with varying pulse widths +- Bit encoding: + - There is ALWAYS a transition at the start of every bit period + - Bit = **1**: additional transition at the MID-point of the bit period + - Bit = **0**: NO mid-point transition +- Result: measuring zero-crossing gaps: + - **Short gap** (≈ T/2): part of a '1' bit (two shorts in a row = one '1') + - **Long gap** (≈ T): a '0' bit + +**Frame structure (80 bits, transmitted LSB-first within each field):** + +``` +Bits 0- 3: Frame units (BCD, 0-9) +Bits 4- 7: User bits 1 (4 bits, ignore) +Bits 8- 9: Frame tens (BCD, 0-2) +Bit 10: Drop frame flag +Bit 11: Color frame flag +Bits 12-15: User bits 2 +Bits 16-19: Seconds units (BCD, 0-9) +Bits 20-23: User bits 3 +Bits 24-26: Seconds tens (BCD, 0-5) +Bit 27: Unused +Bits 28-31: User bits 4 +Bits 32-35: Minutes units (BCD, 0-9) +Bits 36-39: User bits 5 +Bits 40-42: Minutes tens (BCD, 0-5) +Bit 43: Biphase correction bit +Bits 44-47: User bits 6 +Bits 48-51: Hours units (BCD, 0-9) +Bits 52-55: User bits 7 +Bits 56-57: Hours tens (BCD, 0-2) +Bits 58-63: Flags + user bits 8 +Bits 64-79: SYNC WORD = 0011 1111 1111 1101 (fixed pattern, MSB first) +``` + +**Sync word check:** +```python +SYNC_WORD = [0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,1] # bits[64:80] +# or as integer (bit64=MSB): int(''.join(str(b) for b in bits[64:80]), 2) == 0x3FFD +``` + +**BCD decode helper:** +```python +def bcd(bits_slice): + return sum(b << i for i, b in enumerate(bits_slice)) +``` + +**Frame decode:** +```python +frames = bcd(bits[0:4]) + bcd(bits[8:10]) * 10 +drop = bool(bits[10]) +secs = bcd(bits[16:20]) + bcd(bits[24:27]) * 10 +mins = bcd(bits[32:36]) + bcd(bits[40:43]) * 10 +hours = bcd(bits[48:52]) + bcd(bits[56:58]) * 10 + +tc_str = f"{hours:02d}:{mins:02d}:{secs:02d}{',' if drop else ':'}{frames:02d}" +``` + +**Common frame rates:** 24, 25, 29.97 (DF), 30 fps. Bit rate = fps × 80 bits/frame. +At 30fps on 48kHz audio: T ≈ 1600 samples/bit, T/2 ≈ 800 samples. + +**Auto-detection of bit period:** Track recent zero-crossing gaps. The shortest gaps cluster around T/2. A simple approach: keep a running minimum of recent gaps (with outlier rejection) as the T/2 estimate. + +--- + +## Biphase Mark Decoder Algorithm + +``` +State machine with two states: IDLE and HALF + +On each zero crossing, measure gap since last crossing: + threshold = 1.5 × estimated_half_period + + If gap < threshold (SHORT): + If state == IDLE: → set state = HALF (first half of a '1') + If state == HALF: → emit bit=1, set state = IDLE + + If gap >= threshold (LONG): + If state == HALF: → resync error, reset state = IDLE + If state == IDLE: → emit bit=0, stay IDLE + +After emitting each bit, check last 80 bits for sync word. +When sync found at position P: decode bits[P:P+80] as frame, clear buffer. +``` + +**Auto-calibrate half period:** Use the median or filtered minimum of recent short gaps. Update continuously. Handle both short and long gaps gracefully — don't break on occasional noise. + +--- + +## Libraries + +``` +sounddevice # cross-platform audio I/O via PortAudio +numpy # signal processing +python-osc # OSC UDP client +``` + +``` +pip install sounddevice numpy python-osc +``` + +`sounddevice` uses PortAudio which ships pre-built for macOS/Windows/Linux — no system dependencies needed. + +--- + +## CLI Interface + +``` +python ltc_osc_bridge.py --list-devices +python ltc_osc_bridge.py --target 192.168.1.100 --device 2 +python ltc_osc_bridge.py --target 192.168.1.100 --device 2 --channel 1 --port 4210 +python ltc_osc_bridge.py --target 192.168.1.100 --device 2 --address /custom/tc +``` + +**Arguments:** +- `--list-devices` / `-l` — print available audio inputs and exit +- `--device` / `-d` — device index from list (default: system default input) +- `--channel` / `-c` — which channel index to read LTC from (default: 0) +- `--target` / `-t` — target IP (default: `192.168.1.100`) +- `--port` / `-p` — OSC port (default: `4210`) +- `--address` / `-a` — OSC address (default: `/dpx_tc001/custom/tc`) +- `--rate` / `-r` — sample rate in Hz (default: `48000`) + +--- + +## Output Format + +Terminal should show the current timecode updating in place: +``` +dpx_tc001 LTC→OSC Bridge + Target : 192.168.1.100:4210 + OSC : /dpx_tc001/custom/tc + Device : [2] Dante Virtual Soundcard + Channel: 0 + +Listening... Press Ctrl+C to stop + + TC: 01:23:45:12 ← updates in place on same line +``` + +--- + +## Edge Cases to Handle + +1. **No signal / silence** — don't spam OSC. Only send when a valid frame is decoded. +2. **Signal dropout** — after N frames with no valid decode, optionally send a blank or stop sending. +3. **Wrong channel** — user picks the wrong channel. Should fail gracefully with a useful message. +4. **Frame rate detection** — don't require user to specify fps. Auto-detect from bit period measurement. Display detected fps in the header. +5. **Rate limiting** — don't send OSC faster than once per frame (e.g., max 30 sends/sec). The display only updates ~30fps anyway. +6. **Drop frame** — format correctly with `;` or `,` separator when drop flag is set. + +--- + +## Files to Create + +``` +tools/ltc_osc_bridge/ + ltc_osc_bridge.py ← main script, single file, no classes required + requirements.txt ← sounddevice, numpy, python-osc +``` + +Keep it a single Python file. No packaging, no setup.py, no complexity. It should run with `python ltc_osc_bridge.py` after `pip install -r requirements.txt`. + +--- + +## Test Without LTC Hardware + +To test the OSC sending without an actual LTC signal, add a `--test` flag that sends a fake incrementing timecode at 30fps: + +```python +if args.test: + f = 0 + while True: + tc = f"{(f//108000)%24:02d}:{(f//1800)%60:02d}:{(f//30)%60:02d}:{f%30:02d}" + osc.send_message(args.address, tc) + print(f"\r TC: {tc} ", end="", flush=True) + f += 1 + time.sleep(1/30) +``` + +--- + +## Context + +- This tool is a companion to the `dpx_tc001` pixel clock firmware (ESP32, Ulanzi TC001) +- OSC receiver is on UDP port 4210, same port as device discovery — coexists fine +- The `/dpx_tc001/custom/tc` app will display the timecode string, scrolling it across the 32×8 LED matrix +- The firmware also accepts `/dpx_tc001/notify` for one-shot pop-up display +- Future: this same tool should work as-is with a WLED-based device that has an OSC receiver on the same port diff --git a/tools/ltc_osc_bridge/ltc_osc_bridge.py b/tools/ltc_osc_bridge/ltc_osc_bridge.py new file mode 100644 index 0000000000..91e78db46b --- /dev/null +++ b/tools/ltc_osc_bridge/ltc_osc_bridge.py @@ -0,0 +1,896 @@ +#!/usr/bin/env python3 +""" +ltc_osc_bridge — SMPTE LTC → OSC Bridge +========================================= +Decodes Linear Timecode (LTC/SMPTE 12M) from a system audio input or a WAV +file and sends the decoded timecode string via OSC UDP. + +Usage: + python ltc_osc_bridge.py --list-devices + python ltc_osc_bridge.py --target 192.168.1.100 --device 2 + python ltc_osc_bridge.py --wav test.wav --no-osc + python ltc_osc_bridge.py --test --target 192.168.1.100 + +Install deps: + pip install -r requirements.txt +""" + +from __future__ import annotations + +__version__ = "1.0.0" + +import argparse +import json +import sys +import time +import urllib.request +import urllib.error +import wave +import struct +import threading +from collections import deque +from typing import Callable, Optional, List + +import numpy as np + +# ── Optional deps (gracefully absent) ───────────────────────────────────────── + +try: + from pythonosc.udp_client import SimpleUDPClient as _UDPClient + _HAS_OSC = True +except ImportError: # pragma: no cover + _HAS_OSC = False + +try: + import sounddevice as sd + _HAS_SD = True +except ImportError: # pragma: no cover + _HAS_SD = False + +# ── LTC / SMPTE constants ────────────────────────────────────────────────────── + +# Sync word: bits 64-79 of each 80-bit LTC frame, transmitted MSB-first. +# Binary: 0011 1111 1111 1101 (0x3FFD) +_SYNC: tuple = (0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1) + +# Common SMPTE frame rates for FPS snap detection +_COMMON_FPS = (24.0, 25.0, 29.97, 30.0) + +# ── TC string ↔ frame-count helpers ─────────────────────────────────────────── + +def _tc_to_frames(tc: str, fps: float) -> int: + """Parse HH:MM:SS:FF (or with ;/,) into an absolute frame count.""" + parts = tc.strip().replace(";", ":").replace(",", ":").split(":") + if len(parts) != 4: + raise ValueError(f"Invalid timecode: {tc!r} (expected HH:MM:SS:FF)") + h, m, s, f = (int(x) for x in parts) + fps_r = round(fps) + return (h * 3600 + m * 60 + s) * fps_r + f + + +def _frames_to_tc(n: int, fps: float, drop: bool = False) -> str: + """Convert absolute frame count to HH:MM:SS:FF string.""" + fps_r = round(fps) + fr = n % fps_r + ts = n // fps_r + s = ts % 60 + tm = ts // 60 + m = tm % 60 + h = tm // 60 + sep = ";" if drop else ":" + return f"{h:02d}:{m:02d}:{s:02d}{sep}{fr:02d}" + + +# ── BCD helpers ──────────────────────────────────────────────────────────────── + +def _bcd(bits: List[int], start: int, length: int) -> int: + """Decode BCD integer from LSB-first bit slice.""" + return sum(bits[start + i] << i for i in range(length)) + + +def _decode_frame(bits: List[int]) -> Optional[tuple]: + """ + Decode one 80-bit LTC frame. + Returns (tc_str, drop_frame) or None if invalid. + """ + if tuple(bits[64:80]) != _SYNC: + return None + fr = _bcd(bits, 0, 4) + _bcd(bits, 8, 2) * 10 + df = bool(bits[10]) + sc = _bcd(bits, 16, 4) + _bcd(bits, 24, 3) * 10 + mn = _bcd(bits, 32, 4) + _bcd(bits, 40, 3) * 10 + hr = _bcd(bits, 48, 4) + _bcd(bits, 56, 2) * 10 + # Sanity-check values + if fr > 29 or sc > 59 or mn > 59 or hr > 23: + return None + sep = ";" if df else ":" + return f"{hr:02d}:{mn:02d}:{sc:02d}{sep}{fr:02d}", df + +# ── LTC Decoder ──────────────────────────────────────────────────────────────── + +class LTCDecoder: + """ + Streaming biphase-mark LTC decoder. + + Feed mono float32 audio via .feed(). Each decoded frame is delivered to + the on_frame(tc_str: str, drop_frame: bool, fps: float | None) callback + from the calling thread. + + Thread-safety: not thread-safe by itself; use a single producer thread. + """ + + #: Number of zero-crossing gaps to collect before bootstrapping half-period. + #: 300 gaps ≈ 2+ full LTC frames, guaranteeing we capture the sync word's + #: 26 short gaps even in worst-case (mostly-zero timecode) data. + _BOOT_N: int = 300 + + #: EMA alpha for adaptive half-period tracking (smaller = slower/stabler). + _HP_ALPHA: float = 0.08 + + #: Maximum search window when hunting for sync (bits). Larger = more robust + #: initial lock, slightly more CPU when misaligned. + _SYNC_SEARCH_WINDOW: int = 32 + + def __init__(self, sample_rate: int = 48000, + on_frame: Optional[Callable] = None) -> None: + self.sample_rate = sample_rate + self.on_frame = on_frame or (lambda *_: None) + + # Adaptive half-period estimator (samples per half-bit-period) + self._hp: float = 0.0 + self._boot_gaps: List[float] = [] + + # Biphase-mark state machine: 0=IDLE, 1=HALF + self._bmc_state: int = 0 + + # Bit accumulator (plain list for fast appends + slicing) + self._buf: List[int] = [] + + # Absolute sample position of the last detected zero-crossing + self._last_cross: int = -1 + + # Sign of the last sample from the previous feed() call. + # Used to detect zero-crossings at chunk boundaries. + self._last_sign: int = 0 + + # Total samples consumed (used for FPS timing) + self._n: int = 0 + + # FPS estimation (ring buffer of frame arrival times in samples) + self._frame_times: deque = deque(maxlen=12) + + # Absolute position of the most-recently processed crossing. + # Updated in feed() before each _process_gap() call so that + # _record_frame() can use a precise timestamp instead of self._n. + self._cur_abs_pos: int = 0 + + # Public stats + self.fps: Optional[float] = None + self.frame_count: int = 0 + + # ── Half-period estimation ───────────────────────────────────────────────── + + def _bootstrap_hp(self) -> None: + """ + Bootstrap half-period from initial gap collection. + + Gaps form two clusters: short (≈T/2) and long (≈T = 2×T/2). + We find the minimum observed gap (≈T/2) and treat everything within + 60% of that as the short cluster, then take the median. + + This correctly handles worst-case LTC content (mostly-zero timecodes) + where short gaps may be a minority of the bootstrap window. + """ + arr = sorted(g for g in self._boot_gaps if g > 3) + if not arr: + return + # The minimum gap is T/2 (or very close). T = 2×T/2 so the two + # clusters are well-separated: short < 1.6×min, long ≈ 2×min. + min_g = arr[0] + shorts = [g for g in arr if g <= min_g * 1.6] + self._hp = float(np.median(shorts)) if shorts else float(min_g) + self._boot_gaps = [] + + def _update_hp(self, short_gap: float) -> None: + """Exponential moving-average update with a measured short gap.""" + self._hp = (1.0 - self._HP_ALPHA) * self._hp + self._HP_ALPHA * short_gap + + # ── Bit / frame processing ───────────────────────────────────────────────── + + def _push_bit(self, b: int) -> None: + self._buf.append(b) + if len(self._buf) >= 80: + self._search_sync() + + def _search_sync(self) -> None: + """ + Search the bit buffer for a valid LTC frame. + Scans up to _SYNC_SEARCH_WINDOW positions from the oldest end to + lock on quickly while staying O(1) in the steady state. + """ + buf = self._buf + blen = len(buf) + limit = blen - 79 # number of possible frame start positions + + # In steady state (aligned), the frame starts at position 0. + # We allow a small window for cases where a few extra bits accumulated + # before the previous decode (noise, startup misalignment). + for i in range(min(limit, self._SYNC_SEARCH_WINDOW)): + if tuple(buf[i + 64 : i + 80]) == _SYNC: + result = _decode_frame(buf[i : i + 80]) + if result is not None: + tc, df = result + self._on_valid_frame(tc, df) + del buf[: i + 80] + return + + # No sync found in search window; trim to keep last 79 bits so the + # next incoming bit can complete a frame without carrying stale data. + if blen > 200: + del buf[:-79] + + def _on_valid_frame(self, tc: str, df: bool) -> None: + self.frame_count += 1 + self._frame_times.append(self._cur_abs_pos) # precise crossing position + self._estimate_fps() + self.on_frame(tc, df, self.fps) + + def _estimate_fps(self) -> None: + ft = self._frame_times + if len(ft) < 3: + return + intervals = [ft[k] - ft[k - 1] for k in range(1, len(ft))] + avg = float(np.mean(intervals)) + if avg <= 0: + return + raw = self.sample_rate / avg + best = min(_COMMON_FPS, key=lambda f: abs(raw - f)) + if abs(raw - best) < 1.5: + self.fps = best + + # ── Gap / BMC logic ──────────────────────────────────────────────────────── + + def _process_gap(self, gap: int) -> None: + """Handle one measured inter-crossing gap (in samples).""" + # ── Bootstrapping ────────────────────────────────────────────────────── + if self._hp == 0.0: + if 2 < gap < 8000: + self._boot_gaps.append(gap) + if len(self._boot_gaps) >= self._BOOT_N: + self._bootstrap_hp() + return + + # ── Classify gap ─────────────────────────────────────────────────────── + threshold = self._hp * 1.5 # boundary between T/2 and T + + if gap < threshold: + # ── Short gap (≈ T/2): part of a '1' bit ────────────────────────── + self._update_hp(gap) + if self._bmc_state == 0: # IDLE → HALF (first half of '1') + self._bmc_state = 1 + else: # HALF → emit '1', back to IDLE + self._push_bit(1) + self._bmc_state = 0 + + elif gap < threshold * 2.5: + # ── Long gap (≈ T): a '0' bit ───────────────────────────────────── + if self._bmc_state == 1: # unexpected long after half → resync + self._bmc_state = 0 + else: + self._push_bit(0) + + else: + # ── Very long gap: dropout / silence ────────────────────────────── + self._bmc_state = 0 + + # ── Public interface ─────────────────────────────────────────────────────── + + def feed(self, samples: np.ndarray) -> None: + """ + Process a 1-D float32 mono numpy array. + May be called from an audio callback or any single thread. + """ + if samples.ndim != 1: + raise ValueError("LTCDecoder.feed() expects a 1-D mono array") + + # Sign array: +1 / -1; treat exact-zero samples as positive (rare in + # real audio; a 1-sample timing offset at T/2=10 samples is harmless). + signs = np.sign(samples).astype(np.int8) + signs[signs == 0] = 1 + + # ── Boundary crossing detection ────────────────────────────────────── + # np.diff() cannot see across successive feed() calls. If the LTC + # signal has a zero-crossing exactly at the chunk boundary (i.e., the + # last sample of the previous call and the first sample of this call + # have opposite signs), we must detect it here explicitly. + if self._last_sign != 0 and signs[0] != self._last_sign: + abs_pos = self._n # first sample of this chunk = boundary position + self._cur_abs_pos = abs_pos + if self._last_cross < 0: + self._last_cross = abs_pos + else: + self._process_gap(abs_pos - self._last_cross) + self._last_cross = abs_pos + + # Store last sign before we overwrite it below + self._last_sign = int(signs[-1]) + + # ── Internal zero-crossings ────────────────────────────────────────── + crossings = np.where(np.diff(signs) != 0)[0] + for idx in crossings: + abs_pos = self._n + int(idx) + 1 + self._cur_abs_pos = abs_pos + if self._last_cross < 0: + self._last_cross = abs_pos + else: + self._process_gap(abs_pos - self._last_cross) + self._last_cross = abs_pos + + self._n += len(samples) + + def reset(self) -> None: + """Reset all decoder state (e.g., after detected signal loss).""" + self._hp = 0.0 + self._boot_gaps = [] + self._bmc_state = 0 + self._buf = [] + self._last_cross = -1 + self._last_sign = 0 + self.fps = None + +# ── OSC sender ───────────────────────────────────────────────────────────────── + +class OSCSender: + """Thread-safe OSC UDP sender with frame-rate limiting.""" + + def __init__(self, host: str, port: int, address: str) -> None: + if not _HAS_OSC: + raise RuntimeError( + "python-osc not installed. Run: pip install python-osc" + ) + self._client = _UDPClient(host, port) + self._address = address + self._lock = threading.Lock() + self._last_sent: float = 0.0 + self._min_interval: float = 1.0 / 32 # ≤ 32 sends / sec + + def send(self, tc_str: str) -> None: + now = time.monotonic() + with self._lock: + if now - self._last_sent < self._min_interval: + return + self._last_sent = now + try: + self._client.send_message(self._address, tc_str) + except OSError: + pass # network unreachable — don't crash the audio thread + +# ── Device listing ───────────────────────────────────────────────────────────── + +def list_devices() -> None: + if not _HAS_SD: + _die("sounddevice not installed. Run: pip install sounddevice") + devices = sd.query_devices() + print("\nAvailable audio input devices:\n") + for i, dev in enumerate(devices): + if dev["max_input_channels"] < 1: + continue + sr = int(dev["default_samplerate"]) + ch = dev["max_input_channels"] + print(f" [{i:3d}] {dev['name']}") + print(f" {ch} in · {sr} Hz default") + print() + +# ── WAV file mode ────────────────────────────────────────────────────────────── + +def run_wav(path: str, args: argparse.Namespace, + osc: Optional[OSCSender]) -> None: + """Decode LTC from a WAV file and optionally send via OSC.""" + try: + wf = wave.open(path, "rb") + except FileNotFoundError: + _die(f"WAV file not found: {path}") + except wave.Error as e: + _die(f"Cannot open WAV file: {e}") + + n_ch = wf.getnchannels() + sampw = wf.getsampwidth() + rate = wf.getframerate() + n_samp = wf.getnframes() + dur = n_samp / rate + + if args.channel >= n_ch: + _die(f"WAV has {n_ch} channel(s); --channel {args.channel} is out of range.") + + dtype_map = {1: np.int8, 2: np.int16, 4: np.int32} + if sampw not in dtype_map: + _die(f"Unsupported WAV sample width: {sampw} bytes") + + dtype = dtype_map[sampw] + max_val = float(2 ** (sampw * 8 - 1)) + + print(f"\n File : {path}") + print(f" Rate : {rate} Hz · {n_ch}ch · {sampw*8}-bit · {dur:.1f} s") + print(f" Channel: {args.channel}") + if osc: + print(f" OSC : {args.target}:{args.port} {args.address}") + print() + + last_tc: list = [None] + unique_count: list = [0] + start_time = time.monotonic() + + def on_frame(tc: str, df: bool, fps: Optional[float]) -> None: + if tc != last_tc[0]: + last_tc[0] = tc + unique_count[0] += 1 + fps_str = f" @{fps:.2f} fps" if fps else "" + print(f"\r TC: {tc}{fps_str} ", end="", flush=True) + if osc: + osc.send(tc) + + dec = LTCDecoder(sample_rate=rate, on_frame=on_frame) + + CHUNK = 4096 + try: + while True: + raw = wf.readframes(CHUNK) + if not raw: + break + interleaved = np.frombuffer(raw, dtype=dtype) + # De-interleave: select the chosen channel + mono = interleaved[args.channel::n_ch].astype(np.float32) / max_val + dec.feed(mono) + except KeyboardInterrupt: + pass + finally: + wf.close() + + elapsed = time.monotonic() - start_time + print(f"\n\n Done in {elapsed:.1f}s. " + f"{unique_count[0]} unique timecodes decoded, " + f"{dec.frame_count} frames total.") + if dec.fps: + print(f" Detected FPS: {dec.fps}") + +# ── Direct OSC send mode + d3 showcontrol ───────────────────────────────────── + +# disguise d3 zero-argument trigger paths +_D3_TRIGGERS: dict = { + "d3_play": "/d3/showcontrol/play", + "d3_stop": "/d3/showcontrol/stop", + "d3_loop": "/d3/showcontrol/loop", + "d3_playsection": "/d3/showcontrol/playsection", + "d3_nextsection": "/d3/showcontrol/nextsection", + "d3_previoussection": "/d3/showcontrol/previoussection", + "d3_nexttrack": "/d3/showcontrol/nexttrack", + "d3_previoustrack": "/d3/showcontrol/previoustrack", + "d3_returntostart": "/d3/showcontrol/returntostart", + "d3_hold": "/d3/showcontrol/hold", + "d3_fadeup": "/d3/showcontrol/fadeup", + "d3_fadedown": "/d3/showcontrol/fadedown", +} +_D3_VALUES = ("d3_volume", "d3_brightness", "d3_trackname", "d3_trackid", + "d3_cue", "d3_floatcue") + +# disguise d3 monitoring output paths (§7.3) — sent FROM d3 TO device +# Tuple: (osc_path, suggested_channel, label) +_D3_MONITORING: dict = { + "timecodeposition": ("/d3/showcontrol/timecodeposition", "tc", "Timecode"), + "trackposition": ("/d3/showcontrol/trackposition", "d3_pos", "Track Position"), + "trackname": ("/d3/showcontrol/trackname", "d3_name", "Track Name"), + "trackid": ("/d3/showcontrol/trackid", "d3_id", "Track ID"), + "playmode": ("/d3/showcontrol/playmode", "d3_mode", "Play Mode"), + "currentsectionname": ("/d3/showcontrol/currentsectionname","d3_sec", "Current Section"), + "nextsectionname": ("/d3/showcontrol/nextsectionname", "d3_nsec", "Next Section"), + "sectionhint": ("/d3/showcontrol/sectionhint", "d3_hint", "Section Hint"), + "volume": ("/d3/showcontrol/volume", "d3_vol", "Volume"), + "brightness": ("/d3/showcontrol/brightness", "d3_bri", "Brightness"), + "bpm": ("/d3/showcontrol/bpm", "d3_bpm", "BPM"), + "heartbeat": ("/d3/showcontrol/heartbeat", "d3_hb", "Heartbeat"), +} + + +def _any_d3(args: argparse.Namespace) -> bool: + return (any(getattr(args, k, False) for k in _D3_TRIGGERS) or + any(getattr(args, k, None) is not None for k in _D3_VALUES)) + + +def run_d3(args: argparse.Namespace) -> None: + """Send a disguise Designer showcontrol OSC command and exit.""" + if not _HAS_OSC: + _die("python-osc not installed. Run: pip install python-osc") + from pythonosc.udp_client import SimpleUDPClient + client = SimpleUDPClient(args.target, args.d3_port) + + def _send(path: str, value=None) -> None: + client.send_message(path, value) + val_str = f" {value!r}" if value is not None else "" + print(f" d3 → {args.target}:{args.d3_port}") + print(f" {path}{val_str}") + + # Zero-argument triggers + for dest, path in _D3_TRIGGERS.items(): + if getattr(args, dest, False): + _send(path) + return + + # Value commands + if args.d3_volume is not None: + _send("/d3/showcontrol/volume", max(0.0, min(1.0, float(args.d3_volume)))) + elif args.d3_brightness is not None: + _send("/d3/showcontrol/brightness", max(0.0, min(1.0, float(args.d3_brightness)))) + elif args.d3_trackname is not None: + _send("/d3/showcontrol/trackname", args.d3_trackname) + elif args.d3_trackid is not None: + _send("/d3/showcontrol/trackid", args.d3_trackid) + elif args.d3_cue is not None: + ints = [int(x) for x in args.d3_cue[:3]] + client.send_message("/d3/showcontrol/cue", ints[0] if len(ints) == 1 else ints) + print(f" d3 → {args.target}:{args.d3_port}") + print(f" /d3/showcontrol/cue {'.' .join(str(i) for i in ints)}") + elif args.d3_floatcue is not None: + _send("/d3/showcontrol/floatcue", float(args.d3_floatcue)) + else: + _die("No d3 command given — use --d3-play, --d3-volume, etc. (--help)") + + +# ── d3 monitoring listener registry (HTTP) ──────────────────────────────────── + +def _listener_api(args: argparse.Namespace, method: str, body: Optional[dict] = None) -> str: + """Call the device's /api/osc/listeners HTTP endpoint.""" + url = f"http://{args.target}:{args.http_port}/api/osc/listeners" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json"} if data else {}, + method=method, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.read().decode() + except urllib.error.URLError as exc: + _die(f"Device HTTP error ({url}): {exc.reason}") + return "" + + +def run_listener_cmds(args: argparse.Namespace) -> None: + """Handle --d3-list-listeners / --d3-add-listener / --d3-remove-listener / --d3-preset.""" + prefix = f" device → http://{args.target}:{args.http_port}" + + if args.d3_list_listeners: + raw = _listener_api(args, "GET") + listeners: list = json.loads(raw) if raw else [] + if not listeners: + print(f"{prefix}\n No listeners registered.") + else: + print(f"{prefix}") + print(f" {'OSC Path':<47} {'Channel':<15} Label") + print(" " + "─" * 76) + for lsr in listeners: + print(f" {lsr['path']:<47} {lsr['channel']:<15} {lsr.get('label','')}") + return + + if args.d3_add_listener: + parts = args.d3_add_listener + if len(parts) < 2: + _die("--d3-add-listener requires PATH CHANNEL [LABEL]") + path, channel = parts[0], parts[1] + label = parts[2] if len(parts) > 2 else channel + _listener_api(args, "POST", {"path": path, "channel": channel, "label": label}) + print(f"{prefix}\n Added : {path} → {channel} ({label})") + return + + if args.d3_remove_listener: + _listener_api(args, "DELETE", {"path": args.d3_remove_listener}) + print(f"{prefix}\n Removed : {args.d3_remove_listener}") + return + + if args.d3_preset: + if args.d3_preset == "all": + items = list(_D3_MONITORING.values()) + elif args.d3_preset in _D3_MONITORING: + items = [_D3_MONITORING[args.d3_preset]] + else: + names = ", ".join(_D3_MONITORING.keys()) + _die(f"Unknown preset '{args.d3_preset}'. Choose: all, {names}") + print(f"{prefix}") + for path, channel, label in items: + _listener_api(args, "POST", {"path": path, "channel": channel, "label": label}) + print(f" Registered: {path} → {channel} ({label})") + return + + +def _any_listener_cmd(args: argparse.Namespace) -> bool: + return any([ + getattr(args, "d3_list_listeners", False), + getattr(args, "d3_add_listener", None) is not None, + getattr(args, "d3_remove_listener",None) is not None, + getattr(args, "d3_preset", None) is not None, + ]) + + +def run_send(args: argparse.Namespace, osc: Optional["OSCSender"]) -> None: + """Send a single OSC string immediately and exit.""" + if osc is None: + _die("--send requires OSC output. Don't use --no-osc with --send.") + msg = args.send + # Bypass rate-limiting: send directly for one-shot use + try: + osc._client.send_message(args.address, msg) # type: ignore[union-attr] + print(f" Sent : {args.address}") + print(f" Value : \"{msg}\"") + print(f" To : {args.target}:{args.port}") + except OSError as exc: + _die(f"OSC send failed: {exc}") + + +# ── Test / generate mode ────────────────────────────────────────────────────── + +def run_test(args: argparse.Namespace, osc: Optional[OSCSender]) -> None: + """Generate and send TC from an arbitrary start time at a given frame rate.""" + fps = float(getattr(args, "fps", 30.0)) + start_tc_str = getattr(args, "start_tc", None) + try: + start = _tc_to_frames(start_tc_str, fps) if start_tc_str else 0 + except ValueError as exc: + _die(str(exc)) + print(f"\n [GENERATE] {fps} fps from {_frames_to_tc(start, fps)}. Ctrl+C to stop.\n") + f = start + try: + while True: + tc = _frames_to_tc(f, fps) + print(f"\r TC: {tc} ", end="", flush=True) + if osc: + osc.send(tc) + f += 1 + time.sleep(1.0 / fps) + except KeyboardInterrupt: + print("\n\n Stopped.") + +# ── Live audio mode ──────────────────────────────────────────────────────────── + +def run_live(args: argparse.Namespace, osc: Optional[OSCSender]) -> None: + if not _HAS_SD: + _die("sounddevice not installed. Run: pip install sounddevice") + + # Resolve device + try: + dev_idx = args.device # None = system default + dev_info = sd.query_devices(dev_idx, "input") + except Exception as exc: + _die(f"Cannot open audio device: {exc}") + + n_ch = int(dev_info["max_input_channels"]) + if args.channel >= n_ch: + _die(f"Device has {n_ch} input channel(s); --channel {args.channel} is out of range.") + + rate = args.rate or int(dev_info["default_samplerate"]) + name = dev_info["name"] + + print(f"\n Target : {args.target}:{args.port}") + print(f" OSC : {args.address}") + if dev_idx is not None: + print(f" Device : [{dev_idx}] {name}") + else: + print(f" Device : [default] {name}") + print(f" Channel: {args.channel}") + print(f" Rate : {rate} Hz") + print("\n Listening... Ctrl+C to stop\n") + + last_tc: list = [None] + dec = LTCDecoder(sample_rate=rate, on_frame=lambda *_: None) + + def on_frame(tc: str, df: bool, fps: Optional[float]) -> None: + if tc != last_tc[0]: + last_tc[0] = tc + fps_str = f" @{fps:.2f}" if fps else "" + print(f"\r TC: {tc}{fps_str} ", end="", flush=True) + if osc: + osc.send(tc) + + dec.on_frame = on_frame # type: ignore[assignment] + + def audio_callback(indata: np.ndarray, frames: int, + time_info: object, status: object) -> None: + if status: + print(f"\n [audio] {status}", file=sys.stderr) + mono = indata[:, args.channel].copy() + dec.feed(mono) + + try: + with sd.InputStream( + device = dev_idx, + channels = n_ch, + samplerate = rate, + dtype = "float32", + blocksize = 1024, + callback = audio_callback, + ): + while True: + time.sleep(0.1) + except KeyboardInterrupt: + print("\n\n Stopped.") + except sd.PortAudioError as exc: + _die(f"PortAudio error: {exc}") + except Exception as exc: + _die(f"Audio error: {exc}") + +# ── Argument parser ──────────────────────────────────────────────────────────── + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="ltc_osc_bridge", + description=( + "Decode SMPTE LTC from a system audio input (or WAV file) " + "and send the timecode via OSC UDP." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +LTC → OSC examples: + %(prog)s --list-devices + %(prog)s --target 192.168.1.100 --device 2 + %(prog)s --wav ltc.wav --no-osc + %(prog)s --test --start-tc 01:00:00:00 --fps 25 --target 192.168.1.100 + %(prog)s --send "01:00:00:00" --target 192.168.1.100 --address /dpx_tc001/notify + +disguise d3 showcontrol examples: + %(prog)s --target 192.168.1.200 --d3-play + %(prog)s --target 192.168.1.200 --d3-stop + %(prog)s --target 192.168.1.200 --d3-cue 1 2 5 + %(prog)s --target 192.168.1.200 --d3-volume 0.8 + %(prog)s --target 192.168.1.200 --d3-brightness 0.5 + %(prog)s --target 192.168.1.200 --d3-trackname "Act 2" + +d3 monitoring listener registry examples: + %(prog)s --target 192.168.1.100 --d3-list-listeners + %(prog)s --target 192.168.1.100 --d3-preset all + %(prog)s --target 192.168.1.100 --d3-preset timecodeposition + %(prog)s --target 192.168.1.100 --d3-add-listener /d3/showcontrol/playmode d3_mode "Play Mode" + %(prog)s --target 192.168.1.100 --d3-remove-listener /d3/showcontrol/heartbeat +""", + ) + p.add_argument("-l", "--list-devices", action="store_true", + help="List available audio input devices and exit") + p.add_argument("-d", "--device", type=int, default=None, metavar="INDEX", + help="Audio input device index (default: system default)") + p.add_argument("-c", "--channel", type=int, default=0, metavar="N", + help="Audio channel index to read LTC from (default: 0)") + p.add_argument("-t", "--target", default="192.168.1.100", metavar="IP", + help="OSC target IP address (default: 192.168.1.100)") + p.add_argument("-p", "--port", type=int, default=4210, metavar="PORT", + help="OSC UDP port (default: 4210)") + p.add_argument("-a", "--address", default="/dpx_tc001/custom/tc", + metavar="ADDR", + help="OSC address (default: /dpx_tc001/custom/tc)") + p.add_argument("-r", "--rate", type=int, default=None, metavar="HZ", + help="Sample rate in Hz (default: device default)") + p.add_argument("--wav", metavar="FILE", + help="Decode LTC from a WAV file instead of live audio") + p.add_argument("--send", metavar="STRING", + help="Send a single OSC string to --address and exit") + p.add_argument("--no-osc", action="store_true", + help="Disable OSC output (decode and print only)") + p.add_argument("--test", action="store_true", + help="Generate TC from --start-tc at --fps (no audio needed)") + p.add_argument("--start-tc", default=None, metavar="HH:MM:SS:FF", + help="Starting timecode for generate mode (default: 00:00:00:00)") + p.add_argument("--fps", type=float, default=30.0, metavar="FPS", + help="Frame rate for generate mode: 24 / 25 / 29.97 / 30 (default: 30)") + p.add_argument("--version", action="version", + version=f"%(prog)s {__version__}") + + # ── disguise d3 showcontrol ──────────────────────────────────────────────── + d3g = p.add_argument_group( + "disguise d3 showcontrol", + "Send OSC transport commands to a disguise Designer machine and exit." + ) + d3g.add_argument("--d3-port", type=int, default=7401, metavar="PORT", + help="disguise OSC receive port (default: 7401)") + # Trigger commands (no args) + for _flag, _path in [ + ("--d3-play", "play"), + ("--d3-stop", "stop"), + ("--d3-loop", "loop section"), + ("--d3-playsection", "play to end of section"), + ("--d3-nextsection", "jump next section"), + ("--d3-previoussection", "jump previous section"), + ("--d3-nexttrack", "jump next track"), + ("--d3-previoustrack", "jump previous track"), + ("--d3-returntostart", "return to start"), + ("--d3-hold", "hold/freeze outputs"), + ("--d3-fadeup", "master fade up"), + ("--d3-fadedown", "master fade down"), + ]: + d3g.add_argument(_flag, action="store_true", help=f"/d3/showcontrol/{_path}") + # Value commands + d3g.add_argument("--d3-volume", type=float, metavar="0-1", + help="Set master volume (float 0.0–1.0)") + d3g.add_argument("--d3-brightness", type=float, metavar="0-1", + help="Set master brightness (float 0.0–1.0)") + d3g.add_argument("--d3-trackname", metavar="NAME", + help="Jump to named track (string)") + d3g.add_argument("--d3-trackid", metavar="ID", + help="Jump to track by ID (string/int)") + d3g.add_argument("--d3-cue", nargs="+", metavar="N", + help="Trigger cue e.g. --d3-cue 1 2 5 → cue 1.2.5") + d3g.add_argument("--d3-floatcue", type=float, metavar="FLOAT", + help="Trigger float cue (single float, e.g. 1.05)") + + # ── d3 monitoring listener registry (device HTTP API) ───────────────────── + _preset_names = ", ".join(_D3_MONITORING.keys()) + d3m = p.add_argument_group( + "d3 monitoring listener registry", + "Manage which d3 OSC monitoring paths the device listens for.\n" + "These paths are broadcast FROM disguise TO the device (§7.3).\n" + "Uses the device HTTP API — --target must be the device IP." + ) + d3m.add_argument("--http-port", type=int, default=80, metavar="PORT", + help="Device HTTP port (default: 80)") + d3m.add_argument("--d3-list-listeners", action="store_true", + help="List listener registry stored on device") + d3m.add_argument("--d3-add-listener", nargs="+", metavar=("PATH", "CHANNEL"), + help="Add listener: PATH CHANNEL [LABEL]") + d3m.add_argument("--d3-remove-listener", metavar="PATH", + help="Remove listener by OSC path") + d3m.add_argument("--d3-preset", metavar="NAME", + help=f"Register well-known preset(s) on device: all, or one of: {_preset_names}") + return p + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _die(msg: str, code: int = 1) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + sys.exit(code) + +# ── Entry point ──────────────────────────────────────────────────────────────── + +def main() -> None: + p = _build_parser() + args = p.parse_args() + + print(f"\ndpx_tc001 LTC → OSC Bridge v{__version__}") + print("─" * 44) + + if args.list_devices: + list_devices() + return + + # Set up OSC sender (unless suppressed) + osc: Optional[OSCSender] = None + if not args.no_osc: + if not _HAS_OSC: + print( + "WARNING: python-osc not installed — OSC output disabled.\n" + " pip install python-osc\n", + file=sys.stderr, + ) + else: + try: + osc = OSCSender(args.target, args.port, args.address) + print(f" OSC → udp://{args.target}:{args.port} {args.address}") + except Exception as exc: + print(f"WARNING: Could not init OSC sender: {exc}", file=sys.stderr) + + # d3 showcontrol: no OSC sender needed (uses own client directly) + if _any_d3(args): + run_d3(args) + return + + # d3 monitoring listener registry management (device HTTP API) + if _any_listener_cmd(args): + run_listener_cmds(args) + return + + if args.send is not None: + run_send(args, osc) + elif args.test: + run_test(args, osc) + elif args.wav: + run_wav(args.wav, args, osc) + else: + run_live(args, osc) + + +if __name__ == "__main__": + main() diff --git a/tools/ltc_osc_bridge/ltc_osc_bridge_gui.py b/tools/ltc_osc_bridge/ltc_osc_bridge_gui.py new file mode 100644 index 0000000000..fc258dcbf8 --- /dev/null +++ b/tools/ltc_osc_bridge/ltc_osc_bridge_gui.py @@ -0,0 +1,1155 @@ +#!/usr/bin/env python3 +""" +LTC → OSC Bridge — Web GUI v2.0 +================================== +Two-tab web interface: + Tab 1: LTC → OSC — live audio decode | TC generator | WAV file playback + Tab 2: d3 Control — disguise Designer showcontrol OSC panel + +Opens http://localhost:8765 automatically. +Usage: + python ltc_osc_bridge_gui.py + python ltc_osc_bridge_gui.py --port 9000 +""" + +from __future__ import annotations + +import json +import os +import queue +import sys +import tempfile +import threading +import time +import wave +import webbrowser +from typing import Optional + +# ── Dep checks ───────────────────────────────────────────────────────────────── + +def _die(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + sys.exit(1) + +try: + from flask import Flask, Response, jsonify, request, stream_with_context +except ImportError: + _die("Flask not installed.\n Run: pip install flask") + +try: + import numpy as np +except ImportError: + _die("NumPy not installed.\n Run: pip install numpy") + +try: + from ltc_osc_bridge import ( + LTCDecoder, OSCSender, _tc_to_frames, _frames_to_tc, + _HAS_SD, _HAS_OSC, + ) +except ImportError: + _die("ltc_osc_bridge.py not found in the same directory.") + +if _HAS_SD: + import sounddevice as sd + +if _HAS_OSC: + from pythonosc.udp_client import SimpleUDPClient as _UDPClient + +# ── Flask app ─────────────────────────────────────────────────────────────────── + +app = Flask(__name__) +app.config["SECRET_KEY"] = "ltc-osc-bridge-gui-v2-local" + +# ── Shared state ──────────────────────────────────────────────────────────────── + +_lock = threading.Lock() +_subscribers: list[queue.Queue] = [] + +_running = False +_audio_stream = None # sounddevice InputStream (live mode) +_bg_thread = None # Thread (generate / wav mode) +_decoder = None # LTCDecoder +_osc_sender = None # OSCSender +_wav_tmp_path = None # path to uploaded WAV file +_stats = {"tc": "--:--:--:--", "fps": None, "frames": 0} + + +def _broadcast(payload: dict) -> None: + msg = json.dumps(payload) + with _lock: + dead = [] + for q in _subscribers: + try: + q.put_nowait(msg) + except queue.Full: + dead.append(q) + for q in dead: + _subscribers.remove(q) + + +def _make_on_frame(osc): + """Return an on_frame callback that broadcasts TC and optionally sends OSC.""" + def on_frame(tc: str, df: bool, fps) -> None: + _stats["tc"] = tc + _stats["fps"] = fps + _stats["frames"] = _stats["frames"] + 1 + _broadcast({"tc": tc, "fps": fps, "frames": _stats["frames"]}) + if osc: + osc.send(tc) + return on_frame + + +# ── Background threads ────────────────────────────────────────────────────────── + +def _thread_generate(start_frame: int, fps: float, osc) -> None: + global _running + on_frame = _make_on_frame(osc) + f = start_frame + interval = 1.0 / fps + try: + while _running: + on_frame(_frames_to_tc(f, fps), False, fps) + f += 1 + time.sleep(interval) + except Exception as exc: + _broadcast({"error": str(exc)}) + _running = False + _broadcast({"stopped": True}) + + +def _thread_wav(path: str, channel: int, osc) -> None: + global _running + try: + wf = wave.open(path, "rb") + n_ch = wf.getnchannels() + sampw = wf.getsampwidth() + rate = wf.getframerate() + + dtype_map = {1: np.int8, 2: np.int16, 4: np.int32} + if sampw not in dtype_map or channel >= n_ch: + _broadcast({"error": f"WAV: bad format or channel out of range (has {n_ch}ch)"}) + _running = False + return + + dtype = dtype_map[sampw] + max_val = float(2 ** (sampw * 8 - 1)) + dec = LTCDecoder(sample_rate=rate, on_frame=_make_on_frame(osc)) + + CHUNK = 4096 + chunk_dur = CHUNK / rate # seconds per chunk (for real-time pacing) + + while _running: + raw = wf.readframes(CHUNK) + if not raw: + break + t0 = time.monotonic() + interleaved = np.frombuffer(raw, dtype=dtype) + mono = interleaved[channel::n_ch].astype(np.float32) / max_val + dec.feed(mono) + sleep = chunk_dur - (time.monotonic() - t0) + if sleep > 0: + time.sleep(sleep) + + wf.close() + except Exception as exc: + _broadcast({"error": str(exc)}) + _running = False + _broadcast({"stopped": True, "reason": "WAV playback complete"}) + + +# ── Routes ────────────────────────────────────────────────────────────────────── + +@app.route("/") +def index(): + return _HTML, 200, {"Content-Type": "text/html; charset=utf-8"} + + +@app.route("/api/devices") +def api_devices(): + if not _HAS_SD: + return jsonify({"error": "sounddevice not installed — pip install sounddevice"}), 500 + out = [] + for i, d in enumerate(sd.query_devices()): + if d["max_input_channels"] > 0: + out.append({ + "index": i, + "name": d["name"], + "channels": int(d["max_input_channels"]), + "rate": int(d["default_samplerate"]), + "default": (i == sd.default.device[0]), + }) + return jsonify(out) + + +@app.route("/api/wav", methods=["POST"]) +def api_wav(): + """Upload a WAV file for playback. Returns basic file info.""" + global _wav_tmp_path + if "wav" not in request.files: + return jsonify({"error": "No file provided"}), 400 + f = request.files["wav"] + try: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + f.save(tmp.name) + tmp.close() + if _wav_tmp_path and os.path.exists(_wav_tmp_path): + try: + os.unlink(_wav_tmp_path) + except OSError: + pass + _wav_tmp_path = tmp.name + wf = wave.open(tmp.name, "rb") + info = { + "ok": True, + "channels": wf.getnchannels(), + "rate": wf.getframerate(), + "bits": wf.getsampwidth() * 8, + "duration": round(wf.getnframes() / wf.getframerate(), 1), + } + wf.close() + return jsonify(info) + except Exception as exc: + return jsonify({"error": str(exc)}), 400 + + +@app.route("/api/start", methods=["POST"]) +def api_start(): + global _audio_stream, _bg_thread, _decoder, _osc_sender, _running + + if _running: + return jsonify({"error": "Already running"}), 400 + + data = request.get_json(force=True, silent=True) or {} + source = data.get("source", "live") # live | generate | wav + channel = int(data.get("channel", 0)) + target = str(data.get("target", "192.168.1.100")).strip() + port = int(data.get("port", 4210)) + address = str(data.get("address", "/dpx_tc001/custom/tc")).strip() + + # Build OSC sender + _osc_sender = None + if _HAS_OSC: + try: + _osc_sender = OSCSender(target, port, address) + except Exception as exc: + return jsonify({"error": f"OSC init failed: {exc}"}), 500 + + _stats.update(tc="--:--:--:--", fps=None, frames=0) + + # ── Live audio ──────────────────────────────────────────────────────────── + if source == "live": + if not _HAS_SD: + return jsonify({"error": "sounddevice not installed — pip install sounddevice"}), 500 + dev_idx = data.get("device") + try: + dev_info = ( + sd.query_devices(dev_idx, "input") if dev_idx is not None + else sd.query_devices(sd.default.device[0], "input") + ) + except Exception as exc: + return jsonify({"error": f"Cannot open device: {exc}"}), 400 + + n_ch = int(dev_info["max_input_channels"]) + if channel >= n_ch: + return jsonify({"error": f"Device has {n_ch}ch; channel {channel} out of range"}), 400 + rate = int(dev_info["default_samplerate"]) + + _decoder = LTCDecoder(sample_rate=rate, on_frame=_make_on_frame(_osc_sender)) + + def _audio_cb(indata, frames, time_info, status) -> None: + if _decoder is not None: + _decoder.feed(indata[:, channel].copy()) + + try: + _audio_stream = sd.InputStream( + device=dev_idx, channels=n_ch, samplerate=rate, + dtype="float32", blocksize=1024, callback=_audio_cb, + ) + _audio_stream.start() + _running = True + except Exception as exc: + return jsonify({"error": f"Cannot start audio: {exc}"}), 500 + + return jsonify({"ok": True, "source": "live", + "device": dev_info["name"], "rate": rate, "osc": _HAS_OSC}) + + # ── TC generator ────────────────────────────────────────────────────────── + if source == "generate": + fps_val = float(data.get("fps", 30.0)) + start_str = data.get("start_tc", "00:00:00:00") + try: + start_frame = _tc_to_frames(start_str, fps_val) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + _running = True + _bg_thread = threading.Thread( + target=_thread_generate, + args=(start_frame, fps_val, _osc_sender), + daemon=True, + ) + _bg_thread.start() + return jsonify({"ok": True, "source": "generate", + "start_tc": start_str, "fps": fps_val, "osc": _HAS_OSC}) + + # ── WAV playback ────────────────────────────────────────────────────────── + if source == "wav": + if not _wav_tmp_path or not os.path.exists(_wav_tmp_path): + return jsonify({"error": "No WAV file uploaded yet"}), 400 + + _running = True + _bg_thread = threading.Thread( + target=_thread_wav, + args=(_wav_tmp_path, channel, _osc_sender), + daemon=True, + ) + _bg_thread.start() + return jsonify({"ok": True, "source": "wav", "osc": _HAS_OSC}) + + return jsonify({"error": f"Unknown source: {source!r}"}), 400 + + +@app.route("/api/stop", methods=["POST"]) +def api_stop(): + global _audio_stream, _bg_thread, _decoder, _osc_sender, _running + _running = False + if _audio_stream is not None: + try: + _audio_stream.stop() + _audio_stream.close() + except Exception: + pass + _audio_stream = None + _decoder = None + _osc_sender = None + _broadcast({"tc": "--:--:--:--", "fps": None, "frames": 0, "stopped": True}) + return jsonify({"ok": True}) + + +@app.route("/api/d3", methods=["POST"]) +def api_d3(): + """Send a disguise d3 showcontrol OSC command.""" + if not _HAS_OSC: + return jsonify({"error": "python-osc not installed — pip install python-osc"}), 500 + data = request.get_json(force=True, silent=True) or {} + ip = str(data.get("ip", "192.168.1.100")).strip() + port = int(data.get("port", 7401)) + path = str(data.get("path", "")).strip() + args = data.get("args", None) # None | scalar | list + if not path.startswith("/"): + return jsonify({"error": "OSC path must start with /"}), 400 + try: + client = _UDPClient(ip, port) + client.send_message(path, args) + return jsonify({"ok": True, "path": path, "args": args}) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + + +@app.route("/api/stream") +def api_stream(): + q: queue.Queue = queue.Queue(maxsize=60) + with _lock: + _subscribers.append(q) + + def generate(): + yield f"data: {json.dumps(_stats)}\n\n" + try: + while True: + try: + msg = q.get(timeout=12) + yield f"data: {msg}\n\n" + except queue.Empty: + yield 'data: {"ping":true}\n\n' + finally: + with _lock: + if q in _subscribers: + _subscribers.remove(q) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", + "Connection": "keep-alive"}, + ) + + +# ── Embedded HTML/CSS/JS ──────────────────────────────────────────────────────── + +_HTML = r""" + + + + +LTC → OSC Bridge + + + +
+ + +
+ + +
+ + +
+
+ + + + LTC → OSC Bridge +
+
+ + +
+
--:--:--:--
+ +
+ + +
Source
+
+ + + + + + +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+ +
No file selected
+
+
+ +
+ + +
+
OSC Target (dpx_tc001)
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + + + + +
+
+ + + + + +
+ + + + +""" + +# ── Entry point ───────────────────────────────────────────────────────────────── + +DEFAULT_PORT = 8765 + + +def main() -> None: + import argparse + p = argparse.ArgumentParser( + description="LTC → OSC Bridge v2 — web UI with d3 control panel" + ) + p.add_argument("--port", type=int, default=DEFAULT_PORT, + help=f"Local web server port (default: {DEFAULT_PORT})") + p.add_argument("--no-browser", action="store_true", + help="Don't auto-open the browser") + args = p.parse_args() + + url = f"http://localhost:{args.port}" + print(f"\ndpx_tc001 LTC → OSC Bridge v2 (Web UI)") + print(f"{'─' * 42}") + print(f" Open : {url}") + print(f" Stop : Ctrl+C\n") + + if not args.no_browser: + def _open(): + time.sleep(0.9) + webbrowser.open(url) + threading.Thread(target=_open, daemon=True).start() + + app.run(host="127.0.0.1", port=args.port, + debug=False, threaded=True, use_reloader=False) + + +if __name__ == "__main__": + main() diff --git a/tools/ltc_osc_bridge/pyproject.toml b/tools/ltc_osc_bridge/pyproject.toml new file mode 100644 index 0000000000..f95c733e9d --- /dev/null +++ b/tools/ltc_osc_bridge/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "ltc-osc-bridge" +version = "1.0.0" +description = "Decode SMPTE LTC from audio and send timecode via OSC UDP" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.9" +dependencies = [ + "sounddevice>=0.4.0", + "numpy>=1.21.0", + "python-osc>=1.8.0", + "flask>=2.0.0", +] + +[project.scripts] +ltc-osc-bridge = "ltc_osc_bridge:main" +ltc-osc-bridge-gui = "ltc_osc_bridge_gui:main" + +[tool.setuptools] +py-modules = ["ltc_osc_bridge"] diff --git a/tools/ltc_osc_bridge/requirements.txt b/tools/ltc_osc_bridge/requirements.txt new file mode 100644 index 0000000000..dce9fab442 --- /dev/null +++ b/tools/ltc_osc_bridge/requirements.txt @@ -0,0 +1,4 @@ +sounddevice>=0.4.0 +numpy>=1.21.0 +python-osc>=1.8.0 +flask>=2.0.0 diff --git a/usermods/dpx_matrix/dpx_api.h b/usermods/dpx_matrix/dpx_api.h new file mode 100644 index 0000000000..6bd0e1299b --- /dev/null +++ b/usermods/dpx_matrix/dpx_api.h @@ -0,0 +1,521 @@ +// ================================================================================ +// dpx_api.h — HTTP Routes: pages + REST API +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_api.h +// Purpose: Register all HTTP routes for the dpx_matrix usermod. +// +// Pages: +// GET /ctrl → Control panel (dpx_html.h → ctrl_html) +// GET /browse → Icon browser (dpx_html.h → browse_html) +// GET /api-ref → API reference (dpx_html.h → apiref_html) +// GET /screen → Live matrix view (dpx_html.h → screenfull_html) +// +// API: +// GET/POST /api/stats device stats +// GET /api/apps app list +// GET /api/loop app loop map +// POST /api/notify push notification +// POST /api/notify/dismiss dismiss held notification +// GET/POST /api/custom get or push custom app (?name=) +// POST /api/switch switch to named app +// POST /api/nextapp advance loop +// POST /api/previousapp go back in loop +// POST /api/power {"power":true/false} +// POST /api/indicator1|2|3 {"color":[r,g,b],"blink":ms} +// GET/POST /api/time get/set device time +// POST /api/syncntp re-trigger NTP sync +// GET/POST /api/settings DPX + WLED settings +// GET/POST /api/dev raw dev.json +// GET/POST/DELETE /api/osc/listeners OSC listener registry +// POST /api/moodlight enable WLED effects (disable matrix overlay) +// POST /api/rtttl play RTTTL melody on TC001 piezo buzzer (GPIO 15) +// POST /api/sound stub +// POST /api/rename LittleFS rename +// GET /api/effects WLED effect names +// GET /api/transitions empty list (not implemented) +// GET/POST /api/reboot restart device +// GET /dpx legacy status (compat) +// GET /dpx/screen legacy pixel dump (compat) +// +// ================================================================================ + +#pragma once +#include "dpx_html.h" + +// dpxIndicator is declared extern in dpx_osc.h; defined in dpx_matrix.cpp. +extern uint32_t dpxIndicator[3]; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// Get plain-text POST body from AsyncWebServerRequest. +static inline String dpxBody(AsyncWebServerRequest* req) { + if (req->hasParam("plain", true)) + return req->getParam("plain", true)->value(); + return ""; +} + +// 256-pixel screen dump as JSON array. +static String dpxScreenJson() { + DynamicJsonDocument doc(4096); + JsonArray arr = doc.to(); + for (int i = 0; i < 256; i++) + arr.add((uint32_t)(strip.getPixelColor(i) & 0x00FFFFFF)); + String s; serializeJson(doc, s); return s; +} + +// Device stats JSON (shared by /dpx and /api/stats). +static String dpxStatsJson() { + DynamicJsonDocument doc(512); + doc[F("version")] = F("dpx_tc002"); + doc[F("uptime")] = millis() / 1000; + doc[F("ram")] = ESP.getFreeHeap(); + doc[F("ip")] = WiFi.localIP().toString(); + doc[F("rssi")] = WiFi.RSSI(); + doc[F("hostname")] = WiFi.getHostname(); + doc[F("app")] = (dpxCurrentApp < (int)dpxApps.size()) + ? dpxApps[dpxCurrentApp].name : String(); + doc[F("notif")] = (int)dpxNotifQueue.size(); + doc[F("autoTrans")] = dpxAutoTrans; + doc[F("enabled")] = dpxEnabled; + String s; serializeJson(doc, s); return s; +} + +// Settings JSON: DPX runtime + WLED globals. +// GET /api/settings returns this; POST /api/settings applies supported keys. +static String dpxSettingsJson() { + DynamicJsonDocument doc(768); + doc[F("BRI")] = (int)bri; + doc[F("ATIME")] = DPX_ATIME; + doc[F("ATRANS")] = DPX_ATRANS; + doc[F("SSPEED")] = DPX_SSPEED; + doc[F("UPPERCASE")] = DPX_UPPERCASE; + doc[F("Timezone")] = DPX_TIMEZONE; + doc[F("MQTT_PREFIX")] = String(mqttDeviceTopic); + doc[F("SOUND")] = true; // TC001 piezo always available + doc[F("VOL")] = 0; // passive piezo — no volume control + doc[F("TIM")] = DPX_SHOW_TIME; + doc[F("DAT")] = DPX_SHOW_DATE; + String s; serializeJson(doc, s); return s; +} + +// Apply settings from JSON body to DPX runtime + WLED globals. +static void dpxApplySettings(const String& body) { + DynamicJsonDocument doc(512); + if (deserializeJson(doc, body)) return; + + if (doc.containsKey("BRI")) { + bri = (uint8_t)constrain(doc["BRI"].as(), 0, 255); + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + if (doc.containsKey("ATIME")) { DPX_ATIME = doc["ATIME"].as(); } + if (doc.containsKey("ATRANS")) { DPX_ATRANS = doc["ATRANS"].as(); dpxAutoTrans = DPX_ATRANS; } + if (doc.containsKey("SSPEED")) { DPX_SSPEED = doc["SSPEED"].as(); } + if (doc.containsKey("UPPERCASE")){ DPX_UPPERCASE = doc["UPPERCASE"].as(); } + // Timezone is handled via /api/syncntp for full resync + if (doc.containsKey("Timezone")) { + DPX_TIMEZONE = doc["Timezone"].as(); + setenv("TZ", DPX_TIMEZONE.c_str(), 1); + tzset(); + dpxMergeDev(("{\"timezone_posix\":\"" + DPX_TIMEZONE + "\"}").c_str()); + } + if (doc.containsKey("TIM")) { DPX_SHOW_TIME = doc["TIM"].as(); dpxRebuildLoop(); } + if (doc.containsKey("DAT")) { DPX_SHOW_DATE = doc["DAT"].as(); dpxRebuildLoop(); } +} + +// ── Route registration ──────────────────────────────────────────────────────── + +static void dpxRegisterRoutes() { + + // ── Pages ───────────────────────────────────────────────────────────────── + server.on("/ctrl", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send_P(200, PSTR("text/html"), ctrl_html); + }); + server.on("/browse", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send_P(200, PSTR("text/html"), browse_html); + }); + server.on("/api-ref", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send_P(200, PSTR("text/html"), apiref_html); + }); + server.on("/screen", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send_P(200, PSTR("text/html"), screenfull_html); + }); + + // ── Stats ───────────────────────────────────────────────────────────────── + // Legacy endpoint kept for back-compat + server.on("/dpx", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send(200, F("application/json"), dpxStatsJson()); + }); + server.on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send(200, F("application/json"), dpxStatsJson()); + }); + + // ── Screen dump ─────────────────────────────────────────────────────────── + server.on("/dpx/screen", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send(200, F("application/json"), dpxScreenJson()); + }); + server.on("/api/screen", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send(200, F("application/json"), dpxScreenJson()); + }); + + // ── App loop ────────────────────────────────────────────────────────────── + server.on("/api/apps", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send(200, F("application/json"), dpxGetAppsJson()); + }); + server.on("/api/loop", HTTP_GET, [](AsyncWebServerRequest* r) { + r->send(200, F("application/json"), dpxGetLoopJson()); + }); + + // ── Notifications ───────────────────────────────────────────────────────── + server.on("/api/notify", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + if (body.length()) dpxPushNotification(body.c_str()); + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + server.on("/api/notify/dismiss", HTTP_POST, [](AsyncWebServerRequest* r) { + dpxDismissNotification(); + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + + // ── Custom apps (GET=load, POST=push, name in query) ────────────────────── + server.on("/api/custom", HTTP_ANY, [](AsyncWebServerRequest* r) { + String name = r->hasParam("name") ? r->getParam("name")->value() : String(); + if (name.isEmpty()) { r->send(400, F("text/plain"), F("name required")); return; } + + if (r->method() == HTTP_GET) { + r->send(200, F("application/json"), dpxGetCustomAppJson(name)); + return; + } + // POST: push or delete (empty body = delete) + String body = dpxBody(r); + dpxSetCustomApp(name, body.c_str()); + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + + // ── App navigation ──────────────────────────────────────────────────────── + server.on("/api/switch", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + bool ok = body.length() ? dpxSwitchToApp(body.c_str()) : false; + r->send(ok ? 200 : 400, F("application/json"), ok ? F("{\"ok\":true}") : F("{\"error\":\"not found\"}")); + }); + server.on("/api/nextapp", HTTP_POST, [](AsyncWebServerRequest* r) { + dpxNextApp(); + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + server.on("/api/previousapp", HTTP_POST, [](AsyncWebServerRequest* r) { + dpxPrevApp(); + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + + // ── Power on/off ────────────────────────────────────────────────────────── + // {"power": true/false} — maps to WLED brightness + server.on("/api/power", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + DynamicJsonDocument doc(64); + if (!deserializeJson(doc, body) && doc.containsKey("power")) { + bool on = doc["power"].as(); + if (on) { + bri = briLast ? briLast : 128; + } else { + briLast = bri; + bri = 0; + } + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + + // ── Indicators ──────────────────────────────────────────────────────────── + // {"color":[r,g,b],"blink":ms} — blink is ignored (no HW PWM), color stored + auto handleIndicator = [](AsyncWebServerRequest* r, int idx) { + String body = dpxBody(r); + DynamicJsonDocument doc(128); + if (!deserializeJson(doc, body)) { + if (doc.containsKey("color")) { + JsonArray a = doc["color"].as(); + if (a.size() >= 3) + dpxIndicator[idx] = ((uint32_t)(uint8_t)a[0].as() << 16) + | ((uint32_t)(uint8_t)a[1].as() << 8) + | (uint32_t)(uint8_t)a[2].as(); + else + dpxIndicator[idx] = 0; + } + } + r->send(200, F("application/json"), F("{\"ok\":true}")); + }; + server.on("/api/indicator1", HTTP_POST, [handleIndicator](AsyncWebServerRequest* r){ handleIndicator(r, 0); }); + server.on("/api/indicator2", HTTP_POST, [handleIndicator](AsyncWebServerRequest* r){ handleIndicator(r, 1); }); + server.on("/api/indicator3", HTTP_POST, [handleIndicator](AsyncWebServerRequest* r){ handleIndicator(r, 2); }); + + // ── Time ────────────────────────────────────────────────────────────────── + server.on("/api/time", HTTP_ANY, [](AsyncWebServerRequest* r) { + if (r->method() == HTTP_POST) { + String body = dpxBody(r); + DynamicJsonDocument doc(64); + if (!deserializeJson(doc, body) && doc.containsKey("utc")) { + time_t t = (time_t)doc["utc"].as(); + struct timeval tv = { t, 0 }; + settimeofday(&tv, nullptr); + } + r->send(200, F("application/json"), F("{\"ok\":true}")); + return; + } + // GET: return local time string + UTC epoch + time_t now; struct tm ti; + time(&now); localtime_r(&now, &ti); + char buf[24]; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &ti); + DynamicJsonDocument doc(128); + doc[F("local")] = buf; + doc[F("utc")] = (uint32_t)now; + String s; serializeJson(doc, s); + r->send(200, F("application/json"), s); + }); + + // ── NTP resync + timezone ───────────────────────────────────────────────── + // POST body (optional): {"timezone":"PST8PDT,...","server":"pool.ntp.org"} + server.on("/api/syncntp", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + DynamicJsonDocument doc(256); + if (!deserializeJson(doc, body)) { + if (doc.containsKey("timezone")) { + DPX_TIMEZONE = doc["timezone"].as(); + dpxMergeDev(("{\"timezone_posix\":\"" + DPX_TIMEZONE + "\"}").c_str()); + } + } + // Apply TZ env variable + if (DPX_TIMEZONE.length()) { + setenv("TZ", DPX_TIMEZONE.c_str(), 1); + tzset(); + } + // Re-trigger ESP32 SNTP via configTzTime (sets TZ env + syncs) + String ntpSrv = (!doc.isNull() && doc.containsKey("server")) + ? doc["server"].as() : String(ntpServerName); + if (!ntpSrv.length()) ntpSrv = F("pool.ntp.org"); + + if (DPX_TIMEZONE.length()) { + configTzTime(DPX_TIMEZONE.c_str(), ntpSrv.c_str()); + } else { + configTime(0, 0, ntpSrv.c_str()); + } + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + + // ── Settings ────────────────────────────────────────────────────────────── + server.on("/api/settings", HTTP_ANY, [](AsyncWebServerRequest* r) { + if (r->method() == HTTP_POST) { + dpxApplySettings(dpxBody(r)); + r->send(200, F("application/json"), F("{\"ok\":true}")); + return; + } + r->send(200, F("application/json"), dpxSettingsJson()); + }); + + // ── dev.json (raw device settings) ──────────────────────────────────────── + server.on("/api/dev", HTTP_ANY, [](AsyncWebServerRequest* r) { + if (r->method() == HTTP_POST) { + String body = dpxBody(r); + if (body.length()) dpxMergeDev(body.c_str()); + r->send(200, F("application/json"), F("{\"ok\":true}")); + return; + } + r->send(200, F("application/json"), dpxReadDevJson()); + }); + + // ── OSC Listeners ───────────────────────────────────────────────────────── + server.on("/api/osc/listeners", HTTP_ANY, [](AsyncWebServerRequest* r) { + if (r->method() == HTTP_GET) { + r->send(200, F("application/json"), dpxOscListenersJson()); + return; + } + String body = dpxBody(r); + DynamicJsonDocument doc(256); + if (deserializeJson(doc, body)) { r->send(400, F("text/plain"), F("bad JSON")); return; } + + if (r->method() == HTTP_DELETE) { + String path = doc["path"] | String(); + if (path.length()) { + dpxOscListeners.erase( + std::remove_if(dpxOscListeners.begin(), dpxOscListeners.end(), + [&path](const DpxOscListener& l){ return l.path == path; }), + dpxOscListeners.end()); + dpxSaveOscListeners(); + } + r->send(200, F("application/json"), F("{\"ok\":true}")); + return; + } + // POST: add listener + DpxOscListener lsr; + lsr.path = doc["path"] | String(); + lsr.channel = doc["channel"] | String(); + lsr.label = doc["label"] | lsr.channel; + if (lsr.path.length() && lsr.channel.length()) { + // Remove any existing entry for this path first + dpxOscListeners.erase( + std::remove_if(dpxOscListeners.begin(), dpxOscListeners.end(), + [&lsr](const DpxOscListener& l){ return l.path == lsr.path; }), + dpxOscListeners.end()); + dpxOscListeners.push_back(lsr); + dpxSaveOscListeners(); + // Ensure target app channel exists + if (dpxCustom.find(lsr.channel) == dpxCustom.end()) { + DpxCustomApp a; a.valid = true; a.text = lsr.label; + dpxCustom[lsr.channel] = a; + dpxRebuildLoop(); + } + } + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + + // ── Moodlight ───────────────────────────────────────────────────────────── + // Non-empty body → disable dpx overlay, WLED effects take over + // Empty body → re-enable dpx overlay + server.on("/api/moodlight", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + body.trim(); + if (!body.length() || body == "{}") { + dpxEnabled = true; + r->send(200, F("application/json"), F("{\"ok\":true,\"enabled\":true}")); + return; + } + dpxEnabled = false; + // Apply brightness/color to WLED if provided + DynamicJsonDocument doc(256); + if (!deserializeJson(doc, body)) { + if (doc.containsKey("brightness")) { + bri = (uint8_t)constrain(doc["brightness"].as(), 0, 255); + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + } + r->send(200, F("application/json"), F("{\"ok\":true,\"enabled\":false}")); + }); + + // ── RTTTL / Sound — TC001 piezo buzzer on GPIO 15 ──────────────────────── + // POST /api/rtttl body: raw RTTTL string or "stop" to silence + server.on("/api/rtttl", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + body.trim(); + if (!body.length()) { r->send(400, F("text/plain"), F("RTTTL string or 'stop' required")); return; } + if (body.equalsIgnoreCase("stop")) { + dpxBuzzerStop(); + } else { + dpxBuzzerPlay(body.c_str()); + } + r->send(200, F("application/json"), F("{\"ok\":true}")); + }); + // POST /api/sound body: JSON {"rtttl":"..."} or {"sound":"filename"} or empty=stop + // sound files are RTTTL text stored in /MELODIES/.txt on LittleFS + server.on("/api/sound", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + body.trim(); + // Empty body or {} = stop + if (!body.length() || body == F("{}")) { + dpxBuzzerStop(); + r->send(200, F("application/json"), F("{\"ok\":true}")); + return; + } + StaticJsonDocument<256> doc; + if (!deserializeJson(doc, body)) { + if (doc.containsKey("rtttl")) { + dpxBuzzerPlay(doc["rtttl"].as()); + r->send(200, F("application/json"), F("{\"ok\":true}")); + return; + } + if (doc.containsKey("sound")) { + // Load RTTTL from /MELODIES/.txt + String path = String(F("/MELODIES/")) + doc["sound"].as() + F(".txt"); + File f = LittleFS.open(path, "r"); + if (f) { + String rtttl = f.readString(); + f.close(); + rtttl.trim(); + dpxBuzzerPlay(rtttl.c_str()); + r->send(200, F("application/json"), F("{\"ok\":true}")); + } else { + r->send(404, F("text/plain"), F("melody file not found")); + } + return; + } + } + // Fallback: treat raw body as RTTTL string + if (body.indexOf(':') > 0) { + dpxBuzzerPlay(body.c_str()); + r->send(200, F("application/json"), F("{\"ok\":true}")); + } else { + r->send(400, F("text/plain"), F("rtttl or sound field required")); + } + }); + + // ── File rename ─────────────────────────────────────────────────────────── + // {"from":"/ICONS/a.jpg","to":"/ICONS/b.jpg"} + server.on("/api/rename", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + DynamicJsonDocument doc(256); + if (deserializeJson(doc, body)) { r->send(400, F("text/plain"), F("bad JSON")); return; } + String from = doc["from"] | String(); + String to = doc["to"] | String(); + if (from.isEmpty() || to.isEmpty()) { r->send(400, F("text/plain"), F("from/to required")); return; } + if (!LittleFS.exists(from)) { r->send(404, F("text/plain"), F("not found")); return; } + if (LittleFS.rename(from, to)) { + r->send(200, F("application/json"), F("{\"ok\":true}")); + } else { + r->send(500, F("text/plain"), F("rename failed")); + } + }); + + // ── WLED effects list ───────────────────────────────────────────────────── + server.on("/api/effects", HTTP_GET, [](AsyncWebServerRequest* r) { + DynamicJsonDocument doc(8192); + JsonArray arr = doc.to(); + uint8_t cnt = strip.getModeCount(); + for (uint8_t i = 0; i < cnt; i++) { + const char* data = strip.getModeData(i); + if (!data) continue; + // Mode string format: "Name@param1,param2;..." — extract up to '@' or ';' or '\0' + String name; + while (*data && *data != '@' && *data != ';') name += *data++; + name.trim(); + if (name.length()) arr.add(name); + } + String s; serializeJson(doc, s); + r->send(200, F("application/json"), s); + }); + + // ── Transitions list (stub — not implemented) ───────────────────────────── + server.on("/api/transitions", HTTP_GET, [](AsyncWebServerRequest* r) { + // Transition names (cross-fade is the only supported type currently) + r->send(200, F("application/json"), F("[\"fade\",\"slide\"]")); + }); + + // ── Reboot ──────────────────────────────────────────────────────────────── + server.on("/dpx/reboot", HTTP_ANY, [](AsyncWebServerRequest* r) { + r->send(200, F("text/plain"), F("OK")); + delay(200); + ESP.restart(); + }); + server.on("/api/reboot", HTTP_ANY, [](AsyncWebServerRequest* r) { + r->send(200, F("application/json"), F("{\"ok\":true}")); + delay(200); + ESP.restart(); + }); + + // ── Sleep (ESP32 deep sleep) ────────────────────────────────────────── + // POST body: {"sleep": N} — sleep N seconds then wake via timer. + // If N is 0 or omitted, sleeps indefinitely (wake on button press only). + server.on("/api/sleep", HTTP_POST, [](AsyncWebServerRequest* r) { + String body = dpxBody(r); + DynamicJsonDocument doc(64); + uint64_t secs = 0; + if (!deserializeJson(doc, body) && doc.containsKey("sleep")) + secs = (uint64_t)doc["sleep"].as(); + r->send(200, F("application/json"), F("{\"ok\":true}")); + delay(200); + if (secs > 0) + esp_sleep_enable_timer_wakeup(secs * 1000000ULL); + esp_deep_sleep_start(); + }); +} diff --git a/usermods/dpx_matrix/dpx_apps.h b/usermods/dpx_matrix/dpx_apps.h new file mode 100644 index 0000000000..b06dbe4b52 --- /dev/null +++ b/usermods/dpx_matrix/dpx_apps.h @@ -0,0 +1,439 @@ +// ================================================================================ +// dpx_apps.h — App Loop System +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// NOT derived from MatrixDisplayUi.cpp (AWTRIX CC BY-NC-SA 4.0). +// Written from scratch using SPEC.md §3-4 as behavioral reference. +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_apps.h +// Purpose: CustomApp struct, app loop vector, rotation timer, app management. +// Native apps (Time, Date, Temp) render their own content in render(). +// +// ================================================================================ + +#pragma once +#include +#include +#include +#include +#include "dpx_text.h" +#include "dpx_persist.h" + +// ── Draw instruction (subset of SPEC.md §4.3) ──────────────────────────────── +struct DpxDrawCmd { + String cmd; // "dp","dl","dr","df","dc","dfc","dt","db" + // Numeric args (up to 5) + int n[5]; + String s; // string arg for "dt" command + uint32_t color; +}; + +// ── Custom App data (SPEC.md §4.1 key fields) ──────────────────────────────── +struct DpxCustomApp { + String text; + uint32_t color = 0xFFFFFF; + uint32_t background = 0x000000; + bool rainbow = false; + bool center = false; + bool noScroll = false; + int scrollSpeed = 100; // % of base speed + bool topText = false; + int duration = 0; // seconds; 0 = use DPX_ATIME + int16_t repeat = -1; + int progress = -1; // progress bar 0-100, -1=off + uint32_t pColor = 0x00FF00; + uint32_t pbColor = 0x1a1a1a; + uint64_t lifetime = 0; // auto-remove after N seconds (0=off) + unsigned long addedMs = 0; // millis() when app was added + bool save = false; + std::vector drawCmds; + String overlay = ""; // per-app pixel effect name (e.g. "rain", "snow") + String icon = ""; // icon name (no extension, no path) + int pushIcon = 0; // 0=fixed left, 1=scroll+gone, 2=scroll+loop + + bool valid = false; // false = slot unused + + // Effective display duration in milliseconds + unsigned long durationMs() const { + int secs = (duration > 0) ? duration : DPX_ATIME; + return (unsigned long)secs * 1000UL; + } + + // True if this app has expired by lifetime + bool isExpired() const { + if (lifetime == 0 || addedMs == 0) return false; + return (millis() - addedMs) > (lifetime * 1000UL); + } +}; + +// ── App loop entry ──────────────────────────────────────────────────────────── +struct DpxApp { + String name; + DpxCustomApp data; + bool isNative = false; // Time, Date — built-in apps + bool muted = false; // if true, skipped in rotation +}; + +// ── OSC Listener Registry ───────────────────────────────────────────────────── +struct DpxOscListener { + String path; + String channel; + String label; +}; + +// ── Global app state ────────────────────────────────────────────────────────── +// Effect ID assigned by strip.addEffect() in DpxMatrix::setup(). +static uint8_t _dpxEffectId = 255; + +// Switch the main segment to dpx Matrix effect if not already active. +static inline void dpxActivateEffect() { + if (_dpxEffectId != 255 && strip.getMainSegment().mode != _dpxEffectId) { + strip.getMainSegment().setMode(_dpxEffectId); + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } +} + +static std::vector dpxApps; // ordered app loop +static std::map dpxCustom; // named custom apps +static std::set dpxHiddenApps; // removed from rotation (incl. natives) +static std::vector dpxOscListeners; + +static int dpxCurrentApp = 0; // index into dpxApps +static bool dpxAutoTrans = true; // auto-advance enabled +static unsigned long dpxAppStartMs = 0; // when current app was shown + +// Active scroll state — one per display +static DpxScrollState dpxScroll; + +// ── Parse a color from JSON value (string "#RRGGBB" or array [r,g,b]) ───────── +static uint32_t dpxParseColor(JsonVariant v, uint32_t def = 0xFFFFFF) { + if (v.is()) { + JsonArray a = v.as(); + if (a.size() >= 3) { + return ((uint32_t)(a[0].as()) << 16) + | ((uint32_t)(a[1].as()) << 8) + | (uint32_t)(a[2].as()); + } + } + if (v.is()) { + const char* s = v.as(); + if (s && s[0] == '#') { + return (uint32_t)strtol(s + 1, nullptr, 16); + } + } + return def; +} + +// ── Parse a CustomApp from JSON body ───────────────────────────────────────── +static DpxCustomApp dpxParseApp(const char* json) { + DpxCustomApp app; + DynamicJsonDocument doc(1024); + if (deserializeJson(doc, json)) return app; + + app.valid = true; + + if (doc.containsKey("text")) app.text = doc["text"].as(); + if (doc.containsKey("color")) app.color = dpxParseColor(doc["color"]); + if (doc.containsKey("background")) app.background = dpxParseColor(doc["background"], 0x000000); + if (doc.containsKey("rainbow")) app.rainbow = doc["rainbow"].as(); + if (doc.containsKey("center")) app.center = doc["center"].as(); + if (doc.containsKey("noScroll")) app.noScroll = doc["noScroll"].as(); + if (doc.containsKey("scrollSpeed")) app.scrollSpeed = doc["scrollSpeed"].as(); + if (doc.containsKey("topText")) app.topText = doc["topText"].as(); + if (doc.containsKey("duration")) app.duration = doc["duration"].as(); + if (doc.containsKey("repeat")) app.repeat = doc["repeat"].as(); + if (doc.containsKey("progress")) app.progress = doc["progress"].as(); + if (doc.containsKey("progressC")) app.pColor = dpxParseColor(doc["progressC"], 0x00FF00); + if (doc.containsKey("progressBC")) app.pbColor = dpxParseColor(doc["progressBC"], 0x1a1a1a); + if (doc.containsKey("lifetime")) app.lifetime = doc["lifetime"].as(); + if (doc.containsKey("save")) app.save = doc["save"].as(); + if (doc.containsKey("overlay")) { app.overlay = doc["overlay"].as(); app.overlay.toLowerCase(); } + if (doc.containsKey("icon")) app.icon = doc["icon"].as(); + if (doc.containsKey("pushIcon")) app.pushIcon = doc["pushIcon"].as(); + app.addedMs = millis(); + + // Draw commands + if (doc.containsKey("draw") && doc["draw"].is()) { + for (JsonObject o : doc["draw"].as()) { + DpxDrawCmd cmd; + if (o.containsKey("dp")) { cmd.cmd = "dp"; auto a = o["dp"].as(); cmd.n[0]=a[0];cmd.n[1]=a[1]; cmd.color=dpxParseColor(a[2]); app.drawCmds.push_back(cmd); } + else if (o.containsKey("df")) { cmd.cmd = "df"; auto a = o["df"].as(); cmd.n[0]=a[0];cmd.n[1]=a[1];cmd.n[2]=a[2];cmd.n[3]=a[3]; cmd.color=dpxParseColor(a[4]); app.drawCmds.push_back(cmd); } + else if (o.containsKey("dr")) { cmd.cmd = "dr"; auto a = o["dr"].as(); cmd.n[0]=a[0];cmd.n[1]=a[1];cmd.n[2]=a[2];cmd.n[3]=a[3]; cmd.color=dpxParseColor(a[4]); app.drawCmds.push_back(cmd); } + else if (o.containsKey("dl")) { cmd.cmd = "dl"; auto a = o["dl"].as(); cmd.n[0]=a[0];cmd.n[1]=a[1];cmd.n[2]=a[2];cmd.n[3]=a[3]; cmd.color=dpxParseColor(a[4]); app.drawCmds.push_back(cmd); } + else if (o.containsKey("dt")) { cmd.cmd = "dt"; auto a = o["dt"].as(); cmd.n[0]=a[0];cmd.n[1]=a[1]; cmd.s=a[2].as(); cmd.color=dpxParseColor(a[3]); app.drawCmds.push_back(cmd); } + } + } + + return app; +} + +// ── Render draw commands ────────────────────────────────────────────────────── +static void dpxExecDraw(const std::vector& cmds) { + for (const auto& c : cmds) { + if (c.cmd == "dp") { dpxSetPixel(c.n[0], c.n[1], c.color); } + else if (c.cmd == "df") { dpxFillRect(c.n[0], c.n[1], c.n[2], c.n[3], c.color); } + else if (c.cmd == "dr") { dpxDrawRect(c.n[0], c.n[1], c.n[2], c.n[3], c.color); } + else if (c.cmd == "dl") { + // Simple line using Bresenham + int x0=c.n[0],y0=c.n[1],x1=c.n[2],y1=c.n[3]; + int dx=abs(x1-x0), dy=abs(y1-y0), sx=x0-dy){err-=dy;x0+=sx;} if(e2= 0) { + dpxDrawProgressBar(app.progress, app.pColor, app.pbColor); + } + + int textY = app.topText ? (DPX_FONT_BASELINE - 1) : DPX_FONT_BASELINE; // proper AwtrixFont baselines + int textW = dpxTextPixelWidth(app.text.c_str()); + + if (app.noScroll || textW <= DPX_MATRIX_W) { + // Static: center or left-align + int x = 0; + if (app.center && textW < DPX_MATRIX_W) x = (DPX_MATRIX_W - textW) / 2; + dpxRenderText(x, textY, app.text.c_str(), app.color, app.rainbow); + return true; // static apps never "complete" + } + + // Scrolling + if (!dpxScroll.active || dpxScroll.text != app.text) { + dpxScroll.start(app.text, app.color, app.rainbow, textY, app.scrollSpeed, app.repeat); + } + bool done = dpxScroll.tick(); + dpxScroll.render(); + return !done; +} + +// ── App loop management ─────────────────────────────────────────────────────── + +// Rebuild dpxApps from dpxCustom. Any app in dpxHiddenApps is skipped. +static void dpxRebuildLoop() { + std::vector newList; + // Native apps — included unless user deleted them from the rotation + const char* natives[] = {"Time", "Date"}; // WLED removed — dpx Matrix IS the WLED effect + for (auto n : natives) { + if (dpxHiddenApps.find(String(n)) == dpxHiddenApps.end()) { + DpxApp a; a.name = n; a.isNative = true; + newList.push_back(a); + } + } + // Custom apps in insertion order + for (auto& kv : dpxCustom) { + if (kv.second.valid) { + DpxApp a; + a.name = kv.first; + a.data = kv.second; + a.isNative = false; + newList.push_back(a); + } + } + dpxApps = newList; + if (dpxCurrentApp >= (int)dpxApps.size()) dpxCurrentApp = 0; +} + +// Add or update a custom app. Empty body = remove from rotation. +// Native apps (Time, Date) are hidden (not deleted); custom apps are erased. +static void dpxSetCustomApp(const String& name, const char* json) { + if (!json || strlen(json) <= 2) { + // Remove from rotation — custom erased, natives hidden + dpxCustom.erase(name); + dpxHiddenApps.insert(name); + LittleFS.remove("/CUSTOMAPPS/" + name + ".json"); + } else { + DpxCustomApp app = dpxParseApp(json); + if (app.valid) { + dpxCustom[name] = app; + dpxHiddenApps.erase(name); + dpxActivateEffect(); // incoming app — switch display to dpx Matrix + if (app.save) { + LittleFS.mkdir("/CUSTOMAPPS"); + File f = LittleFS.open("/CUSTOMAPPS/" + name + ".json", "w"); + if (f) { f.print(json); f.close(); } + } + } + } + dpxRebuildLoop(); +} + +// Advance to next unmuted app +static void dpxNextApp() { + if (dpxApps.empty()) return; + int start = dpxCurrentApp; + do { + dpxCurrentApp = (dpxCurrentApp + 1) % dpxApps.size(); + } while (dpxApps[dpxCurrentApp].muted && dpxCurrentApp != start); + dpxAppStartMs = millis(); + dpxScroll.stop(); +} + +// Go to previous unmuted app +static void dpxPrevApp() { + if (dpxApps.empty()) return; + int start = dpxCurrentApp; + do { + dpxCurrentApp = (dpxCurrentApp + (int)dpxApps.size() - 1) % dpxApps.size(); + } while (dpxApps[dpxCurrentApp].muted && dpxCurrentApp != start); + dpxAppStartMs = millis(); + dpxScroll.stop(); +} + +// Switch to a named app; JSON body: {"name":"AppName"} +static bool dpxSwitchToApp(const char* json) { + DynamicJsonDocument doc(128); + if (deserializeJson(doc, json)) return false; + String name = doc["name"].as(); + for (int i = 0; i < (int)dpxApps.size(); i++) { + if (dpxApps[i].name == name) { + dpxCurrentApp = i; + dpxAppStartMs = millis(); + dpxScroll.stop(); + dpxActivateEffect(); // switch display to dpx Matrix + return true; + } + } + return false; +} + +// App loop JSON: {"AppName": index, ...} +static String dpxGetLoopJson() { + DynamicJsonDocument doc(1024); + for (int i = 0; i < (int)dpxApps.size(); i++) { + doc[dpxApps[i].name] = i; + } + String s; serializeJson(doc, s); return s; +} + +// Apps-with-icon JSON array (SPEC.md §5 /api/apps) +static String dpxGetAppsJson() { + DynamicJsonDocument doc(2048); + JsonArray arr = doc.to(); + for (auto& a : dpxApps) { + JsonObject o = arr.createNestedObject(); + o["name"] = a.name; + o["native"] = a.isNative; + o["muted"] = a.muted; + } + String s; serializeJson(doc, s); return s; +} + +// Mute or unmute an app by name. Returns false if name not found. +static bool dpxMuteApp(const String& name, bool mute) { + for (auto& a : dpxApps) { + if (a.name == name) { a.muted = mute; return true; } + } + return false; +} + +// Return JSON of a specific custom app's current state +static String dpxGetCustomAppJson(const String& name) { + if (dpxCustom.find(name) == dpxCustom.end()) return F("{}"); + const DpxCustomApp& a = dpxCustom[name]; + DynamicJsonDocument doc(512); + doc["text"] = a.text; + doc["color"] = a.color; + doc["background"] = a.background; + doc["rainbow"] = a.rainbow; + doc["center"] = a.center; + doc["noScroll"] = a.noScroll; + doc["scrollSpeed"] = a.scrollSpeed; + doc["duration"] = a.duration; + doc["progress"] = a.progress; + doc["icon"] = a.icon; + doc["pushIcon"] = a.pushIcon; + doc["overlay"] = a.overlay; + String s; serializeJson(doc, s); return s; +} + +// ── App loop tick (call from loop()) ───────────────────────────────────────── +// Advances app pointer when current app's duration expires. +static void dpxAppLoopTick() { + if (dpxApps.empty()) return; + + // Prune expired lifetime apps + for (auto it = dpxCustom.begin(); it != dpxCustom.end(); ) { + if (it->second.isExpired()) { it = dpxCustom.erase(it); dpxRebuildLoop(); } + else ++it; + } + + if (!dpxAutoTrans) return; + if (dpxApps.empty()) return; + + unsigned long now = millis(); + unsigned long dur = dpxApps[dpxCurrentApp].isNative + ? (unsigned long)DPX_ATIME * 1000UL + : dpxApps[dpxCurrentApp].data.durationMs(); + + if (now - dpxAppStartMs >= dur) { + dpxNextApp(); // dpxNextApp already skips muted apps + } +} + +// ── Render the current app frame ───────────────────────────────────────────── +// Called from handleOverlayDraw(). +static void dpxRenderCurrentApp() { + if (dpxApps.empty()) { + dpxClear(); + dpxRenderText(0, DPX_FONT_BASELINE, "NO APP", 0x444444); + return; + } + + DpxApp& app = dpxApps[dpxCurrentApp]; + if (app.isNative) { + dpxClear(); + if (app.name == "Time") dpxRenderNativeTime(); + else if (app.name == "Date") dpxRenderNativeDate(); + } else { + dpxRenderApp(app.data); + } +} diff --git a/usermods/dpx_matrix/dpx_buzzer.h b/usermods/dpx_matrix/dpx_buzzer.h new file mode 100644 index 0000000000..26d65882c8 --- /dev/null +++ b/usermods/dpx_matrix/dpx_buzzer.h @@ -0,0 +1,201 @@ +// ================================================================================ +// dpx_buzzer.h — Non-blocking RTTTL tone player for TC001 (GPIO 15) +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_buzzer.h +// Purpose: Parses an RTTTL string into a flat note/duration sequence and steps +// through it from loop() without any blocking delays. +// +// Uses Arduino tone()/noTone() which on ESP32 drives a single LEDC +// channel. This is safe alongside WS2812B LEDs because WLED drives +// those via the RMT peripheral, not LEDC. +// +// Public API: +// dpxBuzzerInit() — call once from setup(); drives pin LOW +// dpxBuzzerPlay(const char*) — start playing an RTTTL string +// dpxBuzzerStop() — immediately silence and reset +// dpxBuzzerTick() — call every loop(); advances the sequence +// +// RTTTL format: "Title:d=4,o=5,b=120:e,e,e,c,e,g,G" +// d = default note duration (1/2/4/8/16/32) +// o = default octave (4–7) +// b = BPM +// Notes: [dur][pitch][#][oct][.] — # = sharp, . = dotted (×1.5) +// +// ================================================================================ + +#pragma once +#include "Arduino.h" + +// AI: below section was generated by an AI + +#define DPX_BUZZER_PIN 15 +#define DPX_BUZZER_MAX_NOTES 96 // max notes parsed from one RTTTL string + +// ── Chromatic frequency table — octave 4 base (C4..B4, Hz) ────────────────── +// Index: 0=C 1=C# 2=D 3=D# 4=E 5=F 6=F# 7=G 8=G# 9=A 10=A# 11=B +static const uint16_t DPX_NOTE_HZ[12] PROGMEM = { + 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494 +}; + +// Map RTTTL pitch letter → chromatic index (0-11). Returns -1 for 'p' (pause). +static int _dpxPitchIdx(char c) { + switch (c) { + case 'c': return 0; + case 'd': return 2; + case 'e': return 4; + case 'f': return 5; + case 'g': return 7; + case 'a': return 9; + case 'b': return 11; + default: return -1; // 'p' = pause, unknown = silence + } +} + +struct DpxBuzzerNote { + uint16_t freq; // Hz — 0 means silence/pause + uint16_t ms; // duration in milliseconds (capped at 4000) +}; + +static DpxBuzzerNote _bzNotes[DPX_BUZZER_MAX_NOTES]; +static uint8_t _bzCount = 0; +static uint8_t _bzIdx = 0; +static bool _bzPlaying = false; +static unsigned long _bzNoteMs = 0; // millis() when current note started + +// ── Parse RTTTL string into _bzNotes[] ─────────────────────────────────────── +static void _dpxRtttlParse(const char* rtttl) { + _bzCount = 0; + + // Skip title (up to first ':') + const char* p = rtttl; + while (*p && *p != ':') p++; + if (!*p) return; + p++; // skip ':' + + // Parse header defaults: d=4,o=5,b=120 + int defDur = 4, defOct = 5, bpm = 120; + while (*p && *p != ':') { + while (*p == ' ' || *p == ',') p++; + if (!*p || *p == ':') break; + char key = *p++; + if (*p != '=') continue; + p++; + int val = 0; + while (*p >= '0' && *p <= '9') val = val * 10 + (*p++ - '0'); + if (key == 'd') defDur = max(1, val); + else if (key == 'o') defOct = constrain(val, 4, 7); + else if (key == 'b') bpm = max(1, val); + } + if (!*p) return; + p++; // skip second ':' + + // Whole-note duration in ms = 60000ms/beat × 4 beats/whole = 240000/BPM + uint32_t wholeMs = 240000UL / (uint32_t)bpm; + + while (*p && _bzCount < DPX_BUZZER_MAX_NOTES) { + // Skip whitespace and commas between notes + while (*p == ' ' || *p == ',') p++; + if (!*p) break; + + // Optional explicit duration digits + int dur = 0; + while (*p >= '0' && *p <= '9') dur = dur * 10 + (*p++ - '0'); + if (dur == 0) dur = defDur; + + // Pitch letter (c d e f g a b p) + if (!*p) break; + char pitch = tolower((unsigned char)*p++); + int idx = _dpxPitchIdx(pitch); + + // Optional sharp + bool sharp = false; + if (*p == '#') { sharp = true; p++; } + + // Optional octave digit + int oct = defOct; + if (*p >= '4' && *p <= '7') oct = (int)(*p++ - '0'); + + // Optional dot (duration × 1.5) + bool dotted = false; + if (*p == '.') { dotted = true; p++; } + + // Compute duration + uint32_t durMs = wholeMs / (uint32_t)dur; + if (dotted) durMs = durMs * 3 / 2; + durMs = constrain(durMs, (uint32_t)10, (uint32_t)4000); + + // Compute frequency (0 = pause) + uint16_t freq = 0; + if (idx >= 0) { + int chromIdx = idx + (sharp ? 1 : 0); + // Base frequency from table (octave 4) + uint32_t f = pgm_read_word(&DPX_NOTE_HZ[chromIdx % 12]); + // Octave shift relative to 4 + int shift = oct - 4 + (chromIdx / 12); + if (shift > 0) f <<= shift; + else if (shift < 0) f >>= (-shift); + freq = (uint16_t)constrain((int32_t)f, 20, 20000); + } + + _bzNotes[_bzCount++] = { freq, (uint16_t)durMs }; + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── +static void dpxBuzzerStop(); // forward declaration + +// Call once from setup() — ensures pin is OUTPUT and driven LOW. +static void dpxBuzzerInit() { + pinMode(DPX_BUZZER_PIN, OUTPUT); + digitalWrite(DPX_BUZZER_PIN, LOW); +} + +// Start playing an RTTTL string. Call dpxBuzzerTick() from loop() to advance. +static void dpxBuzzerPlay(const char* rtttl) { + dpxBuzzerStop(); // stop any current melody first + if (!rtttl || !*rtttl) return; + _dpxRtttlParse(rtttl); + if (_bzCount == 0) return; + + _bzIdx = 0; + _bzPlaying = true; + _bzNoteMs = millis(); + + // Start the first note immediately + if (_bzNotes[0].freq > 0) tone(DPX_BUZZER_PIN, _bzNotes[0].freq); + else noTone(DPX_BUZZER_PIN); +} + +// Immediately stop playback and silence the buzzer. +static void dpxBuzzerStop() { + noTone(DPX_BUZZER_PIN); + digitalWrite(DPX_BUZZER_PIN, LOW); + _bzPlaying = false; + _bzIdx = 0; + _bzCount = 0; +} + +// Advance the note sequencer — call from loop() every iteration. +static void dpxBuzzerTick() { + if (!_bzPlaying) return; + + unsigned long now = millis(); + if (now - _bzNoteMs < _bzNotes[_bzIdx].ms) return; // still in this note + + // Move to next note + _bzIdx++; + if (_bzIdx >= _bzCount) { + dpxBuzzerStop(); + return; + } + _bzNoteMs = now; + if (_bzNotes[_bzIdx].freq > 0) tone(DPX_BUZZER_PIN, _bzNotes[_bzIdx].freq); + else noTone(DPX_BUZZER_PIN); +} + +// AI: end diff --git a/usermods/dpx_matrix/dpx_firstboot.h b/usermods/dpx_matrix/dpx_firstboot.h new file mode 100644 index 0000000000..6de1af7e09 --- /dev/null +++ b/usermods/dpx_matrix/dpx_firstboot.h @@ -0,0 +1,114 @@ +// ================================================================================ +// dpx_firstboot.h — First-Boot Config Injection +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// Writes /cfg.json on first boot (file absent) so the device is usable +// out of the box without the WLED setup wizard. +// +// Defaults written: +// AP dpx-tc002 / dubpixel1, behav=1 (always open when disconnected) +// mDNS dpx-tc002 +// LED GPIO 32, 256× WS2812B GRB, 42fps, 8.5W limit +// 2D 32×8 panel, non-serpentine (change via WLED UI if needed) +// Buttons GPIO 26/14/27, push-button type +// Trans 0ms (instant, better for matrix text) +// ================================================================================ + +#pragma once + +static void dpxFirstBoot() { + if (LittleFS.exists(F("/cfg.json"))) return; + + DEBUG_PRINTLN(F("DpxMatrix: first boot — writing /cfg.json")); + + DynamicJsonDocument doc(2048); + + // Identity + JsonObject id = doc.createNestedObject("id"); + id["mdns"] = "dpx-tc002"; + id["name"] = "dpx_tc002"; + id["inv"] = "TC001"; + id["sui"] = false; + + // Access point + JsonObject ap = doc.createNestedObject("ap"); + ap["ssid"] = "dpx-tc002"; + ap["psk"] = "dubpixel1"; + ap["chan"] = 6; + ap["hide"] = 0; + ap["behav"] = 1; // AP_BEHAVIOR_NO_CONN — always open when disconnected + + // WiFi + doc["wifi"]["sleep"] = false; + + // Hardware — LED + JsonObject hw = doc.createNestedObject("hw"); + JsonObject led = hw.createNestedObject("led"); + led["total"] = 256; + led["maxpwr"] = 8500; + led["fps"] = 42; + + JsonObject ins = led["ins"].createNestedObject(); + ins["start"] = 0; + ins["len"] = 256; + ins["pin"][0] = 32; + ins["order"] = 0; // GRB + ins["rev"] = false; + ins["skip"] = 0; + ins["type"] = 22; // TYPE_WS2812_RGB + ins["ref"] = false; + ins["rgbwm"] = 255; + ins["freq"] = 0; + ins["ledma"] = 55; + ins["drv"] = 0; + + // 2D matrix — 32×8, non-serpentine (user can toggle serpentine in WLED UI) + JsonObject matrix = led.createNestedObject("matrix"); + matrix["mpc"] = 1; + JsonObject panel = matrix["panels"].createNestedObject(); + panel["b"] = false; // top start + panel["r"] = false; // left start + panel["v"] = false; // horizontal + panel["s"] = true; // TC001 is serpentine-wired + panel["x"] = 0; + panel["y"] = 0; + panel["h"] = 8; + panel["w"] = 32; + + // Hardware — buttons + JsonObject btn = hw.createNestedObject("btn"); + btn["max"] = 3; + btn["pull"] = true; + JsonObject b0 = btn["ins"].createNestedObject(); b0["type"]=2; b0["pin"][0]=26; b0["macros"][0]=0; b0["macros"][1]=0; b0["macros"][2]=0; + JsonObject b1 = btn["ins"].createNestedObject(); b1["type"]=2; b1["pin"][0]=14; b1["macros"][0]=0; b1["macros"][1]=0; b1["macros"][2]=0; + JsonObject b2 = btn["ins"].createNestedObject(); b2["type"]=2; b2["pin"][0]=27; b2["macros"][0]=0; b2["macros"][1]=0; b2["macros"][2]=0; + + // Light — keep WLED's default transition (750ms) so power fade works + // Do NOT set dur=0 here; that kills the power-on/off fade animation. + // Users can reduce transition in WLED → LED Preferences if desired. + + // Defaults — DNA Spiral as startup effect (FX_MODE_2DDNASPIRAL = 182) + doc["def"]["ps"] = 0; + doc["def"]["on"] = true; + doc["def"]["bri"] = 128; + // def.fx intentionally omitted — dpxActivateEffect() in connected() sets + // the correct dynamic effect ID after strip.addEffect() has run. + + doc["ota"]["lock"] = false; + + // Enable NTP — time sync is needed for Time/Date apps + doc["if"]["ntp"]["en"] = true; + doc["if"]["ntp"]["host"] = "pool.ntp.org"; + doc["if"]["ntp"]["tz"] = 0; // UTC; user sets timezone in WLED → Config → Time + + File f = LittleFS.open(F("/cfg.json"), "w"); + if (!f) { DEBUG_PRINTLN(F("DpxMatrix: failed to open /cfg.json")); return; } + serializeJson(doc, f); + f.close(); + DEBUG_PRINTLN(F("DpxMatrix: /cfg.json written — takes effect on next boot")); +} + diff --git a/usermods/dpx_matrix/dpx_font.h b/usermods/dpx_matrix/dpx_font.h new file mode 100644 index 0000000000..b2b089e3c7 --- /dev/null +++ b/usermods/dpx_matrix/dpx_font.h @@ -0,0 +1,542 @@ +// ================================================================================ +// dpx_font.h — AwtrixFont (TomThumb-derived 3x5 pixel font) +// ================================================================================ +// +// License: BSD 3-Clause (see full text below) +// +// Original 3x5 font: Brian J. Swetland, Vassilii Khachaturov (1999) +// TomThumb modifications: Robey Pointer (2010) — http://robey.lag.net/2010/01/23/tiny-monospace-font.html +// AWTRIX modifications for improved readability: Blueforcer (2018–2024) +// GFX conversion: William Skellenger (2016) +// +// Redistributed under the BSD 3-Clause License. Attribution retained above. +// See: http://opengameart.org/forumtopic/how-to-submit-art-using-the-3-clause-bsd-license +// +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_font.h +// Purpose: Inline GFX-format font data + lightweight lookup helpers. +// Used by dpx_text.h for rendering text onto the 32x8 LED matrix. +// +// Font metrics (per glyph, all glyphs): +// Height: 5 pixels (yOffset = -5 from baseline) +// yAdvance: 6 (line height; only 1 row used currently) +// Baseline: set cursor_y = DPX_FONT_BASELINE (= 6) to center in 8-row display +// → glyph rows land on matrix rows 1..5, leaving row 0 and rows 6..7 clear +// xAdvance: variable (2..4 px) includes 1-px right gap +// +// ================================================================================ + +#pragma once + +// ── Adafruit GFX font structs (defined here; WLED does not bundle Adafruit GFX) ── +#ifndef _GFXFONT_H_ +#define _GFXFONT_H_ +typedef struct { + uint16_t bitmapOffset; // Pointer into GFXfont->bitmap + uint8_t width; // Bitmap dimensions in pixels + uint8_t height; + uint8_t xAdvance; // Distance to advance cursor (x axis) + int8_t xOffset; // X dist from cursor to UL corner + int8_t yOffset; // Y dist from cursor to UL corner (negative = up) +} GFXglyph; + +typedef struct { + uint8_t *bitmap; // Glyph bitmaps, concatenated + GFXglyph *glyph; // Glyph array + uint16_t first; // ASCII extents (first char) + uint16_t last; // ASCII extents (last char) + uint8_t yAdvance; // Newline distance (y axis) +} GFXfont; +#endif // _GFXFONT_H_ + +// ── Font constants ───────────────────────────────────────────────────────────── +#define DPX_FONT_H 5 // Glyph height in pixels +#define DPX_FONT_BASELINE 6 // cursor_y to center glyph in 8-row display + + +// AwtrixFont Version 20240129 + +const uint8_t AwtrixBitmaps[] PROGMEM = { + 0x00, /*[0] 0x20 space */ + 0x80, 0x80, 0x80, 0x00, 0x80, /*[1] 0x21 exclam */ + 0xA0, 0xA0, /*[2] 0x22 quotedbl */ + 0xA0, 0xE0, 0xA0, 0xE0, 0xA0, /*[3] 0x23 numbersign */ + 0x60, 0xC0, 0x60, 0xC0, 0x40, /*[4] 0x24 dollar */ + 0xA0, 0x20, 0x40, 0x80, 0xA0, /*[5] 0x25 percent */ + 0xC0, 0xC0, 0xE0, 0xA0, 0x60, /*[6] 0x26 ampersand */ + 0x80, 0x80, /*[7] 0x27 quotesingle */ + 0x40, 0x80, 0x80, 0x80, 0x40, /*[8] 0x28 parenleft */ + 0x80, 0x40, 0x40, 0x40, 0x80, /*[9] 0x29 parenright */ + 0xA0, 0x40, 0xA0, /*[10] 0x2A asterisk */ + 0x40, 0xE0, 0x40, /*[11] 0x2B plus */ + 0x40, 0x80, /*[12] 0x2C comma */ + 0xE0, /*[13] 0x2D hyphen */ + 0x80, /*[14] 0x2E period */ + 0x20, 0x20, 0x40, 0x80, 0x80, /*[15] 0x2F slash */ + 0xE0, 0xA0, 0xA0, 0xA0, 0xE0, /*[16] 0x30 zero */ + 0x40, 0xC0, 0x40, 0x40, 0xE0, /*[17] 0x31 one */ + 0xE0, 0x20, 0xE0, 0x80, 0xE0, /*[18] 0x32 two */ + 0xE0, 0x20, 0xE0, 0x20, 0xE0, /*[19] 0x33 three */ + 0xA0, 0xA0, 0xE0, 0x20, 0x20, /*[20] 0x34 four */ + 0xE0, 0x80, 0xE0, 0x20, 0xE0, /*[21] 0x35 five */ + 0xE0, 0x80, 0xE0, 0xA0, 0xE0, /*[22] 0x36 six */ + 0xE0, 0x20, 0x20, 0x20, 0x20, /*[23] 0x37 seven */ + 0xE0, 0xA0, 0xE0, 0xA0, 0xE0, /*[24] 0x38 eight */ + 0xE0, 0xA0, 0xE0, 0x20, 0xE0, /*[25] 0x39 nine */ + 0x80, 0x00, 0x80, /*[26] 0x3A colon */ + 0x40, 0x00, 0x40, 0x80, /*[27] 0x3B semicolon */ + 0x20, 0x40, 0x80, 0x40, 0x20, /*[28] 0x3C less */ + 0xE0, 0x00, 0xE0, /*[29] 0x3D equal */ + 0x80, 0x40, 0x20, 0x40, 0x80, /*[30] 0x3E greater */ + 0xE0, 0x20, 0x40, 0x00, 0x40, /*[31] 0x3F question */ + 0x40, 0xA0, 0xE0, 0x80, 0x60, /*[32] 0x40 at */ + 0xC0, 0xA0, 0xE0, 0xA0, 0xA0, /*[33] 0x41 A */ + 0xC0, 0xA0, 0xC0, 0xA0, 0xC0, /*[34] 0x42 B */ + 0x40, 0xA0, 0x80, 0xA0, 0x40, /*[35] 0x43 C */ + 0xC0, 0xA0, 0xA0, 0xA0, 0xC0, /*[36] 0x44 D */ + 0xE0, 0x80, 0xE0, 0x80, 0xE0, /*[37] 0x45 E */ + 0xE0, 0x80, 0xE0, 0x80, 0x80, /*[38] 0x46 F */ + 0x60, 0x80, 0xA0, 0xA0, 0x60, /*[39] 0x47 G */ + 0xA0, 0xA0, 0xE0, 0xA0, 0xA0, /*[40] 0x48 H */ + 0x80, 0x80, 0x80, 0x80, 0x80, /*[41] 0x49 I */ + 0x20, 0x20, 0x20, 0xA0, 0x40, /*[42] 0x4A J */ + 0xA0, 0xA0, 0xC0, 0xA0, 0xA0, /*[43] 0x4B K */ + 0x80, 0x80, 0x80, 0x80, 0xE0, /*[44] 0x4C L */ + 0x88, 0xD8, 0xA8, 0x88, 0x88, /*[45] 0x4D M */ + 0x90, 0xD0, 0xB0, 0x90, 0x90, /*[46] 0x4E N */ + 0x40, 0xA0, 0xA0, 0xA0, 0x40, /*[47] 0x4F O */ + 0xE0, 0xA0, 0xC0, 0x80, 0x80, /*[48] 0x50 P */ + 0x40, 0xA0, 0xA0, 0xA0, 0x70, /*[49] 0x51 Q */ + 0xE0, 0xA0, 0xC0, 0xA0, 0xA0, /*[50] 0x52 R */ + 0xE0, 0x80, 0xE0, 0x20, 0xE0, /*[51] 0x53 S */ + 0xE0, 0x40, 0x40, 0x40, 0x40, /*[52] 0x54 T */ + 0xA0, 0xA0, 0xA0, 0xA0, 0xE0, /*[53] 0x55 U */ + 0xA0, 0xA0, 0xA0, 0xA0, 0x40, /*[54] 0x56 V */ + 0x88, 0x88, 0x88, 0xA8, 0x50, /*[55] 0x57 W */ + 0xA0, 0xA0, 0x40, 0xA0, 0xA0, /*[56] 0x58 X */ + 0xA0, 0xA0, 0xE0, 0x20, 0xC0, /*[57] 0x59 Y */ + 0xE0, 0x20, 0x40, 0x80, 0xE0, /*[58] 0x5A Z */ + 0xE0, 0x80, 0x80, 0x80, 0xE0, /*[59] 0x5B bracketleft */ + 0x80, 0x40, 0x20, /*[60] 0x5C backslash */ + 0xE0, 0x20, 0x20, 0x20, 0xE0, /*[61] 0x5D bracketright */ + 0x40, 0xA0, /*[62] 0x5E asciicircum */ + 0xE0, /*[63] 0x5F underscore */ + 0x80, 0x40, /*[64] 0x60 grave */ + 0xC0, 0x60, 0xA0, 0xE0, /*[65] 0x61 a */ + 0x80, 0xC0, 0xA0, 0xA0, 0xC0, /*[66] 0x62 b */ + 0x60, 0x80, 0x80, 0x60, /*[67] 0x63 c */ + 0x20, 0x60, 0xA0, 0xA0, 0x60, /*[68] 0x64 d */ + 0x60, 0xA0, 0xC0, 0x60, /*[69] 0x65 e */ + 0x20, 0x40, 0xE0, 0x40, 0x40, /*[70] 0x66 f */ + 0x60, 0xA0, 0xE0, 0x20, 0x40, /*[71] 0x67 g */ + 0x80, 0xC0, 0xA0, 0xA0, 0xA0, /*[72] 0x68 h */ + 0x80, 0x00, 0x80, 0x80, 0x80, /*[73] 0x69 i */ + 0x20, 0x00, 0x20, 0x20, 0xA0, 0x40, /*[74] 0x6A j */ + 0x80, 0xA0, 0xC0, 0xC0, 0xA0, /*[75] 0x6B k */ + 0xC0, 0x40, 0x40, 0x40, 0xE0, /*[76] 0x6C l */ + 0xE0, 0xE0, 0xE0, 0xA0, /*[77] 0x6D m */ + 0xC0, 0xA0, 0xA0, 0xA0, /*[78] 0x6E n */ + 0x40, 0xA0, 0xA0, 0x40, /*[79] 0x6F o */ + 0xC0, 0xA0, 0xA0, 0xC0, 0x80, /*[80] 0x70 p */ + 0x60, 0xA0, 0xA0, 0x60, 0x20, /*[81] 0x71 q */ + 0x60, 0x80, 0x80, 0x80, /*[82] 0x72 r */ + 0x60, 0xC0, 0x60, 0xC0, /*[83] 0x73 s */ + 0x40, 0xE0, 0x40, 0x40, 0x60, /*[84] 0x74 t */ + 0xA0, 0xA0, 0xA0, 0x60, /*[85] 0x75 u */ + 0xA0, 0xA0, 0xE0, 0x40, /*[86] 0x76 v */ + 0xA0, 0xE0, 0xE0, 0xE0, /*[87] 0x77 w */ + 0xA0, 0x40, 0x40, 0xA0, /*[88] 0x78 x */ + 0xA0, 0xA0, 0x60, 0x20, 0x40, /*[89] 0x79 y */ + 0xE0, 0x60, 0xC0, 0xE0, /*[90] 0x7A z */ + 0x60, 0x40, 0x80, 0x40, 0x60, /*[91] 0x7B braceleft */ + 0x80, 0x80, 0x80, 0x80, 0x80, /*[92] 0x7C bar */ + 0xC0, 0x40, 0x20, 0x40, 0xC0, /*[93] 0x7D braceright */ + 0x60, 0xC0, /*[94] 0x7E asciitilde */ + + 0xE0, 0xA0, 0xE0, 0xA0, 0xA0, /*[95] 0x7F А */ + 0xE0, 0x80, 0xE0, 0xA0, 0xE0, /*[96] 0x80 Б */ + 0xC0, 0xA0, 0xE0, 0xA0, 0xC0, /*[97] 0x81 В */ + 0xE0, 0x80, 0x80, 0x80, 0x80, /*[98] 0x82 Г */ + 0x70, 0x50, 0x50, 0x50, 0xF8, /*[99] 0x83 Д */ + 0xE0, 0x80, 0xC0, 0x80, 0xE0, /*[100] 0x84 Е */ + 0xA8, 0xA8, 0x70, 0xA8, 0xA8, /*[101] 0x85 Ж */ + 0xC0, 0x20, 0x40, 0x20, 0xC0, /*[102] 0x86 З */ + 0x90, 0x90, 0xB0, 0xD0, 0x90, /*[103] 0x87 И */ + 0x20, 0x90, 0xB0, 0xD0, 0x90, /*[104] 0x88 Й */ + 0xA0, 0xA0, 0xC0, 0xA0, 0xA0, /*[105] 0x89 К */ + 0x60, 0xA0, 0xA0, 0xA0, 0xA0, /*[106] 0x8A Л */ + 0x88, 0xD8, 0xA8, 0x88, 0x88, /*[107] 0x8B М */ + 0xA0, 0xA0, 0xE0, 0xA0, 0xA0, /*[108] 0x8C Н */ + 0xE0, 0xA0, 0xA0, 0xA0, 0xE0, /*[109] 0x8D О */ + 0xE0, 0xA0, 0xA0, 0xA0, 0xA0, /*[110] 0x8E П */ + 0xE0, 0xA0, 0xE0, 0x80, 0x80, /*[111] 0x8F Р */ + 0xE0, 0x80, 0x80, 0x80, 0xE0, /*[112] 0x90 С */ + 0xE0, 0x40, 0x40, 0x40, 0x40, /*[113] 0x91 Т */ + 0xA0, 0xA0, 0xE0, 0x20, 0xC0, /*[114] 0x92 У */ + 0xF8, 0xA8, 0xF8, 0x20, 0x20, /*[115] 0x93 Ф */ + 0xA0, 0xA0, 0x40, 0xA0, 0xA0, /*[116] 0x94 Х */ + 0xA0, 0xA0, 0xA0, 0xA0, 0xF0, /*[117] 0x95 Ц */ + 0xA0, 0xA0, 0xE0, 0x20, 0x20, /*[118] 0x96 Ч */ + 0xA8, 0xA8, 0xA8, 0xA8, 0xF8, /*[119] 0x97 Ш */ + 0xA8, 0xA8, 0xA8, 0xA8, 0xFC, /*[120] 0x98 Щ */ + 0xC0, 0x40, 0x70, 0x50, 0x70, /*[121] 0x99 Ъ */ + 0x88, 0x88, 0xE8, 0xA8, 0xE8, /*[122] 0x9A Ы */ + 0x80, 0x80, 0xE0, 0xA0, 0xE0, /*[123] 0x9B Ь */ + 0xC0, 0x20, 0x60, 0x20, 0xC0, /*[124] 0x9C Э */ + 0xB8, 0xA8, 0xE8, 0xA8, 0xB8, /*[125] 0x9D Ю */ + 0xE0, 0xA0, 0x60, 0xA0, 0xA0, /*[126] 0x9E Я */ + 0x20, 0xE0, 0x80, 0x80, 0x80, 0x00, 0x00, /*[127] 0x9F Ґ */ + 0x60, 0x80, 0xC0, 0x80, 0x60, /*[128] 0xA0 Є */ + + 0x80, 0x00, 0x80, 0x80, 0x80, /*[129] 0xA1 exclamdown */ + 0x40, 0xE0, 0x80, 0xE0, 0x40, /*[130] 0xA2 cent */ + 0x60, 0x40, 0xE0, 0x40, 0xE0, /*[131] 0xA3 sterling */ + 0xA0, 0x40, 0xE0, 0x40, 0xA0, /*[132] 0xA4 currency */ + 0xA0, 0xA0, 0x40, 0xE0, 0x40, /*[133] 0xA5 yen */ + 0x80, 0x80, 0x00, 0x80, 0x80, /*[134] 0xA6 brokenbar */ + 0x60, 0x40, 0xA0, 0x40, 0xC0, /*[135] 0xA7 section */ + 0xA0, /*[136] 0xA8 dieresis */ + 0x60, 0x80, 0x60, /*[137] 0xA9 copyright */ + 0x60, 0xA0, 0xE0, 0x00, 0xE0, /*[138] 0xAA ordfeminine */ + 0x40, 0x80, 0x40, /*[139] 0xAB guillemotleft */ + 0xE0, 0x20, /*[140] 0xAC logicalnot */ + 0xC0, /*[141] 0xAD softhyphen */ + 0xC0, 0xC0, 0xA0, /*[142] 0xAE registered */ + 0xE0, /*[143] 0xAF macron */ + 0xC0, 0xC0, 0x00, /*[144] 0xB0 degree */ + 0x40, 0xE0, 0x40, 0x00, 0xE0, /*[145] 0xB1 plusminus */ + 0xC0, 0x40, 0x60, /*[146] 0xB2 twosuperior */ + 0xE0, 0x60, 0xE0, /*[147] 0xB3 threesuperior */ + 0x40, 0x80, /*[148] 0xB4 acute */ + 0xA0, 0xA0, 0xA0, 0xC0, 0x80, /*[149] 0xB5 mu */ + // 0x60, 0xA0, 0x60, 0x60, 0x60, /*[150] 0xB6 paragraph */ + 0x60, 0xC0, 0xE0, 0xC0, 0x60, /*[150] 0x20AC Euro */ + 0xE0, 0xE0, 0xE0, /*[151] 0xB7 periodcentered */ + 0x40, 0x20, 0xC0, /*[152] 0xB8 cedilla */ + 0x80, 0x80, 0x80, /*[153] 0xB9 onesuperior */ + 0x40, 0xA0, 0x40, 0x00, 0xE0, /*[154] 0xBA ordmasculine */ + 0x80, 0x40, 0x80, /*[155] 0xBB guillemotright */ + 0x80, 0x80, 0x00, 0x60, 0x20, /*[156] 0xBC onequarter */ + 0x80, 0x80, 0x00, 0xC0, 0x60, /*[157] 0xBD onehalf */ + 0xC0, 0xC0, 0x00, 0x60, 0x20, /*[158] 0xBE threequarters */ + 0x40, 0x00, 0x40, 0x80, 0xE0, /*[159] 0xBF questiondown */ + 0x40, 0x20, 0x40, 0xE0, 0xA0, /*[160] 0xC0 Agrave */ + 0x40, 0x80, 0x40, 0xE0, 0xA0, /*[161] 0xC1 Aacute */ + 0xE0, 0x00, 0x40, 0xE0, 0xA0, /*[162] 0xC2 Acircumflex */ + 0x60, 0xC0, 0x40, 0xE0, 0xA0, /*[163] 0xC3 Atilde */ + 0xA0, 0x40, 0xA0, 0xE0, 0xA0, /*[164] 0xC4 Adieresis */ + 0xC0, 0xC0, 0xA0, 0xE0, 0xA0, /*[165] 0xC5 Aring */ + 0x60, 0xC0, 0xE0, 0xC0, 0xE0, /*[166] 0xC6 AE */ + 0x60, 0x80, 0x80, 0x60, 0x20, 0x40, /*[167] 0xC7 Ccedilla */ + 0x40, 0x20, 0xE0, 0xC0, 0xE0, /*[168] 0xC8 Egrave */ + 0x40, 0x80, 0xE0, 0xC0, 0xE0, /*[169] 0xC9 Eacute */ + 0xE0, 0x00, 0xE0, 0xC0, 0xE0, /*[170] 0xCA Ecircumflex */ + 0xA0, 0x00, 0xE0, 0xC0, 0xE0, /*[171] 0xCB Edieresis */ + 0x40, 0x20, 0xE0, 0x40, 0xE0, /*[172] 0xCC Igrave */ + 0x40, 0x80, 0xE0, 0x40, 0xE0, /*[173] 0xCD Iacute */ + 0xE0, 0x00, 0xE0, 0x40, 0xE0, /*[174] 0xCE Icircumflex */ + 0xA0, 0x00, 0xE0, 0x40, 0xE0, /*[175] 0xCF Idieresis */ + 0xC0, 0xA0, 0xE0, 0xA0, 0xC0, /*[176] 0xD0 Eth */ + 0xC0, 0x60, 0xA0, 0xE0, 0xA0, /*[177] 0xD1 Ntilde */ + 0x40, 0x20, 0xE0, 0xA0, 0xE0, /*[178] 0xD2 Ograve */ + 0x40, 0x80, 0xE0, 0xA0, 0xE0, /*[179] 0xD3 Oacute */ + 0xE0, 0x00, 0xE0, 0xA0, 0xE0, /*[180] 0xD4 Ocircumflex */ + 0xC0, 0x60, 0xE0, 0xA0, 0xE0, /*[181] 0xD5 Otilde */ + 0xA0, 0x00, 0xE0, 0xA0, 0xE0, /*[182] 0xD6 Odieresis */ + 0xA0, 0x40, 0xA0, /*[183] 0xD7 multiply */ + 0x60, 0xA0, 0xE0, 0xA0, 0xC0, /*[184] 0xD8 Oslash */ + 0x80, 0x40, 0xA0, 0xA0, 0xE0, /*[185] 0xD9 Ugrave */ + 0x20, 0x40, 0xA0, 0xA0, 0xE0, /*[186] 0xDA Uacute */ + 0xE0, 0x00, 0xA0, 0xA0, 0xE0, /*[187] 0xDB Ucircumflex */ + 0xA0, 0x00, 0xA0, 0xA0, 0xE0, /*[188] 0xDC Udieresis */ + 0x20, 0x40, 0xA0, 0xE0, 0x40, /*[189] 0xDD Yacute */ + 0x80, 0xE0, 0xA0, 0xE0, 0x80, /*[190] 0xDE Thorn */ + 0x60, 0xA0, 0xC0, 0xA0, 0xC0, 0x80, /*[191] 0xDF germandbls */ + 0x40, 0x20, 0x60, 0xA0, 0xE0, /*[192] 0xE0 agrave */ + 0x40, 0x80, 0x60, 0xA0, 0xE0, /*[193] 0xE1 aacute */ + 0xE0, 0x00, 0x60, 0xA0, 0xE0, /*[194] 0xE2 acircumflex */ + 0x60, 0xC0, 0x60, 0xA0, 0xE0, /*[195] 0xE3 atilde */ + 0xA0, 0x00, 0x60, 0xA0, 0xE0, /*[196] 0xE4 adieresis */ + 0x60, 0x60, 0x60, 0xA0, 0xE0, /*[197] 0xE5 aring */ + 0x60, 0xE0, 0xE0, 0xC0, /*[198] 0xE6 ae */ + 0x60, 0x80, 0x60, 0x20, 0x40, /*[199] 0xE7 copy&pasteistrash */ + 0x40, 0x20, 0x60, 0xE0, 0x60, /*[200] 0xE8 egrave */ + 0x40, 0x80, 0x60, 0xE0, 0x60, /*[201] 0xE9 eacute */ + 0xE0, 0x00, 0x60, 0xE0, 0x60, /*[202] 0xEA ecircumflex */ + 0xA0, 0x00, 0x60, 0xE0, 0x60, /*[203] 0xEB edieresis */ + 0x80, 0x40, 0x80, 0x80, 0x80, /*[204] 0xEC igrave */ + 0x40, 0x80, 0x40, 0x40, 0x40, /*[205] 0xED iacute */ + 0xE0, 0x00, 0x40, 0x40, 0x40, /*[206] 0xEE icircumflex */ + 0xA0, 0x00, 0x40, 0x40, 0x40, /*[207] 0xEF idieresis */ + 0x60, 0xC0, 0x60, 0xA0, 0x60, /*[208] 0xF0 eth */ + 0xC0, 0x60, 0xC0, 0xA0, 0xA0, /*[209] 0xF1 ntilde */ + 0x40, 0x20, 0x40, 0xA0, 0x40, /*[210] 0xF2 ograve */ + 0x40, 0x80, 0x40, 0xA0, 0x40, /*[211] 0xF3 oacute */ + 0xE0, 0x00, 0x40, 0xA0, 0x40, /*[212] 0xF4 ocircumflex */ + 0xC0, 0x60, 0x40, 0xA0, 0x40, /*[213] 0xF5 otilde */ + 0xA0, 0x00, 0x40, 0xA0, 0x40, /*[214] 0xF6 odieresis */ + 0x40, 0x00, 0xE0, 0x00, 0x40, /*[215] 0xF7 divide */ + 0x60, 0xE0, 0xA0, 0xC0, /*[216] 0xF8 oslash */ + 0x80, 0x40, 0xA0, 0xA0, 0x60, /*[217] 0xF9 ugrave */ + 0x20, 0x40, 0xA0, 0xA0, 0x60, /*[218] 0xFA uacute */ + 0xE0, 0x00, 0xA0, 0xA0, 0x60, /*[219] 0xFB ucircumflex */ + 0xA0, 0x00, 0xA0, 0xA0, 0x60, /*[220] 0xFC udieresis */ + 0x20, 0x40, 0xA0, 0x60, 0x20, 0x40, /*[221] 0xFD yacute */ + 0x80, 0xC0, 0xA0, 0xC0, 0x80, /*[222] 0xFE thorn */ + 0xA0, 0x00, 0xA0, 0x60, 0x20, 0x40, /*[223] 0xFF ydieresis */ +}; + +/* {offset, width, height, advance cursor, x offset, y offset} */ +const GFXglyph AwtrixFontGlyphs[] PROGMEM = { + { 0 , 8 , 1 , 2 , 0 , -5 }, /*[0] 0x20 space */ + { 1 , 8 , 5 , 2 , 0 , -5 }, /*[1] 0x21 exclam */ + { 6 , 8 , 2 , 4 , 0 , -5 }, /*[2] 0x22 quotedbl */ + { 8 , 8 , 5 , 4 , 0 , -5 }, /*[3] 0x23 numbersign */ + { 13 , 8 , 5 , 4 , 0 , -5 }, /*[4] 0x24 dollar */ + { 18 , 8 , 5 , 4 , 0 , -5 }, /*[5] 0x25 percent */ + { 23 , 8 , 5 , 4 , 0 , -5 }, /*[6] 0x26 ampersand */ + { 28 , 8 , 2 , 2 , 0 , -5 }, /*[7] 0x27 quotesingle */ + { 30 , 8 , 5 , 3 , 0 , -5 }, /*[8] 0x28 parenleft */ + { 35 , 8 , 5 , 3 , 0 , -5 }, /*[9] 0x29 parenright */ + { 40 , 8 , 3 , 4 , 0 , -5 }, /*[10] 0x2A asterisk */ + { 43 , 8 , 3 , 4 , 0 , -4 }, /*[11] 0x2B plus */ + { 46 , 8 , 2 , 3 , 0 , -1 }, /*[12] 0x2C comma */ + { 48 , 8 , 1 , 4 , 0 , -3 }, /*[13] 0x2D hyphen */ + { 49 , 8 , 1 , 2 , 0 , -1 }, /*[14] 0x2E period */ + { 50 , 8 , 5 , 4 , 0 , -5 }, /*[15] 0x2F slash */ + { 55 , 8 , 5 , 4 , 0 , -5 }, /*[16] 0x30 zero */ + { 60 , 8 , 5 , 4 , 0 , -5 }, /*[17] 0x31 one */ + { 65 , 8 , 5 , 4 , 0 , -5 }, /*[18] 0x32 two */ + { 70 , 8 , 5 , 4 , 0 , -5 }, /*[19] 0x33 three */ + { 75 , 8 , 5 , 4 , 0 , -5 }, /*[20] 0x34 four */ + { 80 , 8 , 5 , 4 , 0 , -5 }, /*[21] 0x35 five */ + { 85 , 8 , 5 , 4 , 0 , -5 }, /*[22] 0x36 six */ + { 90 , 8 , 5 , 4 , 0 , -5 }, /*[23] 0x37 seven */ + { 95 , 8 , 5 , 4 , 0 , -5 }, /*[24] 0x38 eight */ + { 100 , 8 , 5 , 4 , 0 , -5 }, /*[25] 0x39 nine */ + { 105 , 8 , 3 , 2 , 0 , -4 }, /*[26] 0x3A colon */ + { 108 , 8 , 4 , 3 , 0 , -4 }, /*[27] 0x3B semicolon */ + { 112 , 8 , 5 , 4 , 0 , -5 }, /*[28] 0x3C less */ + { 117 , 8 , 3 , 4 , 0 , -4 }, /*[29] 0x3D equal */ + { 120 , 8 , 5 , 4 , 0 , -5 }, /*[30] 0x3E greater */ + { 125 , 8 , 5 , 4 , 0 , -5 }, /*[31] 0x3F question */ + { 130 , 8 , 5 , 4 , 0 , -5 }, /*[32] 0x40 at */ + { 135 , 8 , 5 , 4 , 0 , -5 }, /*[33] 0x41 A */ + { 140 , 8 , 5 , 4 , 0 , -5 }, /*[34] 0x42 B */ + { 145 , 8 , 5 , 4 , 0 , -5 }, /*[35] 0x43 C */ + { 150 , 8 , 5 , 4 , 0 , -5 }, /*[36] 0x44 D */ + { 155 , 8 , 5 , 4 , 0 , -5 }, /*[37] 0x45 E */ + { 160 , 8 , 5 , 4 , 0 , -5 }, /*[38] 0x46 F */ + { 165 , 8 , 5 , 4 , 0 , -5 }, /*[39] 0x47 G */ + { 170 , 8 , 5 , 4 , 0 , -5 }, /*[40] 0x48 H */ + { 175 , 8 , 5 , 2 , 0 , -5 }, /*[41] 0x49 I */ + { 180 , 8 , 5 , 4 , 0 , -5 }, /*[42] 0x4A J */ + { 185 , 8 , 5 , 4 , 0 , -5 }, /*[43] 0x4B K */ + { 190 , 8 , 5 , 4 , 0 , -5 }, /*[44] 0x4C L */ + { 195 , 8 , 5 , 6 , 0 , -5 }, /*[45] 0x4D M */ + { 200 , 8 , 5 , 5 , 0 , -5 }, /*[46] 0x4E N */ + { 205 , 8 , 5 , 4 , 0 , -5 }, /*[47] 0x4F O */ + { 210 , 8 , 5 , 4 , 0 , -5 }, /*[48] 0x50 P */ + { 215 , 8 , 5 , 5 , 0 , -5 }, /*[49] 0x51 Q */ + { 220 , 8 , 5 , 4 , 0 , -5 }, /*[50] 0x52 R */ + { 225 , 8 , 5 , 4 , 0 , -5 }, /*[51] 0x53 S */ + { 230 , 8 , 5 , 4 , 0 , -5 }, /*[52] 0x54 T */ + { 235 , 8 , 5 , 4 , 0 , -5 }, /*[53] 0x55 U */ + { 240 , 8 , 5 , 4 , 0 , -5 }, /*[54] 0x56 V */ + { 245 , 8 , 5 , 6 , 0 , -5 }, /*[55] 0x57 W */ + { 250 , 8 , 5 , 4 , 0 , -5 }, /*[56] 0x58 X */ + { 255 , 8 , 5 , 4 , 0 , -5 }, /*[57] 0x59 Y */ + { 260 , 8 , 5 , 4 , 0 , -5 }, /*[58] 0x5A Z */ + { 265 , 8 , 5 , 4 , 0 , -5 }, /*[59] 0x5B bracketleft */ + { 270 , 8 , 3 , 4 , 0 , -4 }, /*[60] 0x5C backslash */ + { 273 , 8 , 5 , 4 , 0 , -5 }, /*[61] 0x5D bracketright */ + { 278 , 8 , 2 , 4 , 0 , -5 }, /*[62] 0x5E asciicircum */ + { 280 , 8 , 1 , 4 , 0 , -1 }, /*[63] 0x5F underscore */ + { 281 , 8 , 2 , 3 , 0 , -5 }, /*[64] 0x60 grave */ + { 283 , 8 , 4 , 4 , 0 , -4 }, /*[65] 0x61 a */ + { 287 , 8 , 5 , 4 , 0 , -5 }, /*[66] 0x62 b */ + { 292 , 8 , 4 , 4 , 0 , -4 }, /*[67] 0x63 c */ + { 296 , 8 , 5 , 4 , 0 , -5 }, /*[68] 0x64 d */ + { 301 , 8 , 4 , 4 , 0 , -4 }, /*[69] 0x65 e */ + { 305 , 8 , 5 , 4 , 0 , -5 }, /*[70] 0x66 f */ + { 310 , 8 , 5 , 4 , 0 , -4 }, /*[71] 0x67 g */ + { 315 , 8 , 5 , 4 , 0 , -5 }, /*[72] 0x68 h */ + { 320 , 8 , 5 , 2 , 0 , -5 }, /*[73] 0x69 i */ + { 325 , 8 , 6 , 4 , 0 , -5 }, /*[74] 0x6A j */ + { 331 , 8 , 5 , 4 , 0 , -5 }, /*[75] 0x6B k */ + { 336 , 8 , 5 , 4 , 0 , -5 }, /*[76] 0x6C l */ + { 341 , 8 , 4 , 4 , 0 , -4 }, /*[77] 0x6D m */ + { 345 , 8 , 4 , 4 , 0 , -4 }, /*[78] 0x6E n */ + { 349 , 8 , 4 , 4 , 0 , -4 }, /*[79] 0x6F o */ + { 353 , 8 , 5 , 4 , 0 , -4 }, /*[80] 0x70 p */ + { 358 , 8 , 5 , 4 , 0 , -4 }, /*[81] 0x71 q */ + { 363 , 8 , 4 , 4 , 0 , -4 }, /*[82] 0x72 r */ + { 367 , 8 , 4 , 4 , 0 , -4 }, /*[83] 0x73 s */ + { 371 , 8 , 5 , 4 , 0 , -5 }, /*[84] 0x74 t */ + { 376 , 8 , 4 , 4 , 0 , -4 }, /*[85] 0x75 u */ + { 380 , 8 , 4 , 4 , 0 , -4 }, /*[86] 0x76 v */ + { 384 , 8 , 4 , 4 , 0 , -4 }, /*[87] 0x77 w */ + { 388 , 8 , 4 , 4 , 0 , -4 }, /*[88] 0x78 x */ + { 392 , 8 , 5 , 4 , 0 , -4 }, /*[89] 0x79 y */ + { 397 , 8 , 4 , 4 , 0 , -4 }, /*[90] 0x7A z */ + { 401 , 8 , 5 , 4 , 0 , -5 }, /*[91] 0x7B braceleft */ + { 406 , 8 , 5 , 2 , 0 , -5 }, /*[92] 0x7C bar */ + { 411 , 8 , 5 , 4 , 0 , -5 }, /*[93] 0x7D braceright */ + { 416 , 8 , 2 , 4 , 0 , -5 }, /*[94] 0x7E asciitilde */ + + {418 , 8 , 5 , 4 , 0 , -5 }, /*[95] 0x7F А */ + {423 , 8 , 5 , 4 , 0 , -5 }, /*[96] 0x80 Б */ + {428 , 8 , 5 , 4 , 0 , -5 }, /*[97] 0x81 В */ + {433 , 8 , 5 , 4 , 0 , -5 }, /*[98] 0x82 Г */ + {438 , 8 , 5 , 6 , 0 , -5 }, /*[99] 0x83 Д */ + {443 , 8 , 5 , 4 , 0 , -5 }, /*[100] 0x84 Е */ + {448 , 8 , 5 , 6 , 0 , -5 }, /*[101] 0x85 Ж */ + {453 , 8 , 5 , 4 , 0 , -5 }, /*[102] 0x86 З */ + {458 , 8 , 5 , 5 , 0 , -5 }, /*[103] 0x87 И */ + {463 , 8 , 5 , 5 , 0 , -5 }, /*[104] 0x88 Й */ + {468 , 8 , 5 , 4 , 0 , -5 }, /*[105] 0x89 К */ + {473 , 8 , 5 , 4 , 0 , -5 }, /*[106] 0x8A Л */ + {478 , 8 , 5 , 6 , 0 , -5 }, /*[107] 0x8B М */ + {483 , 8 , 5 , 4 , 0 , -5 }, /*[108] 0x8C Н */ + {488 , 8 , 5 , 4 , 0 , -5 }, /*[109] 0x8D О */ + {493 , 8 , 5 , 4 , 0 , -5 }, /*[110] 0x8E П */ + {498 , 8 , 5 , 4 , 0 , -5 }, /*[111] 0x8F Р */ + {503 , 8 , 5 , 4 , 0 , -5 }, /*[112] 0x90 С */ + {508 , 8 , 5 , 4 , 0 , -5 }, /*[113] 0x91 Т */ + {513 , 8 , 5 , 4 , 0 , -5 }, /*[114] 0x92 У */ + {518 , 8 , 5 , 6 , 0 , -5 }, /*[115] 0x93 Ф */ + {523 , 8 , 5 , 4 , 0 , -5 }, /*[116] 0x94 Х */ + {528 , 8 , 5 , 5 , 0 , -5 }, /*[117] 0x95 Ц */ + {533 , 8 , 5 , 4 , 0 , -5 }, /*[118] 0x96 Ч */ + {538 , 8 , 5 , 6 , 0 , -5 }, /*[119] 0x97 Ш */ + {543 , 8 , 5 , 7 , 0 , -5 }, /*[120] 0x98 Щ */ + {548 , 8 , 5 , 5 , 0 , -5 }, /*[121] 0x99 Ъ */ + {553 , 8 , 5 , 6 , 0 , -5 }, /*[122] 0x9A Ы */ + {558 , 8 , 5 , 4 , 0 , -5 }, /*[123] 0x9B Ь */ + {563 , 8 , 5 , 4 , 0 , -5 }, /*[124] 0x9C Э */ + {568 , 8 , 5 , 6 , 0 , -5 }, /*[125] 0x9D Ю */ + {573 , 8 , 5 , 4 , 0 , -5 }, /*[126] 0x9E Я */ + {578 , 8 , 7 , 4 , 0 , -5 }, /*[127] 0x9F Ґ */ + {585 , 8 , 5 , 4 , 0 , -5 }, /*[128] 0xA0 Є */ + + {590 , 8 , 5 , 2 , 0 , -5 } , /*[129] 0xA1 exclamdown */ + {595 , 8 , 5 , 4 , 0 , -5 } , /*[130] 0xA2 cent */ + {600 , 8 , 5 , 4 , 0 , -5 } , /*[131] 0xA3 sterling */ + {605 , 8 , 5 , 4 , 0 , -5 } , /*[132] 0xA4 currency */ + {610 , 8 , 5 , 4 , 0 , -5 } , /*[133] 0xA5 yen */ + {615 , 8 , 5 , 2 , 0 , -5 } , /*[134] 0xA6 brokenbar */ + {620 , 8 , 5 , 4 , 0 , -5 } , /*[135] 0xA7 section */ + {625 , 8 , 1 , 4 , 0 , -5 } , /*[136] 0xA8 dieresis */ + {626 , 8 , 3 , 4 , 0 , -5 } , /*[137] 0xA9 copyright */ + {629 , 8 , 5 , 4 , 0 , -5 } , /*[138] 0xAA ordfeminine */ + {634 , 8 , 3 , 3 , 0 , -5 } , /*[139] 0xAB guillemotleft */ + {637 , 8 , 2 , 4 , 0 , -4 } , /*[140] 0xAC logicalnot */ + {639 , 8 , 1 , 3 , 0 , -3 } , /*[141] 0xAD softhyphen */ + {640 , 8 , 3 , 4 , 0 , -5 } , /*[142] 0xAE registered */ + {643 , 8 , 1 , 4 , 0 , -5 } , /*[143] 0xAF macron */ + {644 , 8 , 3 , 3 , 0 , -5 } , /*[144] 0xB0 degree */ + {647 , 8 , 5 , 4 , 0 , -5 } , /*[145] 0xB1 plusminus */ + {652 , 8 , 3 , 4 , 0 , -5 } , /*[146] 0xB2 twosuperior */ + {655 , 8 , 3 , 4 , 0 , -5 } , /*[147] 0xB3 threesuperior */ + {658 , 8 , 2 , 3 , 0 , -5 } , /*[148] 0xB4 acute */ + {660 , 8 , 5 , 4 , 0 , -5 } , /*[149] 0xB5 mu */ + {665 , 8 , 5 , 4 , 0 , -5 } , /*[150] 0xB6 paragraph */ + {670 , 8 , 3 , 4 , 0 , -4 } , /*[151] 0xB7 periodcentered */ + {673 , 8 , 3 , 4 , 0 , -3 } , /*[152] 0xB8 cedilla */ + {676 , 8 , 3 , 2 , 0 , -5 } , /*[153] 0xB9 onesuperior */ + {679 , 8 , 5 , 4 , 0 , -5 } , /*[154] 0xBA ordmasculine */ + {684 , 8 , 3 , 3 , 0 , -5 } , /*[155] 0xBB guillemotright */ + {687 , 8 , 5 , 4 , 0 , -5 } , /*[156] 0xBC onequarter */ + {692 , 8 , 5 , 4 , 0 , -5 } , /*[157] 0xBD onehalf */ + {697 , 8 , 5 , 4 , 0 , -5 } , /*[158] 0xBE threequarters */ + {702 , 8 , 5 , 4 , 0 , -5 } , /*[159] 0xBF questiondown */ + {707 , 8 , 5 , 4 , 0 , -5 } , /*[160] 0xC0 Agrave */ + {712 , 8 , 5 , 4 , 0 , -5 } , /*[161] 0xC1 Aacute */ + {717 , 8 , 5 , 4 , 0 , -5 } , /*[162] 0xC2 Acircumflex */ + {722 , 8 , 5 , 4 , 0 , -5 } , /*[163] 0xC3 Atilde */ + {727 , 8 , 5 , 4 , 0 , -5 } , /*[164] 0xC4 Adieresis */ + {732 , 8 , 5 , 4 , 0 , -5 } , /*[165] 0xC5 Aring */ + {737 , 8 , 5 , 4 , 0 , -5 } , /*[166] 0xC6 AE */ + {742 , 8 , 6 , 4 , 0 , -5 } , /*[167] 0xC7 Ccedilla */ + {748 , 8 , 5 , 4 , 0 , -5 } , /*[168] 0xC8 Egrave */ + {753 , 8 , 5 , 4 , 0 , -5 } , /*[169] 0xC9 Eacute */ + {758 , 8 , 5 , 4 , 0 , -5 } , /*[170] 0xCA Ecircumflex */ + {763 , 8 , 5 , 4 , 0 , -5 } , /*[171] 0xCB Edieresis */ + {768 , 8 , 5 , 4 , 0 , -5 } , /*[172] 0xCC Igrave */ + {773 , 8 , 5 , 4 , 0 , -5 } , /*[173] 0xCD Iacute */ + {778 , 8 , 5 , 4 , 0 , -5 } , /*[174] 0xCE Icircumflex */ + {783 , 8 , 5 , 4 , 0 , -5 } , /*[175] 0xCF Idieresis */ + {788 , 8 , 5 , 4 , 0 , -5 } , /*[176] 0xD0 Eth */ + {793 , 8 , 5 , 4 , 0 , -5 } , /*[177] 0xD1 Ntilde */ + {798 , 8 , 5 , 4 , 0 , -5 } , /*[178] 0xD2 Ograve */ + {803 , 8 , 5 , 4 , 0 , -5 } , /*[179] 0xD3 Oacute */ + {808 , 8 , 5 , 4 , 0 , -5 } , /*[180] 0xD4 Ocircumflex */ + {813 , 8 , 5 , 4 , 0 , -5 } , /*[181] 0xD5 Otilde */ + {818 , 8 , 5 , 4 , 0 , -5 } , /*[182] 0xD6 Odieresis */ + {823 , 8 , 3 , 4 , 0 , -4 } , /*[183] 0xD7 multiply */ + {826 , 8 , 5 , 4 , 0 , -5 } , /*[184] 0xD8 Oslash */ + {831 , 8 , 5 , 4 , 0 , -5 } , /*[185] 0xD9 Ugrave */ + {836 , 8 , 5 , 4 , 0 , -5 } , /*[186] 0xDA Uacute */ + {841 , 8 , 5 , 4 , 0 , -5 } , /*[187] 0xDB Ucircumflex */ + {846 , 8 , 5 , 4 , 0 , -5 } , /*[188] 0xDC Udieresis */ + {851 , 8 , 5 , 4 , 0 , -5 } , /*[189] 0xDD Yacute */ + {856 , 8 , 5 , 4 , 0 , -5 } , /*[190] 0xDE Thorn */ + {861 , 8 , 6 , 4 , 0 , -5 } , /*[191] 0xDF germandbls */ + {867 , 8 , 5 , 4 , 0 , -5 } , /*[192] 0xE0 agrave */ + {872 , 8 , 5 , 4 , 0 , -5 } , /*[193] 0xE1 aacute */ + {877 , 8 , 5 , 4 , 0 , -5 } , /*[194] 0xE2 acircumflex */ + {882 , 8 , 5 , 4 , 0 , -5 } , /*[195] 0xE3 atilde */ + {887 , 8 , 5 , 4 , 0 , -5 } , /*[196] 0xE4 adieresis */ + {892 , 8 , 5 , 4 , 0 , -5 } , /*[197] 0xE5 aring */ + {897 , 8 , 4 , 4 , 0 , -4 } , /*[198] 0xE6 ae */ + {901 , 8 , 5 , 4 , 0 , -4 } , /*[199] 0xE7 copy&pasteistrash */ + {906 , 8 , 5 , 4 , 0 , -5 } , /*[200] 0xE8 egrave */ + {911 , 8 , 5 , 4 , 0 , -5 } , /*[201] 0xE9 eacute */ + {916 , 8 , 5 , 4 , 0 , -5 } , /*[202] 0xEA ecircumflex */ + {921 , 8 , 5 , 4 , 0 , -5 } , /*[203] 0xEB edieresis */ + {926 , 8 , 5 , 3 , 0 , -5 } , /*[204] 0xEC igrave */ + {931 , 8 , 5 , 3 , 0 , -5 } , /*[205] 0xED iacute */ + {936 , 8 , 5 , 4 , 0 , -5 } , /*[206] 0xEE fckpixelit */ + {941 , 8 , 5 , 4 , 0 , -5 } , /*[207] 0xEF idieresis */ + {946 , 8 , 5 , 4 , 0 , -5 } , /*[208] 0xF0 eth */ + {951 , 8 , 5 , 4 , 0 , -5 } , /*[209] 0xF1 ntilde */ + {956 , 8 , 5 , 4 , 0 , -5 } , /*[210] 0xF2 ograve */ + {961 , 8 , 5 , 4 , 0 , -5 } , /*[211] 0xF3 oacute */ + {966 , 8 , 5 , 4 , 0 , -5 } , /*[212] 0xF4 ocircumflex */ + {971 , 8 , 5 , 4 , 0 , -5 } , /*[213] 0xF5 otilde */ + {976 , 8 , 5 , 4 , 0 , -5 } , /*[214] 0xF6 odieresis */ + {981 , 8 , 5 , 4 , 0 , -5 } , /*[215] 0xF7 divide */ + {986 , 8 , 4 , 4 , 0 , -4 } , /*[216] 0xF8 oslash */ + {990 , 8 , 5 , 4 , 0 , -5 } , /*[217] 0xF9 ugrave */ + {995 , 8 , 5 , 4 , 0 , -5 } , /*[218] 0xFA uacute */ + {1000 , 8 , 5 , 4 , 0 , -5 }, /*[219] 0xFB ucircumflex */ + {1005 , 8 , 5 , 4 , 0 , -5 }, /*[220] 0xFC udieresis */ + {1010 , 8 , 6 , 4 , 0 , -5 }, /*[221] 0xFD yacute */ + {1016 , 8 , 5 , 4 , 0 , -4 }, /*[222] 0xFE thorn */ + {1021 , 8 , 6 , 4 , 0 , -5 }, /*[223] 0xFF ydieresis */ +}; + +const GFXfont AwtrixFont PROGMEM = { + (uint8_t *)AwtrixBitmaps, + (GFXglyph *)AwtrixFontGlyphs, + 0x20, 0xFF, 6}; + +// ── Pixel-width helper (defined after font data so AwtrixFont is in scope) ───── +// Returns the total pixel width of a null-terminated string. +static int dpxTextPixelWidth(const char* str) { + if (!str) return 0; + int w = 0; + while (*str) { + uint8_t c = (uint8_t)*str++; + if (c < AwtrixFont.first || c > AwtrixFont.last) continue; + GFXglyph glyph; + memcpy_P(&glyph, &AwtrixFont.glyph[c - AwtrixFont.first], sizeof(GFXglyph)); + w += glyph.xAdvance; + } + // Trim trailing 1-px gap of last character + if (w > 0) w--; + return w; +} diff --git a/usermods/dpx_matrix/dpx_html.h b/usermods/dpx_matrix/dpx_html.h new file mode 100644 index 0000000000..140ba7c5ce --- /dev/null +++ b/usermods/dpx_matrix/dpx_html.h @@ -0,0 +1,1361 @@ +// ================================================================================ +// dpx_html.h — PROGMEM Web UI Pages +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// Pages: /api-ref, /browse, /ctrl, /screen, /fullscreen +// Ported from dpx_tc001/src/htmls.h (original work, not AWTRIX-derived). +// EXCLUDED: custom_html, screen_html, backup_html (contain AWTRIX references). +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ + +#pragma once + +static const char apiref_html[] PROGMEM = R"APIREF( +API Reference + +

📄 API Reference

+ + + + + +

HTTP Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodURLBody / Notes
GET/api/statsDevice stats (RAM, uptime, battery…)
GET/api/screenCurrent matrix as 256× 24-bit int array
GET/api/effectsArray of effect name strings
GET/api/transitionsArray of transition name strings
GET/api/loopOrdered app list
GET/api/appsApp list with icons
GET/api/settingsCurrent settings JSON
POST/api/notifyOne-shot notification — see JSON keys below
POST/api/notify/dismissDismiss held notification (empty body)
POST/api/custom?name=appnameCreate/update persistent custom app
POST/api/switch{"name":"Time"} — jump to app
POST/api/nextappempty body
POST/api/previousappempty body
POST/api/power{"power":true}
POST/api/sleep{"sleep":60} — seconds
POST/api/settingsSettings JSON — see keys below
POST/api/moodlight{"color":[255,80,0],"brightness":170}
POST/api/indicator1 /2 /3{"color":[255,0,0],"blink":500}
POST/api/rtttlRaw RTTTL string as plain body
POST/api/sound{"sound":"alarm"}
POST/api/reorderArray of app name strings — set loop order
POST/api/doupdateTrigger OTA firmware update (empty body)
GET/api/timeCurrent device time: {"local":"2026-07-14T15:30:00","utc":1752506200}
POST/api/time{"utc":1752506200} — set clock via settimeofday()
POST/api/syncntpRe-trigger NTP sync with current server + timezone settings
POST/api/rename{"from":"/ICONS/old.jpg","to":"/ICONS/new.jpg"}
GET/api/rebootReboot device
GET/screenLive view page
GET/ctrlControl panel
GET/browseCommunity browser
+ +

Quick curl examples

+
curl -X POST http://[IP]/api/notify \ + -H "Content-Type: application/json" \ + -d '{"text":"Hello!","color":[255,200,0],"icon":"87","duration":8}'
+
curl -X POST http://[IP]/api/custom?name=myapp \ + -H "Content-Type: application/json" \ + -d '{"text":"Always on","rainbow":true,"scrollSpeed":80}'
+ + +

Notify & Custom App — JSON Keys

+

All keys optional. N=notify only   C=custom app only   B=both

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTypeDescriptionDefault
textstring/arrayText to display. Array = colored fragments: [{"t":"Hi","c":"FF0000"}]B
textCaseint0=global, 1=UPPER, 2=as-sent0B
topTextboolDraw text on top rowfalseB
textOffsetintX-offset for text start0B
centerboolCenter short non-scrolling texttrueB
colorstr/[r,g,b]Text/bar/line colorB
gradient[c1,c2]Two-color text gradientB
backgroundstr/[r,g,b]Background fill colorB
rainbowboolRGB rainbow per letterfalseB
blinkTextintBlink text every N msB
fadeTextintFade text in/out every N msB
iconstringIcon filename (no ext) or LaMetric ID, or 8×8 JPG as base64B
pushIconint0=fixed, 1=scroll+gone, 2=scroll+loop0B
noScrollboolDisable text scrollingfalseB
scrollSpeedintScroll speed % of default100B
durationintDisplay time in seconds5B
repeatintTimes to scroll before ending (-1=forever)-1B
holdboolHold notification until dismissedfalseN
stackboolStack notification; false=replace currenttrueN
wakeupboolWake matrix from sleep for notificationfalseN
soundstringRTTTL filename (no ext) or DFPlayer 4-digit numberN
rtttlstringInline RTTTL stringN
loopSoundboolLoop sound for notification durationfalseN
effectstringBackground effect name (see Effects)B
effectSettingsobject{"speed":3,"palette":"Rainbow","blend":true}B
overlaystringPer-app overlay: snow/rain/drizzle/storm/thunder/frost/clearB
barint[]Bar chart data (max 16 values, 11 with icon)B
lineint[]Line chart data (same limits)B
autoscaleboolAutoscale bar/line charttrueB
barBCstr/[r,g,b]Bar background color0B
progressintProgress bar 0–100 (-1=off)-1B
progressCstr/[r,g,b]Progress bar fill colorB
progressBCstr/[r,g,b]Progress bar background colorB
drawobject[]Drawing instructions array — see Draw CommandsB
lifetimeintRemove custom app if no update after N seconds0C
posintLoop position (0-based, set on first push)C
saveboolPersist custom app across reboots (avoid for high-freq updates)falseC
clientsstring[]Forward notification to other devices (IP or MQTT prefix)N
+ + +

Settings Keys — POST /api/settings or GET to read

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTypeDescriptionRange
BRIintBrightness0–255
ABRIboolAuto brightnesstrue/false
ATIMEintApp display duration (s)>0
TEFFintTransition effect index0–10
TSPEEDintTransition duration (ms)>0
ATRANSboolAuto advance appstrue/false
TCOLstr/[r,g,b]Global text color
TMODEintTime app layout (0–6)0–6
TFORMATstringTime format e.g. %H:%M
DFORMATstringDate format e.g. %d.%m.%y
UPPERCASEboolForce uppercase text globallytrue/false
SSPEEDintGlobal scroll speed %1–200
OVERLAYstringGlobal overlay effectsee Overlays
WDboolShow weekday bartrue/false
WDCAstr/[r,g,b]Active weekday color
WDCIstr/[r,g,b]Inactive weekday color
CELboolCelsius (false=Fahrenheit)true/false
SOMboolWeek starts Mondaytrue/false
BLOCKNboolBlock physical buttonstrue/false
MATPboolMatrix on/off (no animation)true/false
VOLintSpeaker/DFPlayer volume0–30
TIMboolEnable time app (reboot)true/false
DATboolEnable date app (reboot)true/false
HUMboolEnable humidity app (reboot)true/false
TEMPboolEnable temperature app (reboot)true/false
BATboolEnable battery app (reboot)true/false
CCORRECTION[r,g,b]Color correction
CTEMP[r,g,b]Color temperature
TIME_COLstr/[r,g,b]Time app text color (0=global)
DATE_COLstr/[r,g,b]Date app text color
TEMP_COLstr/[r,g,b]Temp app text color
HUM_COLstr/[r,g,b]Humidity app text color
BAT_COLstr/[r,g,b]Battery app text color
CHCOLstr/[r,g,b]Calendar header color
CBCOLstr/[r,g,b]Calendar body color
CTCOLstr/[r,g,b]Calendar text color
+ + +

Effects & Overlays — fetched live from device

+
+
+

Background Effects

+
Loading...
+
+
+

Overlay Effects

+
clear · snow · rain · drizzle · storm · thunder · frost
+
+
+

App Transitions

+
Loading...
+
+
+ + +

Draw Commands — "draw":[...] in notify/custom

+ + + + + + + + + + +
CommandValuesDescription
dp[x,y,color]Draw pixel
dl[x0,y0,x1,y1,color]Draw line
dr[x,y,w,h,color]Rectangle outline
df[x,y,w,h,color]Filled rectangle
dc[x,y,r,color]Circle outline
dfc[x,y,r,color]Filled circle
dt[x,y,"text",color]Draw text
db[x,y,w,h,[rgb888array]]Bitmap array
+
{"draw":[{"df":[0,0,8,8,"#1a1aff"]},{"dt":[9,1,"Hi","#ffffff"]}]}
+ + +

OSC (UDP port 4210)

+

Send OSC UDP packets to port 4210. Both bare addresses and /dpx_tc002/ or /awtrix/ namespace prefixes are accepted.

+ + + + + + + + + + +
AddressArgsAction
/notify or /text(s) textOne-shot notification
/custom/<name>(s) textUpdate/create persistent app
/switch(s) appnameSwitch to named app
/nextappnoneNext app
/previousappnonePrevious app
/power(i|f) 0/1Power off/on
/brightness(i|f) 0–255Set brightness
/indicator/1 /2 /3(i i i) r g bSet indicator color
+
# python-osc example +from pythonosc import udp_client +c = udp_client.SimpleUDPClient("192.168.x.x", 4210) +c.send_message("/notify", "01:23:45:12") +c.send_message("/dpx_tc002/custom/tc", "00:59:59:24")
+ + +

MQTT — broker configured in /setup · prefix set per device

+

All topics use your configured prefix (default: device hostname, e.g. awtrix_a1b2c3). Format: [PREFIX]/topic.

+ +

Inbound — device subscribes, you publish

+ + + + + + + + + + + + + + + + + + + + + + +
TopicPayloadAction
[PREFIX]/notifyJSONOne-shot notification — same JSON keys as HTTP notify
[PREFIX]/notify/dismissemptyDismiss held notification
[PREFIX]/custom/[appname]JSON or emptyCreate/update custom app; empty payload = delete
[PREFIX]/settingsJSONUpdate settings — same keys as HTTP settings
[PREFIX]/switch{"name":"Time"}Switch to named app
[PREFIX]/nextappanyNext app
[PREFIX]/previousappanyPrevious app
[PREFIX]/power{"power":true}Power on/off
[PREFIX]/sleep{"sleep":60}Deep sleep for N seconds
[PREFIX]/moodlightJSON or emptySet moodlight; empty = disable
[PREFIX]/indicator1{"color":[r,g,b],"blink":500}Indicator 1
[PREFIX]/indicator2sameIndicator 2
[PREFIX]/indicator3sameIndicator 3
[PREFIX]/sound{"sound":"alarm"}Play sound file from /MELODIES/
[PREFIX]/rtttlraw RTTTL stringPlay inline RTTTL melody
[PREFIX]/appsJSON arrayUpdate app loop order
[PREFIX]/sendscreenanyRequest device to publish current screen state
[PREFIX]/doupdateanyTrigger OTA firmware update
[PREFIX]/rebootanyReboot device
[PREFIX]/r2d2anyPlay R2D2 sound effect
+ +

Outbound — device publishes, you subscribe

+ + + + + + + + + + + +
TopicPayloadWhen
[PREFIX]/statsJSONPeriodic stats: battery, RAM, uptime, temp, humidity, lux, RSSI, IP, version
[PREFIX]/stats/currentAppstringApp name each time display switches
[PREFIX]/stats/effectsJSON arrayPublished on connect — list of effect names
[PREFIX]/stats/transitionsJSON arrayPublished on connect — list of transition names
[PREFIX]/stats/deviceonlinePublished on connect
[PREFIX]/screenJSON array (256× 24-bit int)Response to /sendscreen
[PREFIX]/buttonLefttrue/falsePhysical button press/release
[PREFIX]/buttonRighttrue/falsePhysical button press/release
[PREFIX]/buttonSelecttrue/falseMiddle button press/release
+ +
# mosquitto example — send notification +mosquitto_pub -h 192.168.x.x -t "awtrix_a1b2c3/notify" \ + -m '{"text":"hello","rainbow":true,"duration":8}' + +# create a persistent app +mosquitto_pub -h 192.168.x.x -t "awtrix_a1b2c3/custom/myticker" \ + -m '{"text":"LIVE","color":[255,0,0],"scrollSpeed":80}' + +# subscribe to button presses +mosquitto_sub -h 192.168.x.x -t "awtrix_a1b2c3/button+"
+ +
+ +)APIREF"; + +// ── Community Browser page (/browse) ───────────────────────────────────────── +// Three tabs: +// 1. LaMetric Icons – paginated grid of thumbnails loaded via (no CORS), +// one-click install to /ICONS/ on the device. Clicking any installed icon +// copies the usage snippet to the clipboard. +// 2. Bigtime GIFs – lists files from Blueforcer/awtrix3 on GitHub, one-click +// install to the device root (used by TMODE=5). +// 3. On-Device Files – lists /ICONS/, /MELODIES/, root; allows delete. +static const char browse_html[] PROGMEM = R"BROWSE( +Browse + +

☶ Browse & Install

+ + +
+
LaMetric Icons
+
Bigtime GIFs
+
On-Device Files
+
+ + +
+

Click Get to install an icon to the device. Once installed, click Use to copy its ID — paste it as the icon value in any notify/custom-app call.

+
+ + + | + + page + + + (50/page) +
+
+
+ + +
+

Official Bigtime GIFs — install to device root, then set TMODE=5 and name the file bigtime.gif.

+
Loading from GitHub...
+
+ + +
+
+

ICONS/

Loading...
+

MELODIES/

Loading...
+

Root

Loading...
+
+ +
+ +)BROWSE"; + +// Small snippet injected into /setup to link to the control panel +static const char ctrl_nav_html[] PROGMEM = R"EOF( + +)EOF"; + +static const char ctrl_html[] PROGMEM = R"EOF( +dpx_tc002 + +

■ dpx_tc002

+ +
+ +
+

Notification

+ +
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + +
+
+ +
+

Custom App

+

A custom app lives permanently in the rotation loop alongside Time/Date/Temp — it cycles through automatically and stays until you remove it. A Notification (above) is one-shot: it interrupts once then vanishes.

+
+
+
+ + +
+
+
+ +
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + + +
+
+ +
+

Indicators

+
+
+ 1 +
+
+
+ + +
+
+ 2 +
+
+
+ + +
+
+ 3 +
+
+
+ + +
+
+
+ +
+

Moodlight

+ +
+
+
+
+
+ +
+ + +
+
+ +
+

Display

+ +
128
+ +
+ +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+

App Channels

+

Each custom app is a named channel. The name is the OSC address and MQTT topic suffix — send to it from anywhere to update the display.

+
+ MQTT prefix loading... +
+
Loading...
+
+

Create Channel

+
+ + + +
+

After creating, push updates via OSC or MQTT using the channel name shown above.

+
+ +
+

OSC Listeners — map OSC paths to display channels

+

Register any OSC address. When a packet arrives the first argument is shown on the named channel. Use channel tc for timecode (triggers TC display mode).

+
+
+ + + +
+
+ + +
+
+ + +
+
+
+ +
+
Loading...
+
+ +
+

TC Settings

+

Controls how the timecode display behaves when LTC frames arrive via OSC.

+
+
+
+
+ + +
+
+
+
+ +
+ +
+

Native Apps

+

Toggle which built-in apps appear in the rotation. Changes take effect immediately.

+
+
+
+
+
+
+
+ +
+ +
+

Time

+

Device syncs via NTP on boot. Set manually if NTP is unavailable or time is wrong.

+ +
+ +
+ +
+ + +
+
+ +
+ + + +
+
+ + re-polls configured NTP server +
+
+ +
+

Sensors

+

Offsets apply immediately and persist across reboots. The ESP32's internal temp sensor runs hot — a negative offset around -9°C is typical.

+ +
+
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+ +
+

Sound

+
+
+
Volume: n/a — passive piezo
+ +
+
+ +
+ +
+ +
+ + +
+
+ +
+ + + +
+
+ +
+
+ +)EOF"; + +static const char screenfull_html[] PROGMEM = R"EOF( + LiveView +)EOF"; \ No newline at end of file diff --git a/usermods/dpx_matrix/dpx_matrix.cpp b/usermods/dpx_matrix/dpx_matrix.cpp new file mode 100644 index 0000000000..d480234eb8 --- /dev/null +++ b/usermods/dpx_matrix/dpx_matrix.cpp @@ -0,0 +1,25 @@ +// ================================================================================ +// dpx_matrix.cpp — Usermod Entry Point +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_matrix.cpp +// Purpose: Includes wled.h (gives access to strip, server, bri, etc.) and then +// the full dpx_matrix usermod implementation. +// WLED's build system compiles this as a separate library and links it. +// +// ================================================================================ + +#include "wled.h" +#include "dpx_matrix.h" + +// ── Global definitions ──────────────────────────────────────────────────────── +// dpxIndicator is declared extern in dpx_osc.h; one definition here. +uint32_t dpxIndicator[3] = {0, 0, 0}; + +// ── Usermod registration ────────────────────────────────────────────────────── +static DpxMatrix dpxMatrixMod; +REGISTER_USERMOD(dpxMatrixMod); diff --git a/usermods/dpx_matrix/dpx_matrix.h b/usermods/dpx_matrix/dpx_matrix.h new file mode 100644 index 0000000000..a0d0b78566 --- /dev/null +++ b/usermods/dpx_matrix/dpx_matrix.h @@ -0,0 +1,398 @@ +// ================================================================================ +// dpx_matrix.h — Main Usermod Class +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_matrix.h +// Purpose: WLED Usermod class that takes over the 32×8 LED matrix to display +// text apps, scrolling text, timecode, and notifications. Also registers +// custom HTTP API routes and an OSC UDP receiver. +// +// Activate in platformio_override.ini: +// custom_usermods = dpx_matrix +// +// WLED setup required (via WLED web UI or startup config): +// - LED Type: WS2812B (RGB) +// - GPIO: 32 +// - LED Count: 256 +// - 2D Matrix: width=32, height=8, serpentine=no +// +// ================================================================================ + +#pragma once + +// Include all module headers in dependency order +#include "dpx_firstboot.h" +#include "dpx_buzzer.h" +#include "dpx_font.h" +#include "dpx_text.h" +#include "dpx_persist.h" +#include "dpx_apps.h" +#include "dpx_notifications.h" +#include "dpx_tc.h" +#include "dpx_osc.h" +#include "dpx_overlay.h" +#include "dpx_mqtt.h" +#include "dpx_api.h" + +// ── dpx Matrix WLED effect ──────────────────────────────────────────────────── +// Registered as a proper WLED effect so brightness, transitions, and power-fade +// all work natively. Replaces the old handleOverlayDraw() hack. +// _dpxEffectId is declared in dpx_apps.h and assigned in DpxMatrix::setup(). + +static void mode_dpx_matrix() { + // Notifications take priority + if (dpxNotifTick()) { + dpxRenderNotification(); + } else { + dpxRenderCurrentApp(); + } + // Text overlay + pixel effects on top + dpxRenderOverlays(); + // Corner indicator dots (topmost layer) + if (dpxIndicator[0]) SEGMENT.setPixelColorXY(0, 0, dpxIndicator[0]); + if (dpxIndicator[1]) SEGMENT.setPixelColorXY(31, 0, dpxIndicator[1]); + if (dpxIndicator[2]) SEGMENT.setPixelColorXY(0, 7, dpxIndicator[2]); +} + +class DpxMatrix : public Usermod { + +private: + bool _initDone = false; + bool dpxEnabled = true; + +public: + static const char _name[]; + + void setup() override { + // Write default cfg.json on first boot if LittleFS has none. + // Must run before dpxLoadDev() so WLED picks it up on next reboot. + dpxFirstBoot(); + + // Load persistent settings + dpxLoadDev(); + dpxLoadOscListeners(); + + // Build the initial app loop (Time, Date only to start) + dpxRebuildLoop(); + dpxAppStartMs = millis(); + + // Initialise TC001 buzzer pin; drives LOW to prevent float noise. + dpxBuzzerInit(); + + // NOTE: dpxOscBegin() is called in connected() once WiFi is up. + // Opening a UDP socket before lwip is ready crashes the device. + + // Register dpx Matrix as a WLED effect — this replaces handleOverlayDraw. + // Using SEGMENT APIs means brightness/transitions/power-fade work natively. + _dpxEffectId = strip.addEffect(255, &mode_dpx_matrix, + "dpx Matrix;!;2"); // 255 = auto-assign next available ID + // Switch the main segment to our effect on startup + strip.getMainSegment().setMode(_dpxEffectId); + stateUpdated(CALL_MODE_INIT); + + // Register HTTP routes + dpxRegisterRoutes(); + + _initDone = true; + DEBUG_PRINTF("DpxMatrix: setup complete, effect id=%d\n", _dpxEffectId); + } + + void connected() override { + // Safe to open UDP socket now that WiFi/lwip is ready + dpxOscBegin(); + + // Re-apply POSIX timezone AFTER WLED's configTime() call (which runs on + // WiFi connect and overwrites TZ with a dumb UTC-offset string). + // configTzTime sets TZ env + kicks off an NTP sync in one shot. + if (DPX_TIMEZONE.length()) { + configTzTime(DPX_TIMEZONE.c_str(), ntpServerName); + DEBUG_PRINTF("DpxMatrix: TZ applied: %s\n", DPX_TIMEZONE.c_str()); + } + + DEBUG_PRINTLN(F("DpxMatrix: WiFi connected, OSC UDP started")); + + // Print IP prominently — visible any time serial monitor is open + String ip = WiFi.localIP().toString(); + Serial.println(); + Serial.println(F("┌─────────────────────────────┐")); + Serial.print (F("│ dpx_tc002 IP: ")); + Serial.print (ip); + Serial.println(F(" │")); + Serial.println(F("└─────────────────────────────┘")); + Serial.println(); + Serial.printf("[dpx] IP: %s\n", ip.c_str()); + StaticJsonDocument<128> doc; + doc["text"] = ip; + doc["color"] = "#00FF88"; + doc["repeat"] = 1; + String s; serializeJson(doc, s); + dpxPushNotification(s.c_str()); + } + + void loop() override { + if (!_initDone) return; + + // Rate-limit to ~50Hz max. Constant UDP/timer polling on every Arduino + // loop tick (which runs at 300-1000Hz) hammers the lwip socket layer + // and slows down HTTP/WebSocket responses. + static unsigned long _lastLoopMs = 0; + unsigned long now = millis(); + if (now - _lastLoopMs < 20) return; + _lastLoopMs = now; + + // ── Serial debug command handler ──────────────────────────────── + // Send a character to get status info. Commands: + // ? / h — help + // s — status (IP, app, time, RSSI, heap) + // r — reboot + if (Serial.available()) { + char cmd = Serial.read(); + while (Serial.available()) Serial.read(); // flush + switch (cmd) { + case '?': case 'h': + Serial.println(F("[dpx] commands: s=status r=reboot h=help")); + break; + case 's': { + Serial.printf("[dpx] IP : %s\n", WiFi.localIP().toString().c_str()); + Serial.printf("[dpx] AP SSID : %s (%s) clients=%d\n", + apSSID, + WiFi.softAPIP().toString().c_str(), + WiFi.softAPgetStationNum()); + Serial.printf("[dpx] WiFi : %s (RSSI %d dBm)\n", + WiFi.SSID().c_str(), WiFi.RSSI()); + Serial.printf("[dpx] Heap : %u free / %u total\n", + ESP.getFreeHeap(), ESP.getHeapSize()); + Serial.printf("[dpx] App : %s (#%u of %u)\n", + dpxCurrentApp < dpxApps.size() ? dpxApps[dpxCurrentApp].name.c_str() : "?", + (unsigned)dpxCurrentApp, (unsigned)dpxApps.size()); + Serial.printf("[dpx] Notifs : %u queued\n", (unsigned)dpxNotifQueue.size()); + Serial.printf("[dpx] Time : %02d:%02d:%02d (localTime=%lu)\n", + hour(localTime), minute(localTime), second(localTime), (unsigned long)localTime); + Serial.printf("[dpx] Uptime : %lus\n", millis() / 1000); + Serial.printf("[dpx] MQTT : %s\n", WLED_MQTT_CONNECTED ? "connected" : "disconnected"); + Serial.printf("[dpx] OSC UDP : %s port %d\n", dpxUdpStarted ? "started" : "stopped", DPX_OSC_PORT); + break; + } + case 'r': + Serial.println(F("[dpx] rebooting...")); + delay(100); ESP.restart(); + break; + default: + Serial.printf("[dpx] unknown command '%c' — send h for help\n", cmd); + } + } + static bool _serialWasConnected = false; + bool serialNow = (bool)Serial; + if (serialNow && !_serialWasConnected && WiFi.localIP()[0] != 0) { + String ip = WiFi.localIP().toString(); + Serial.println(); + Serial.println(F("┌─────────────────────────────┐")); + Serial.print (F("│ dpx_tc002 IP: ")); + Serial.print (ip); + Serial.println(F(" │")); + Serial.println(F("└─────────────────────────────┘")); + Serial.println(); + } + _serialWasConnected = serialNow; + + // Advance app pointer when duration expires + dpxAppLoopTick(); + + // TC dwell timeout — restore auto-transition after TC signal stops + dpxTcDwellTick(); + + // Receive OSC packets + dpxOscTick(); + + // Advance RTTTL note sequencer + dpxBuzzerTick(); + } + + // ── Button handling ──────────────────────────────────────────────────── + // Called by WLED's button loop on every tick for each configured button. + // Returning true consumes the event — WLED will not act on it. + // We use WLED's APIs (toggleOnOff, stateUpdated) for WLED-level actions. + // + // Button layout (TC001 front, left→right): + // b=0 GPIO DPX_BTN_LEFT short=prev app long=dismiss notification + // b=1 GPIO DPX_BTN_MID short=next app long=cycle WLED effect + // b=2 GPIO DPX_BTN_RIGHT short=power tog long=show IP + bool handleButton(uint8_t b) override { + if (!_initDone || b > 2) return false; + + static const uint8_t PINS[3] = {DPX_BTN_LEFT, DPX_BTN_MID, DPX_BTN_RIGHT}; + static unsigned long pressStart[3] = {0, 0, 0}; + static bool wasPressed[3] = {false, false, false}; + + bool pressed = (digitalRead(PINS[b]) == LOW); // active-low + unsigned long now = millis(); + + if (pressed && !wasPressed[b]) { + pressStart[b] = now; // rising edge + } else if (!pressed && wasPressed[b]) { + // falling edge — determine action + unsigned long dur = now - pressStart[b]; + if (dur >= 30) { // debounce threshold + bool lng = (dur > 800); + switch (b) { + case 0: // LEFT + lng ? dpxDismissNotification() : dpxPrevApp(); + break; + case 1: // MIDDLE — next app / long=cycle effect + if (lng) { + effectCurrent = (effectCurrent + 1) % strip.getModeCount(); + stateChanged = true; + colorUpdated(CALL_MODE_BUTTON); + } else { + dpxNextApp(); + } + break; + case 2: // RIGHT — power toggle / long=show IP + if (lng) { + String ip = WiFi.localIP().toString(); + StaticJsonDocument<64> doc; + doc["text"] = ip; doc["color"] = "#00FF88"; + String s; serializeJson(doc, s); + dpxPushNotification(s.c_str()); + } else { + toggleOnOff(); + stateUpdated(CALL_MODE_BUTTON); + } + break; + } + } + } + wasPressed[b] = pressed; + return true; // always consume — prevent WLED double-acting on these pins + } + + // handleOverlayDraw() removed — rendering now happens in mode_dpx_matrix() + // which is a proper WLED effect registered in setup(). This gives correct + // brightness scaling, transitions, and power-fade for free. + + // ── MQTT integration ────────────────────────────────────────────────── + void onMqttConnect(bool /*sessionPresent*/) override { + dpxMqttConnect(); + } + + bool onMqttMessage(char* topic, char* payload) override { + // dpx_mqtt.h handles dpx/* and {deviceTopic}/dpx/* topics + if (dpxMqttMessage(topic, payload)) return true; + return false; + } + + // ── JSON state integration (POST /json {"dpx":{...}}) ───────────────── + void readFromJsonState(JsonObject& obj) override { + JsonObject dpx = obj[F("dpx")]; + if (dpx.isNull()) return; + + // Notifications + if (dpx.containsKey(F("notify"))) { + String s; serializeJson(dpx[F("notify")], s); + dpxPushNotification(s.c_str()); + } + if (dpx.containsKey(F("notify_dismiss"))) dpxDismissNotification(); + + // Timecode + if (dpx.containsKey(F("tc"))) { + String tc = dpx[F("tc")].as(); tc.trim(); + if (tc.length() >= 8) dpxPushTC(tc); + } + + // Mute/unmute channels: {"mute": {"Time": true, "WLED": false}} + if (dpx.containsKey(F("mute"))) { + JsonObject mutes = dpx[F("mute")].as(); + for (JsonPair kv : mutes) + dpxMuteApp(String(kv.key().c_str()), kv.value().as()); + } + + // Enable/disable matrix overlay + if (dpx.containsKey(F("enabled"))) dpxEnabled = dpx[F("enabled")].as(); + + // App loop control + if (dpx.containsKey(F("nextapp"))) dpxNextApp(); + if (dpx.containsKey(F("previousapp"))) dpxPrevApp(); + if (dpx.containsKey(F("switch"))) { + String s; serializeJson(dpx[F("switch")], s); + dpxSwitchToApp(s.c_str()); + } + + // Custom app upsert: {"app":{"name":{...}}} + if (dpx.containsKey(F("app"))) { + JsonObject apps = dpx[F("app")].as(); + for (JsonPair kv : apps) { + String v; serializeJson(kv.value(), v); + dpxSetCustomApp(String(kv.key().c_str()), v.c_str()); + } + } + + // Text overlay + if (dpx.containsKey(F("overlay"))) { + String s; serializeJson(dpx[F("overlay")], s); + dpxSetOverlay(s.c_str()); + } + + // Pixel effect + if (dpx.containsKey(F("effect"))) { + String s; serializeJson(dpx[F("effect")], s); + dpxSetPixelEffect(s.c_str()); + } + + // Indicators + for (int i = 1; i <= 3; i++) { + String key = String(F("indicator")) + i; + if (dpx.containsKey(key)) { + JsonArray a = dpx[key].as(); + if (a.size() >= 3) + dpxIndicator[i-1] = ((uint32_t)(uint8_t)a[0] << 16) + | ((uint32_t)(uint8_t)a[1] << 8) + | (uint32_t)(uint8_t)a[2]; + else + dpxIndicator[i-1] = 0; + } + } + } + + void addToJsonState(JsonObject& obj) override { + JsonObject dpx = obj.createNestedObject(F("dpx")); + dpx[F("enabled")] = dpxEnabled; + dpx[F("app")] = (dpxCurrentApp < dpxApps.size()) + ? dpxApps[dpxCurrentApp].name : String(); + dpx[F("notif")] = (int)dpxNotifQueue.size(); + dpx[F("autoTrans")] = dpxAutoTrans; + dpx[F("overlay")] = dpxTextOverlay.active ? dpxTextOverlay.text : String(); + dpx[F("effect")] = dpxPixelEffect.active ? dpxPixelEffect.name : String(); + } + + uint16_t getId() override { return USERMOD_ID_DPX_MATRIX; } + + void addToJsonInfo(JsonObject& root) override { + JsonObject user = root["u"].isNull() ? root.createNestedObject("u") : root["u"]; + user[FPSTR(_name)] = F("dpx_tc002 matrix active"); + } + + // ── Usermod settings persist (WLED /cfg.json) ───────────────────────── + void addToConfig(JsonObject& root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top["enabled"] = dpxEnabled; + top["atime"] = DPX_ATIME; + top["atrans"] = DPX_ATRANS; + top["sspeed"] = DPX_SSPEED; + } + + bool readFromConfig(JsonObject& root) override { + JsonObject top = root[FPSTR(_name)]; + if (top.isNull()) return false; + if (top.containsKey("enabled")) dpxEnabled = top["enabled"].as(); + if (top.containsKey("atime")) DPX_ATIME = top["atime"]; + if (top.containsKey("atrans")) DPX_ATRANS = top["atrans"]; + if (top.containsKey("sspeed")) DPX_SSPEED = top["sspeed"]; + return true; + } +}; + +const char DpxMatrix::_name[] PROGMEM = "DpxMatrix"; diff --git a/usermods/dpx_matrix/dpx_mqtt.h b/usermods/dpx_matrix/dpx_mqtt.h new file mode 100644 index 0000000000..daac689776 --- /dev/null +++ b/usermods/dpx_matrix/dpx_mqtt.h @@ -0,0 +1,218 @@ +// ================================================================================ +// dpx_mqtt.h — MQTT Integration +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_mqtt.h +// Purpose: Subscribe to dpx/# topics and dispatch to dpx_matrix modules. +// Uses WLED's existing AsyncMqttClient connection (global `mqtt`). +// +// Topic convention — subscribe root is: {mqttDeviceTopic}/dpx/# +// (e.g. "wled/AABBCCDDEEFF/dpx/#") +// Also accepts the short alias "dpx/#" for compatibility with dpx_tc001 senders. +// +// Supported topics (payload is JSON unless noted): +// .../dpx/notify → push notification {"text":"...","duration":5} +// .../dpx/notify/dismiss → dismiss active notification (payload ignored) +// .../dpx/tc → timecode string "HH:MM:SS:FF" (plain text or JSON) +// .../dpx/switch → switch to app {"name":"clock"} or plain app name +// .../dpx/nextapp → advance app loop (payload ignored) +// .../dpx/previousapp → step back in app loop (payload ignored) +// .../dpx/app/ → create/update custom app by name +// .../dpx/indicator/<1-3> → set indicator pixel {"color":[r,g,b]} or "" to clear +// .../dpx/power → {"power":true/false} +// .../dpx/brightness → {"bri":128} (0–255) +// .../dpx/rtttl → raw RTTTL string or JSON {"rtttl":"..."} — "stop" silences +// +// ================================================================================ + +#pragma once +#include "dpx_apps.h" +#include "dpx_notifications.h" +#include "dpx_tc.h" +#include "dpx_overlay.h" + +// dpxIndicator is defined in dpx_matrix.cpp; declared extern in dpx_osc.h +extern uint32_t dpxIndicator[3]; + +// Relative sub-path we attach to mqttDeviceTopic. Must match what senders use. +static const char DPX_MQTT_SUB[] PROGMEM = "/dpx/#"; +// Short alias accepted regardless of device topic (for cross-device compatibility) +static const char DPX_MQTT_ALIAS[] PROGMEM = "dpx/#"; + +// ── Internal helpers ────────────────────────────────────────────────────────── + +// Strip the leading topic prefix and return the command portion ("dpx/cmd"). +// Returns empty String if topic doesn't look like ours. +static String dpxMqttCmd(const char* topic) { + String t(topic); + // Match either "{mqttDeviceTopic}/dpx/..." or "dpx/..." + String dev = String(mqttDeviceTopic) + F("/dpx/"); + if (t.startsWith(dev)) return t.substring(dev.length()); + if (t.startsWith(F("dpx/"))) return t.substring(4); + return ""; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// Call from DpxMatrix::onMqttConnect() +static void dpxMqttConnect() { + if (!WLED_MQTT_CONNECTED) return; + // Per-device topic: wled/MAC/dpx/# + String sub = String(mqttDeviceTopic) + "/dpx/#"; + mqtt->subscribe(sub.c_str(), 0); + // Alias: dpx/# (compatible with dpx_tc001 senders) + mqtt->subscribe("dpx/#", 0); + DEBUG_PRINTF("DpxMatrix: MQTT subscribed to %s and dpx/#\n", sub.c_str()); +} + +// Call from DpxMatrix::onMqttMessage(). Returns true if topic was ours. +static bool dpxMqttMessage(char* topic, char* payload) { + String cmd = dpxMqttCmd(topic); + if (!cmd.length()) return false; + + // ── notify ────────────────────────────────────────────────────────────── + if (cmd == F("notify")) { + dpxPushNotification(payload); + return true; + } + if (cmd == F("notify/dismiss")) { + dpxDismissNotification(); + return true; + } + + // ── timecode ───────────────────────────────────────────────────────────── + if (cmd == F("tc")) { + String tc(payload); + tc.trim(); + // Accept plain "HH:MM:SS:FF" or JSON {"tc":"HH:MM:SS:FF"} + if (tc.startsWith("{")) { + StaticJsonDocument<64> doc; + if (!deserializeJson(doc, tc) && doc.containsKey("tc")) + tc = doc["tc"].as(); + } + if (tc.length() >= 8) dpxPushTC(tc); + return true; + } + + // ── app loop control ────────────────────────────────────────────────────── + if (cmd == F("nextapp")) { dpxNextApp(); return true; } + if (cmd == F("previousapp")) { dpxPrevApp(); return true; } + + if (cmd == F("switch")) { + String p(payload); p.trim(); + // Accept plain name or {"name":"..."} + if (!p.startsWith("{")) { + StaticJsonDocument<64> doc; + doc["name"] = p; + String s; serializeJson(doc, s); + dpxSwitchToApp(s.c_str()); + } else { + dpxSwitchToApp(p.c_str()); + } + return true; + } + + // ── custom app upsert ───────────────────────────────────────────────────── + // topic: .../dpx/app/ + if (cmd.startsWith(F("app/"))) { + String name = cmd.substring(4); + if (name.length()) dpxSetCustomApp(name, payload); + return true; + } + + // ── indicator pixels ───────────────────────────────────────────────────── + // topic: .../dpx/indicator/1 (or 2, 3) + if (cmd.startsWith(F("indicator/"))) { + int num = cmd.charAt(10) - '0'; + if (num >= 1 && num <= 3) { + String p(payload); p.trim(); + if (!p.length() || p == "0" || p == "off") { + dpxIndicator[num - 1] = 0; + } else { + StaticJsonDocument<128> doc; + if (!deserializeJson(doc, p) && doc.containsKey("color")) { + JsonArray a = doc["color"].as(); + if (a.size() >= 3) + dpxIndicator[num - 1] = ((uint32_t)(uint8_t)a[0] << 16) + | ((uint32_t)(uint8_t)a[1] << 8) + | (uint32_t)(uint8_t)a[2]; + } + } + } + return true; + } + + // ── power / brightness ──────────────────────────────────────────────────── + if (cmd == F("power")) { + StaticJsonDocument<64> doc; + if (!deserializeJson(doc, payload) && doc.containsKey("power")) { + bool on = doc["power"].as(); + bri = on ? (briLast > 0 ? briLast : 128) : 0; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + return true; + } + if (cmd == F("brightness")) { + StaticJsonDocument<64> doc; + String p(payload); p.trim(); + if (p.length() && isDigit(p[0])) { + bri = (uint8_t)p.toInt(); + } else if (!deserializeJson(doc, p) && doc.containsKey("bri")) { + bri = doc["bri"].as(); + } + stateUpdated(CALL_MODE_DIRECT_CHANGE); + return true; + } + + // ── RTTTL / buzzer ──────────────────────────────────────────────────────── + // topic: .../dpx/rtttl payload: raw RTTTL string or JSON {"rtttl":"..."} + if (cmd == F("rtttl")) { + String p(payload); p.trim(); + if (!p.length() || p == "stop") { + dpxBuzzerStop(); + } else if (p.startsWith("{")) { + StaticJsonDocument<256> doc; + if (!deserializeJson(doc, p) && doc.containsKey("rtttl")) + dpxBuzzerPlay(doc["rtttl"].as()); + } else { + dpxBuzzerPlay(p.c_str()); + } + return true; + } + + // ── Channel mute ────────────────────────────────────────────────────────── + // topic: .../dpx/mute/ payload: 1/0/true/false/on/off + if (cmd.startsWith(F("mute/"))) { + String name = cmd.substring(5); + String p(payload); p.trim(); p.toLowerCase(); + bool mute = (p == "1" || p == "true" || p == "on"); + dpxMuteApp(name, mute); + return true; + } + + // ── Text overlay ────────────────────────────────────────────────────────── + if (cmd == F("overlay")) { + String p(payload); p.trim(); + if (!p.length() || p == "0" || p == "off" || p == "clear") + dpxClearOverlay(); + else + dpxSetOverlay(p.c_str()); + return true; + } + + // ── Pixel effect ────────────────────────────────────────────────────────── + if (cmd == F("effect")) { + String p(payload); p.trim(); + if (!p.length() || p == "none" || p == "off" || p == "clear") + dpxClearPixelEffect(); + else + dpxSetPixelEffect(p.c_str()); + return true; + } + + return false; // not our topic +} diff --git a/usermods/dpx_matrix/dpx_notifications.h b/usermods/dpx_matrix/dpx_notifications.h new file mode 100644 index 0000000000..72dd2ecdb4 --- /dev/null +++ b/usermods/dpx_matrix/dpx_notifications.h @@ -0,0 +1,87 @@ +// ================================================================================ +// dpx_notifications.h — Notification Queue +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_notifications.h +// Purpose: One-shot notifications that interrupt the app loop, display once, +// then vanish (or hold until dismissed if hold=true). +// +// ================================================================================ + +#pragma once +#include "dpx_apps.h" + +struct DpxNotification { + DpxCustomApp data; + bool hold = false; // true = hold until explicit dismiss + bool stack = true; // true = queue, false = replace current +}; + +static std::vector dpxNotifQueue; +static bool dpxNotifActive = false; +static DpxNotification dpxCurrentNotif; +static unsigned long dpxNotifStartMs = 0; + +// Parse and enqueue a notification from JSON body (SPEC.md §5 POST /api/notify) +static bool dpxPushNotification(const char* json) { + DynamicJsonDocument doc(1024); + if (deserializeJson(doc, json)) return false; + + DpxNotification notif; + notif.data = dpxParseApp(json); + if (!notif.data.valid) { + // Minimal: just a text string + notif.data.valid = true; + notif.data.text = doc["text"].as(); + } + notif.hold = doc.containsKey("hold") ? doc["hold"].as() : false; + notif.stack = doc.containsKey("stack") ? doc["stack"].as() : true; + + if (!notif.stack) { + // Replace: clear queue and active + dpxNotifQueue.clear(); + dpxNotifActive = false; + } + dpxNotifQueue.push_back(notif); + dpxActivateEffect(); // ensure dpx Matrix effect is showing + return true; +} + +// Dismiss the currently active held notification +static void dpxDismissNotification() { + dpxNotifActive = false; + dpxNotifQueue.erase(dpxNotifQueue.begin(), dpxNotifQueue.end()); +} + +// Notification tick — call from loop() after app tick +// Returns true while a notification is being displayed. +static bool dpxNotifTick() { + if (!dpxNotifActive) { + if (dpxNotifQueue.empty()) return false; + dpxCurrentNotif = dpxNotifQueue.front(); + dpxNotifQueue.erase(dpxNotifQueue.begin()); + dpxNotifActive = true; + dpxNotifStartMs = millis(); + dpxScroll.stop(); + } + + // Check duration expiry (hold notifications never auto-dismiss) + if (!dpxCurrentNotif.hold) { + unsigned long dur = dpxCurrentNotif.data.durationMs(); + if (dur == 0) dur = 5000; // default 5s + if (millis() - dpxNotifStartMs >= dur) { + dpxNotifActive = false; + return false; + } + } + return true; +} + +// Render the current notification (call from handleOverlayDraw() when notifTick() = true) +static void dpxRenderNotification() { + dpxRenderApp(dpxCurrentNotif.data); +} diff --git a/usermods/dpx_matrix/dpx_osc.h b/usermods/dpx_matrix/dpx_osc.h new file mode 100644 index 0000000000..0d14e349fd --- /dev/null +++ b/usermods/dpx_matrix/dpx_osc.h @@ -0,0 +1,237 @@ +// ================================================================================ +// dpx_osc.h — OSC Receiver (UDP port 4210) +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// Ported/adapted from dpx_tc001 ServerManager.cpp::handleOSC() +// (original work — OSC protocol handling, not AWTRIX-derived) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_osc.h +// Purpose: Receives OSC packets on UDP port 4210. Handles built-in addresses +// and routes unknown addresses through the OSC Listener Registry +// (d3 disguise integration). FIND_AWTRIX discovery also handled here. +// +// Supported addresses (both bare and /dpx_tc002/ or /awtrix/ prefixed): +// /notify, /text, /tc, /custom/, /switch, +// /nextapp, /previousapp, /power, /brightness, +// /indicator/1, /indicator/2, /indicator/3 +// +// ================================================================================ + +#pragma once +#include +#include +#include "dpx_tc.h" +#include "dpx_notifications.h" + +static WiFiUDP dpxUdp; +static const uint16_t DPX_OSC_PORT = 4210; +static bool dpxUdpStarted = false; + +// ── OSC binary helpers ──────────────────────────────────────────────────────── +static String dpxOscStr(const uint8_t* b, int& p, int len) { + String s; + while (p < len && b[p]) s += (char)b[p++]; + p++; // consume null terminator + p = (p + 3) & ~3; // pad to 4-byte boundary + return s; +} + +static int32_t dpxOscI32(const uint8_t* b, int& p) { + int32_t v = ((int32_t)b[p] << 24) | ((int32_t)b[p+1] << 16) + | ((int32_t)b[p+2] << 8) | (int32_t)b[p+3]; + p += 4; + return v; +} + +static float dpxOscF32(const uint8_t* b, int& p) { + uint32_t raw = ((uint32_t)b[p] << 24) | ((uint32_t)b[p+1] << 16) + | ((uint32_t)b[p+2] << 8) | (uint32_t)b[p+3]; + p += 4; + float f; + memcpy(&f, &raw, sizeof f); + return f; +} + +static int dpxOscNum(const uint8_t* b, int& p, char tag) { + if (tag == 'f') return (int)dpxOscF32(b, p); + return (int)dpxOscI32(b, p); +} + +// ── OSC Listener Registry — persist ────────────────────────────────────────── +static void dpxSaveOscListeners() { + DynamicJsonDocument doc(2048); + JsonArray arr = doc.to(); + for (auto& lsr : dpxOscListeners) { + JsonObject o = arr.createNestedObject(); + o["path"] = lsr.path; o["channel"] = lsr.channel; o["label"] = lsr.label; + } + File f = LittleFS.open("/osc_listeners.json", "w"); + if (f) { serializeJson(doc, f); f.close(); } +} + +static void dpxLoadOscListeners() { + if (!LittleFS.exists("/osc_listeners.json")) return; + File f = LittleFS.open("/osc_listeners.json", "r"); + if (!f) return; + DynamicJsonDocument doc(2048); + if (deserializeJson(doc, f)) { f.close(); return; } + f.close(); + dpxOscListeners.clear(); + for (JsonObject o : doc.as()) { + DpxOscListener lsr; + lsr.path = o["path"].as(); + lsr.channel = o["channel"].as(); + lsr.label = o.containsKey("label") ? o["label"].as() : lsr.channel; + if (!lsr.path.isEmpty() && !lsr.channel.isEmpty()) + dpxOscListeners.push_back(lsr); + } +} + +static String dpxOscListenersJson() { + DynamicJsonDocument doc(2048); + JsonArray arr = doc.to(); + for (auto& lsr : dpxOscListeners) { + JsonObject o = arr.createNestedObject(); + o["path"] = lsr.path; o["channel"] = lsr.channel; o["label"] = lsr.label; + } + String s; serializeJson(doc, s); return s; +} + +// ── Main OSC dispatch ───────────────────────────────────────────────────────── +static void dpxHandleOSC(const uint8_t* buf, int len) { + if (len < 4 || buf[0] != '/') return; + int pos = 0; + String addr = dpxOscStr(buf, pos, len); + if (pos >= len || buf[pos] != ',') return; + String tags = dpxOscStr(buf, pos, len); // tags[0] == ',' + + // Accept /dpx_tc002/... as primary; /dpx_tc001/ and /awtrix/ for compatibility + if (addr.startsWith("/dpx_tc002")) addr = addr.substring(10); + else if (addr.startsWith("/dpx_tc001")) addr = addr.substring(10); + else if (addr.startsWith("/awtrix")) addr = addr.substring(7); + if (addr.isEmpty()) addr = "/"; + + if (addr == "/tc") { + if (tags.length() >= 2 && tags[1] == 's') { + String tc = dpxOscStr(buf, pos, len); + dpxPushTC(tc); + } + } else if (addr == "/notify" || addr == "/text") { + if (tags.length() >= 2 && tags[1] == 's') { + String text = dpxOscStr(buf, pos, len); + StaticJsonDocument<256> doc; + doc["text"] = text; + String json; serializeJson(doc, json); + dpxPushNotification(json.c_str()); + } + } else if (addr.startsWith("/custom/")) { + String name = addr.substring(8); + if (!name.isEmpty() && tags.length() >= 2 && tags[1] == 's') { + String text = dpxOscStr(buf, pos, len); + if (name == "tc") { + dpxPushTC(text); + } else { + StaticJsonDocument<256> doc; + doc["text"] = text; + String json; serializeJson(doc, json); + dpxSetCustomApp(name, json.c_str()); + } + } + } else if (addr == "/switch") { + if (tags.length() >= 2 && tags[1] == 's') { + String name = dpxOscStr(buf, pos, len); + StaticJsonDocument<128> doc; + doc["name"] = name; + String json; serializeJson(doc, json); + dpxSwitchToApp(json.c_str()); + } + } else if (addr == "/nextapp") { dpxNextApp(); } + else if (addr == "/previousapp") { dpxPrevApp(); } + else if (addr == "/power") { + if (tags.length() >= 2) { + bool on = (dpxOscNum(buf, pos, tags[1]) != 0); + if (!on) { + // Turn WLED off + bri = 0; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } else { + bri = briLast > 0 ? briLast : 128; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + } + } else if (addr == "/brightness") { + if (tags.length() >= 2) { + int b = constrain(dpxOscNum(buf, pos, tags[1]), 0, 255); + bri = b; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + } else if (addr.startsWith("/indicator/")) { + // Indicator dots — stored as corner pixels + int num = addr.substring(11).toInt(); + if (num >= 1 && num <= 3 && tags.length() >= 4) { + int r = constrain(dpxOscNum(buf, pos, tags[1]), 0, 255); + int g = constrain(dpxOscNum(buf, pos, tags[2]), 0, 255); + int b = constrain(dpxOscNum(buf, pos, tags[3]), 0, 255); + // Store indicator colors (rendered in handleOverlayDraw) + // Indicator 1 = pixel 0 (top-left), 2 = pixel 31, 3 = pixel 224 + uint32_t col = ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; + extern uint32_t dpxIndicator[3]; + if (num >= 1 && num <= 3) dpxIndicator[num - 1] = col; + } + } else { + // Dynamic OSC listener registry (d3 monitoring paths) + for (auto& lsr : dpxOscListeners) { + if (addr == lsr.path && tags.length() >= 2) { + String val; + char t = tags[1]; + if (t == 's') val = dpxOscStr(buf, pos, len); + else if (t == 'f') val = String(dpxOscF32(buf, pos), 4); + else if (t == 'i') val = String(dpxOscI32(buf, pos)); + if (lsr.channel == "tc") { + dpxPushTC(val); + } else { + StaticJsonDocument<256> doc; + doc["text"] = val; + String json; serializeJson(doc, json); + dpxSetCustomApp(lsr.channel, json.c_str()); + } + break; + } + } + } +} + +// ── UDP receive tick — call from loop() ─────────────────────────────────────── +#define DPX_UDP_BUF 512 +static char dpxUdpBuf[DPX_UDP_BUF]; + +static void dpxOscTick() { + if (!dpxUdpStarted) return; + int pkt = dpxUdp.parsePacket(); + if (!pkt) return; + int len = dpxUdp.read(dpxUdpBuf, DPX_UDP_BUF - 1); + if (len <= 0) return; + dpxUdpBuf[len] = 0; + + if (dpxUdpBuf[0] == '/') { + // OSC packet + dpxHandleOSC((const uint8_t*)dpxUdpBuf, len); + } else if (strcmp(dpxUdpBuf, "FIND_AWTRIX") == 0) { + // Discovery response — reply on port 4211 with hostname:port + char resp[64]; + snprintf(resp, sizeof(resp), "%s", WiFi.getHostname()); + dpxUdp.beginPacket(dpxUdp.remoteIP(), 4211); + dpxUdp.write((const uint8_t*)resp, strlen(resp)); + dpxUdp.endPacket(); + } +} + +// ── Start UDP listener ──────────────────────────────────────────────────────── +static void dpxOscBegin() { + if (dpxUdpStarted) return; + dpxUdp.begin(DPX_OSC_PORT); + dpxUdpStarted = true; +} diff --git a/usermods/dpx_matrix/dpx_overlay.h b/usermods/dpx_matrix/dpx_overlay.h new file mode 100644 index 0000000000..3e94a7e384 --- /dev/null +++ b/usermods/dpx_matrix/dpx_overlay.h @@ -0,0 +1,365 @@ +// ================================================================================ +// dpx_overlay.h — Text Overlay + Pixel Effects (run on top of app content) +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_overlay.h +// Purpose: Two stacked layers rendered after dpxRenderCurrentApp(): +// +// 1. TEXT OVERLAY — static or scrolling text drawn at a specific position +// over the current app content. Controlled via MQTT dpx/overlay or +// JSON state {"dpx":{"overlay":{...}}}. +// +// Position options: +// "top" → baseline row 2 (glyph rows 1–5, occupies top of display) +// "bottom" → baseline row 7 (glyph rows 3–7, occupies bottom 5 rows) +// "center" → baseline DPX_FONT_BASELINE (vertically centered) +// N (int) → explicit baseline row number +// +// JSON schema: +// {"text":"LIVE","color":"#FF0000","pos":"top","scroll":false,"speed":80} +// Clear: {"text":""} +// +// 2. PIXEL EFFECT — lightweight per-pixel effect applied on top of everything. +// Controlled via MQTT dpx/effect or JSON state {"dpx":{"effect":{...}}}. +// +// Effects: "sparkle", "strobe", "rain", "twinkle", "blink", "none" +// +// JSON schema: +// {"name":"sparkle","color":"#FFFFFF","intensity":50} +// Clear: {"name":"none"} or {"name":""} +// +// ================================================================================ + +#pragma once +#include "dpx_text.h" +#include "dpx_apps.h" // for dpxParseColor + +// ── Text overlay state ──────────────────────────────────────────────────────── +static struct { + bool active = false; + String text; + uint32_t color = 0xFFFFFF; + bool rainbow = false; + bool scroll = false; + int baseline = DPX_FONT_BASELINE; // cursor_y + int speed = 80; // scroll speed % + // Scroll state (re-used from DpxScrollState internals inline): + int scrollX = DPX_MATRIX_W; + int textWidth = 0; + unsigned long lastMs = 0; + int speedMs = 50; +} dpxTextOverlay; + +static struct { + String name; // "sparkle", "strobe", "rain", "twinkle", "blink", "none" + uint32_t color = 0xFFFFFF; + uint8_t intensity = 50; // 0–100 + bool active = false; + // per-effect private state + unsigned long lastMs = 0; + bool strobeOn = true; + uint8_t rain[DPX_MATRIX_W] = {}; // rain/drizzle/storm drop positions per column + uint8_t snow[DPX_MATRIX_W] = {}; // snow floor accumulation (rows from bottom) + bool frostInit = false; // frost: static pattern generated? + uint8_t frost[256] = {}; // frost: which pixels are frosted (1=yes) +} dpxPixelEffect; + +// ── Text overlay control ────────────────────────────────────────────────────── + +static bool dpxSetOverlay(const char* json) { + StaticJsonDocument<256> doc; + if (deserializeJson(doc, json)) return false; + + String text = doc["text"] | String(); + if (!text.length()) { + dpxTextOverlay.active = false; + return true; + } + + dpxTextOverlay.text = text; + dpxTextOverlay.color = dpxParseColor(doc["color"], 0xFFFFFF); + dpxTextOverlay.rainbow = doc["rainbow"] | false; + dpxTextOverlay.scroll = doc["scroll"] | false; + dpxTextOverlay.speed = doc["speed"] | 80; + dpxTextOverlay.speedMs = max(10, (int)(50 * 100 / max(1, dpxTextOverlay.speed))); + dpxTextOverlay.textWidth = dpxTextPixelWidth(text.c_str()); + dpxTextOverlay.scrollX = DPX_MATRIX_W; + dpxTextOverlay.lastMs = 0; + + // Resolve position string → baseline row + if (doc.containsKey("pos")) { + JsonVariant pos = doc["pos"]; + if (pos.is()) { + dpxTextOverlay.baseline = pos.as(); + } else { + String p = pos.as(); + if (p == "top") dpxTextOverlay.baseline = 2; + else if (p == "bottom") dpxTextOverlay.baseline = 7; + else if (p == "center") dpxTextOverlay.baseline = DPX_FONT_BASELINE; + else dpxTextOverlay.baseline = DPX_FONT_BASELINE; + } + } else { + dpxTextOverlay.baseline = DPX_FONT_BASELINE; + } + + dpxTextOverlay.active = true; + return true; +} + +static void dpxClearOverlay() { dpxTextOverlay.active = false; } + +// Render text overlay — called after app/notification content is drawn. +static void dpxRenderTextOverlay() { + if (!dpxTextOverlay.active) return; + + if (!dpxTextOverlay.scroll || dpxTextOverlay.textWidth <= DPX_MATRIX_W) { + // Static: centre or left-align + int x = 0; + if (dpxTextOverlay.textWidth < DPX_MATRIX_W) + x = (DPX_MATRIX_W - dpxTextOverlay.textWidth) / 2; + dpxRenderText(x, dpxTextOverlay.baseline, + dpxTextOverlay.text.c_str(), + dpxTextOverlay.color, dpxTextOverlay.rainbow); + } else { + // Scrolling + unsigned long now = millis(); + if (now - dpxTextOverlay.lastMs >= (unsigned long)dpxTextOverlay.speedMs) { + dpxTextOverlay.lastMs = now; + if (--dpxTextOverlay.scrollX < -(dpxTextOverlay.textWidth)) + dpxTextOverlay.scrollX = DPX_MATRIX_W; + } + dpxRenderText(dpxTextOverlay.scrollX, dpxTextOverlay.baseline, + dpxTextOverlay.text.c_str(), + dpxTextOverlay.color, dpxTextOverlay.rainbow); + } +} + +// ── Pixel effect control ────────────────────────────────────────────────────── + +static bool dpxSetPixelEffect(const char* json) { + StaticJsonDocument<128> doc; + if (deserializeJson(doc, json)) return false; + + String name = doc["name"] | String("none"); + name.toLowerCase(); + dpxPixelEffect.name = name; + dpxPixelEffect.color = dpxParseColor(doc["color"], 0xFFFFFF); + dpxPixelEffect.intensity = doc["intensity"] | 50; + dpxPixelEffect.active = (name != "none" && name.length() > 0); + dpxPixelEffect.lastMs = 0; + dpxPixelEffect.strobeOn = true; + memset(dpxPixelEffect.rain, 0, sizeof(dpxPixelEffect.rain)); + memset(dpxPixelEffect.snow, 0, sizeof(dpxPixelEffect.snow)); + dpxPixelEffect.frostInit = false; + return true; +} + +static void dpxClearPixelEffect() { dpxPixelEffect.active = false; } + +// Render one frame of the active pixel effect on top of current LED content. +static void dpxRenderPixelEffect() { + if (!dpxPixelEffect.active) return; + + unsigned long now = millis(); + uint32_t col = dpxPixelEffect.color; + uint8_t iv = dpxPixelEffect.intensity; + + // ── Sparkle ──────────────────────────────────────────────────────────── + if (dpxPixelEffect.name == "sparkle") { + // Each frame, light up ~intensity/4 random pixels white then fade + int count = max(1, (int)(iv / 4)); + for (int i = 0; i < count; i++) { + int px = (int)(random(256)); + strip.setPixelColor(px, col); + } + } + + // ── Twinkle ──────────────────────────────────────────────────────────────── + else if (dpxPixelEffect.name == "twinkle") { + int count = max(1, (int)(iv / 8)); + for (int i = 0; i < count; i++) { + int px = (int)(random(256)); + // Blend with existing pixel + uint32_t existing = strip.getPixelColor(px); + strip.setPixelColor(px, color_blend(existing, col, 180)); + } + } + + // ── Strobe ──────────────────────────────────────────────────────────── + else if (dpxPixelEffect.name == "strobe") { + // Flash on/off at speed derived from intensity + int intervalMs = map(iv, 0, 100, 400, 40); + if (now - dpxPixelEffect.lastMs >= (unsigned long)intervalMs) { + dpxPixelEffect.lastMs = now; + dpxPixelEffect.strobeOn = !dpxPixelEffect.strobeOn; + } + if (dpxPixelEffect.strobeOn) { + for (int i = 0; i < 256; i++) + strip.setPixelColor(i, color_blend(strip.getPixelColor(i), col, 200)); + } + } + + // ── Blink ───────────────────────────────────────────────────────────── + else if (dpxPixelEffect.name == "blink") { + int intervalMs = map(iv, 0, 100, 1000, 200); + if (now - dpxPixelEffect.lastMs >= (unsigned long)intervalMs) { + dpxPixelEffect.lastMs = now; + dpxPixelEffect.strobeOn = !dpxPixelEffect.strobeOn; + } + if (!dpxPixelEffect.strobeOn) { + for (int i = 0; i < 256; i++) strip.setPixelColor(i, 0); + } + } + + // ── Rain ────────────────────────────────────────────────────────────── + else if (dpxPixelEffect.name == "rain") { + int intervalMs = map(iv, 0, 100, 200, 20); + if (now - dpxPixelEffect.lastMs >= (unsigned long)intervalMs) { + dpxPixelEffect.lastMs = now; + // Advance each column's raindrop + for (int x = 0; x < DPX_MATRIX_W; x++) { + if (dpxPixelEffect.rain[x] > 0) { + // Erase old head, draw new + int y = dpxPixelEffect.rain[x] - 1; + if (y < DPX_MATRIX_H) dpxSetPixel(x, y, 0); + dpxPixelEffect.rain[x]++; + if ((int)dpxPixelEffect.rain[x] - 1 < DPX_MATRIX_H) + dpxSetPixel(x, dpxPixelEffect.rain[x] - 1, col); + if ((int)dpxPixelEffect.rain[x] > DPX_MATRIX_H + 1) + dpxPixelEffect.rain[x] = 0; // reset column + } + // Randomly start new drops + if (dpxPixelEffect.rain[x] == 0 && (random(256) < (iv * 2))) + dpxPixelEffect.rain[x] = 1; + } + } + } + + // ── Drizzle: lighter rain \u2014 fewer, slower, dimmer drops ────────────────── + else if (dpxPixelEffect.name == "drizzle") { + int intervalMs = map(iv, 0, 100, 400, 80); + if (now - dpxPixelEffect.lastMs >= (unsigned long)intervalMs) { + dpxPixelEffect.lastMs = now; + for (int x = 0; x < DPX_MATRIX_W; x++) { + if (dpxPixelEffect.rain[x] > 0) { + int y = dpxPixelEffect.rain[x] - 1; + if (y < DPX_MATRIX_H) dpxSetPixel(x, y, 0); + dpxPixelEffect.rain[x]++; + if ((int)dpxPixelEffect.rain[x] - 1 < DPX_MATRIX_H) + dpxSetPixel(x, dpxPixelEffect.rain[x] - 1, 0x4466AA); + if ((int)dpxPixelEffect.rain[x] > DPX_MATRIX_H + 1) + dpxPixelEffect.rain[x] = 0; + } + if (dpxPixelEffect.rain[x] == 0 && (random(512) < (uint32_t)(iv + 5))) + dpxPixelEffect.rain[x] = 1; + } + } + } + + // ── Storm: heavy rain + occasional lightning flash ──────────────────── + else if (dpxPixelEffect.name == "storm") { + int intervalMs = map(iv, 0, 100, 100, 10); + if (now - dpxPixelEffect.lastMs >= (unsigned long)intervalMs) { + dpxPixelEffect.lastMs = now; + for (int x = 0; x < DPX_MATRIX_W; x++) { + if (dpxPixelEffect.rain[x] > 0) { + int y = dpxPixelEffect.rain[x] - 1; + if (y < DPX_MATRIX_H) dpxSetPixel(x, y, 0); + dpxPixelEffect.rain[x]++; + if ((int)dpxPixelEffect.rain[x] - 1 < DPX_MATRIX_H) + dpxSetPixel(x, dpxPixelEffect.rain[x] - 1, 0x4466FF); + if ((int)dpxPixelEffect.rain[x] > DPX_MATRIX_H + 1) + dpxPixelEffect.rain[x] = 0; + } + if (dpxPixelEffect.rain[x] == 0 && (random(128) < (uint32_t)(iv * 3 + 20))) + dpxPixelEffect.rain[x] = 1; + } + } + // Occasional lightning flash (2 bright frames) + static unsigned long _stormFlashMs = 0; + static int _stormFlashFrames = 0; + if (_stormFlashFrames > 0) { + for (int i = 0; i < 256; i++) + strip.setPixelColor(i, color_blend(strip.getPixelColor(i), 0xFFFFFF, 180)); + _stormFlashFrames--; + } else if (now - _stormFlashMs > 2000 && random(200) < 5) { + _stormFlashMs = now; + _stormFlashFrames = 2; + } + } + + // ── Thunder: periodic full-screen white flash, no rain ─────────────── + else if (dpxPixelEffect.name == "thunder") { + // intervalMs decreases with intensity (more frequent flashes) + int intervalMs = map(iv, 0, 100, 4000, 600); + if (now - dpxPixelEffect.lastMs >= (unsigned long)intervalMs) { + dpxPixelEffect.lastMs = now; + dpxPixelEffect.strobeOn = true; + } + if (dpxPixelEffect.strobeOn) { + for (int i = 0; i < 256; i++) + strip.setPixelColor(i, color_blend(strip.getPixelColor(i), 0xFFFFFF, 220)); + dpxPixelEffect.strobeOn = false; // single frame flash + } + } + + // ── Snow: slow-falling white flakes, accumulate at the bottom ───────── + else if (dpxPixelEffect.name == "snow") { + int intervalMs = map(iv, 0, 100, 350, 70); + if (now - dpxPixelEffect.lastMs >= (unsigned long)intervalMs) { + dpxPixelEffect.lastMs = now; + for (int x = 0; x < DPX_MATRIX_W; x++) { + if (dpxPixelEffect.rain[x] > 0) { + int y = dpxPixelEffect.rain[x] - 1; + // floor = bottom of free space above snow pile + int floorY = DPX_MATRIX_H - 1 - (int)dpxPixelEffect.snow[x]; + if (y >= floorY) { + // Flake landed \u2014 grow pile + if (dpxPixelEffect.snow[x] < DPX_MATRIX_H) + dpxPixelEffect.snow[x]++; + dpxPixelEffect.rain[x] = 0; + } else { + if (y < DPX_MATRIX_H) dpxSetPixel(x, y, 0); + dpxPixelEffect.rain[x]++; + if ((int)dpxPixelEffect.rain[x] - 1 < DPX_MATRIX_H) + dpxSetPixel(x, dpxPixelEffect.rain[x] - 1, 0xDDDDFF); + } + } else if (random(1024) < (uint32_t)(iv + 5)) { + dpxPixelEffect.rain[x] = 1; + } + } + // Redraw snow floor every tick + for (int x = 0; x < DPX_MATRIX_W; x++) { + for (uint8_t py = 0; py < dpxPixelEffect.snow[x]; py++) { + dpxSetPixel(x, DPX_MATRIX_H - 1 - py, 0xDDDDFF); + } + } + } + } + + // ── Frost: static blue-white scatter generated once per activation ──── + else if (dpxPixelEffect.name == "frost") { + if (!dpxPixelEffect.frostInit) { + // Generate pattern once: ~(intensity/2)% of pixels frosted + dpxPixelEffect.frostInit = true; + int count = (256 * iv) / 200; + memset(dpxPixelEffect.frost, 0, sizeof(dpxPixelEffect.frost)); + for (int i = 0; i < count; i++) + dpxPixelEffect.frost[(int)random(256)] = 1; + } + for (int i = 0; i < 256; i++) { + if (dpxPixelEffect.frost[i]) + strip.setPixelColor(i, color_blend(strip.getPixelColor(i), 0xAADDFF, 160)); + } + } +} + +// ── Master overlay render — call at end of handleOverlayDraw() ──────────────── +static void dpxRenderOverlays() { + dpxRenderPixelEffect(); // pixel effect first (under text) + dpxRenderTextOverlay(); // text on top +} diff --git a/usermods/dpx_matrix/dpx_persist.h b/usermods/dpx_matrix/dpx_persist.h new file mode 100644 index 0000000000..c3538ffe17 --- /dev/null +++ b/usermods/dpx_matrix/dpx_persist.h @@ -0,0 +1,122 @@ +// ================================================================================ +// dpx_persist.h — Persistent Settings (dev.json) + Runtime Globals +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_persist.h +// Purpose: Runtime configuration variables loaded from /dev.json on LittleFS. +// Also provides load/save helpers. Included once via dpx_matrix.cpp. +// +// dev.json keys: +// hostname, temp_offset, hum_offset, tc_dwell, tc_hold, tc_show_frames, +// min_brightness, max_brightness, ldr_factor, ldr_gamma, rotate_screen, +// mirror_screen, sensor_reading, matrix, dfplayer, bootsound, web_port, +// background_effect, ha_prefix, stats_interval +// +// ================================================================================ + +#pragma once +// ArduinoJson is pulled in via wled.h — do not include it directly here. +#include + +// ── Runtime variables (set at boot from dev.json, some apply immediately) ───── +static float DPX_TEMP_OFFSET = -9.0f; +static float DPX_HUM_OFFSET = 0.0f; +static uint32_t DPX_TC_DWELL_MS = 2000; // ms before TC app auto-dismiss +static bool DPX_TC_HOLD = false; // if true, TC never auto-dismisses +static bool DPX_TC_SHOW_FRAMES = false; // false=HH:MM:SS+bar, true=MM:SS.FF +static int DPX_MIN_BRI = 2; +static int DPX_MAX_BRI = 180; +static float DPX_LDR_FACTOR = 1.0f; +static float DPX_LDR_GAMMA = 3.0f; +static bool DPX_ROTATE_SCREEN = false; +static bool DPX_MIRROR_SCREEN = false; +static bool DPX_SENSOR_READING = true; +static int DPX_ATIME = 7; // app display seconds +static bool DPX_ATRANS = true; // auto-advance apps +static int DPX_SSPEED = 100; // global scroll speed % +static bool DPX_UPPERCASE = true; // force uppercase text +static bool dpxEnabled = true; // false = pass-through to WLED effects +static String DPX_TIMEZONE; // POSIX TZ string (e.g. PST8PDT,...) +// Native app visibility (toggled via POST /api/settings TIM/DAT keys) +static bool DPX_SHOW_TIME = true; +static bool DPX_SHOW_DATE = false; + +// ── Load dev.json from LittleFS ─────────────────────────────────────────────── +static void dpxLoadDev() { + if (!LittleFS.exists("/dev.json")) return; + File f = LittleFS.open("/dev.json", "r"); + if (!f) return; + DynamicJsonDocument doc(1024); + if (deserializeJson(doc, f)) { f.close(); return; } + f.close(); + + if (doc.containsKey("temp_offset")) DPX_TEMP_OFFSET = doc["temp_offset"].as(); + if (doc.containsKey("hum_offset")) DPX_HUM_OFFSET = doc["hum_offset"].as(); + if (doc.containsKey("tc_dwell")) DPX_TC_DWELL_MS = (uint32_t)(doc["tc_dwell"].as() * 1000); + if (doc.containsKey("tc_hold")) DPX_TC_HOLD = doc["tc_hold"].as(); + if (doc.containsKey("tc_show_frames")) DPX_TC_SHOW_FRAMES = doc["tc_show_frames"].as(); + if (doc.containsKey("min_brightness")) DPX_MIN_BRI = doc["min_brightness"].as(); + if (doc.containsKey("max_brightness")) DPX_MAX_BRI = doc["max_brightness"].as(); + if (doc.containsKey("ldr_factor")) DPX_LDR_FACTOR = doc["ldr_factor"].as(); + if (doc.containsKey("ldr_gamma")) DPX_LDR_GAMMA = doc["ldr_gamma"].as(); + if (doc.containsKey("rotate_screen")) DPX_ROTATE_SCREEN = doc["rotate_screen"].as(); + if (doc.containsKey("mirror_screen")) DPX_MIRROR_SCREEN = doc["mirror_screen"].as(); + if (doc.containsKey("sensor_reading")) DPX_SENSOR_READING = doc["sensor_reading"].as(); + if (doc.containsKey("timezone_posix")) { + DPX_TIMEZONE = doc["timezone_posix"].as(); + setenv("TZ", DPX_TIMEZONE.c_str(), 1); + tzset(); + } +} + +// ── Merge a JSON object into dev.json and apply immediately ─────────────────── +// Returns false on parse error. +static bool dpxMergeDev(const char* json) { + DynamicJsonDocument incoming(512); + if (deserializeJson(incoming, json)) return false; + + DynamicJsonDocument merged(1024); + if (LittleFS.exists("/dev.json")) { + File f = LittleFS.open("/dev.json", "r"); + if (f) { deserializeJson(merged, f); f.close(); } + } + for (JsonPair kv : incoming.as()) + merged[kv.key()] = kv.value(); + + File fw = LittleFS.open("/dev.json", "w"); + if (!fw) return false; + serializeJson(merged, fw); + fw.close(); + + // Apply changed values immediately + if (incoming.containsKey("temp_offset")) DPX_TEMP_OFFSET = incoming["temp_offset"].as(); + if (incoming.containsKey("hum_offset")) DPX_HUM_OFFSET = incoming["hum_offset"].as(); + if (incoming.containsKey("tc_dwell")) DPX_TC_DWELL_MS = (uint32_t)(incoming["tc_dwell"].as() * 1000); + if (incoming.containsKey("tc_hold")) DPX_TC_HOLD = incoming["tc_hold"].as(); + if (incoming.containsKey("tc_show_frames")) DPX_TC_SHOW_FRAMES = incoming["tc_show_frames"].as(); + if (incoming.containsKey("min_brightness")) DPX_MIN_BRI = incoming["min_brightness"].as(); + if (incoming.containsKey("max_brightness")) DPX_MAX_BRI = incoming["max_brightness"].as(); + if (incoming.containsKey("ldr_factor")) DPX_LDR_FACTOR = incoming["ldr_factor"].as(); + if (incoming.containsKey("ldr_gamma")) DPX_LDR_GAMMA = incoming["ldr_gamma"].as(); + if (incoming.containsKey("timezone_posix")) { + DPX_TIMEZONE = incoming["timezone_posix"].as(); + setenv("TZ", DPX_TIMEZONE.c_str(), 1); + tzset(); + } + + return true; +} + +// ── Read dev.json as a String ───────────────────────────────────────────────── +static String dpxReadDevJson() { + if (!LittleFS.exists("/dev.json")) return F("{}"); + File f = LittleFS.open("/dev.json", "r"); + if (!f) return F("{}"); + String s = f.readString(); + f.close(); + return s; +} diff --git a/usermods/dpx_matrix/dpx_tc.h b/usermods/dpx_matrix/dpx_tc.h new file mode 100644 index 0000000000..0c1fc89e90 --- /dev/null +++ b/usermods/dpx_matrix/dpx_tc.h @@ -0,0 +1,112 @@ +// ================================================================================ +// dpx_tc.h — Timecode Display Helper +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// Ported/adapted from dpx_tc001 ServerManager.cpp::pushTCDisplay() +// (original work, not AWTRIX-derived) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_tc.h +// Purpose: Parses HH:MM:SS:FF timecode strings, renders them on the matrix, +// and manages the TC display lock (auto-switches to tc app, disables +// auto-transition, auto-restores after DPX_TC_DWELL_MS). +// +// Mode A (DPX_TC_SHOW_FRAMES=false, default): +// Renders HH:MM:SS as text. Bottom 2 rows (6-7) show a frame-progress bar +// that fills left-to-right green→amber as frames advance within the second. +// +// Mode B (DPX_TC_SHOW_FRAMES=true): +// Renders compact MM:SS.FF — fits 32px wide, no bar. +// +// ================================================================================ + +#pragma once +#include "dpx_apps.h" + +static unsigned long dpxTcLastMs = 0; // millis() of last TC packet +static bool dpxTcLocked = false; // display locked to TC app + +// Parse "HH:MM:SS:FF" or "HH:MM:SS,FF" into components. +// Returns false if parse failed. +static bool dpxParseTc(const String& tc, int& h, int& m, int& s, int& f) { + h = m = s = f = 0; + return sscanf(tc.c_str(), "%d:%d:%d%*c%d", &h, &m, &s, &f) >= 3; +} + +// Push a timecode packet — builds a custom app JSON and sets "tc" app. +static void dpxPushTC(const String& tc, int fps = 30) { + int h, m, s, f; + if (!dpxParseTc(tc, h, m, s, f)) return; + if (f >= fps) f = fps - 1; + + DpxCustomApp app; + app.valid = true; + app.noScroll = true; + app.color = 0xFFFFFF; + app.topText = true; + + if (DPX_TC_SHOW_FRAMES) { + // Mode B: MM:SS.FF + char buf[12]; + snprintf(buf, sizeof(buf), "%02d:%02d.%02d", m, s, f); + app.text = buf; + } else { + // Mode A: HH:MM:SS + frame progress bar + char buf[12]; + snprintf(buf, sizeof(buf), "%02d:%02d:%02d", h, m, s); + app.text = buf; + + // Build progress bar as draw commands + int barW = (f * DPX_MATRIX_W) / fps; + // Background bar — rows 6-7 + DpxDrawCmd bg; + bg.cmd = "df"; bg.n[0]=0; bg.n[1]=6; bg.n[2]=DPX_MATRIX_W; bg.n[3]=2; bg.color=0x1a1a1a; + app.drawCmds.push_back(bg); + // Progress fill — green→amber + if (barW > 0) { + DpxDrawCmd bar; + bar.cmd = "df"; bar.n[0]=0; bar.n[1]=6; bar.n[2]=barW; bar.n[3]=2; + uint8_t r = (uint8_t)((barW * 255) / DPX_MATRIX_W); + uint8_t g = (uint8_t)(200 - (barW * 100) / DPX_MATRIX_W); + bar.color = ((uint32_t)r << 16) | ((uint32_t)g << 8); + app.drawCmds.push_back(bar); + } + } + + // Install as custom app "tc" + dpxCustom["tc"] = app; + dpxRebuildLoop(); + + // Lock display to tc app + dpxTcLastMs = millis(); + if (!dpxTcLocked) { + // Switch to tc app and disable auto-transition + for (int i = 0; i < (int)dpxApps.size(); i++) { + if (dpxApps[i].name == "tc") { + dpxCurrentApp = i; + dpxAppStartMs = millis(); + dpxScroll.stop(); + break; + } + } + dpxAutoTrans = false; + dpxTcLocked = true; + dpxActivateEffect(); // ensure dpx Matrix effect is showing + } +} + +// TC dwell tick — call from loop(). Restores auto-transition after dwell. +static void dpxTcDwellTick() { + if (!dpxTcLocked) return; + if (DPX_TC_HOLD) return; // never auto-dismiss in hold mode + if (millis() - dpxTcLastMs > DPX_TC_DWELL_MS) { + // Remove tc app and restore auto-transition + dpxCustom.erase("tc"); + dpxRebuildLoop(); + dpxAutoTrans = true; + dpxTcLocked = false; + dpxNextApp(); + } +} diff --git a/usermods/dpx_matrix/dpx_text.h b/usermods/dpx_matrix/dpx_text.h new file mode 100644 index 0000000000..522879a067 --- /dev/null +++ b/usermods/dpx_matrix/dpx_text.h @@ -0,0 +1,190 @@ +// ================================================================================ +// dpx_text.h — Text Rendering for 32×8 LED Matrix +// ================================================================================ +// Original work — dubpixel / dpx_tc002 (EUPL v1.2) +// ================================================================================ +// PROJECT: dpx_tc002_frm +// ================================================================================ +// +// File: dpx_text.h +// Purpose: Pixel-level text rendering using dpx_font.h onto WLED's LED buffer. +// Functions operate on a 32-pixel-wide, 8-pixel-tall matrix. +// Pixel (x, y) = strip index y*32 + x. +// Caller must hold the strip lock / call from handleOverlayDraw(). +// +// ================================================================================ + +#pragma once +#include "dpx_font.h" + +// Matrix dimensions — Ulanzi TC001 +static const int DPX_MATRIX_W = 32; +static const int DPX_MATRIX_H = 8; + +// ── Pixel write via SEGMENT ──────────────────────────────────────────────────── +// Using SEGMENT.setPixelColorXY() instead of strip.setPixelColor() means WLED +// handles brightness, transitions, power-fade, and 2D panel mapping (incl. +// serpentine) automatically. Only valid from within an effect function context. +static inline void dpxSetPixel(int x, int y, uint32_t color) { + if (x < 0 || x >= DPX_MATRIX_W || y < 0 || y >= DPX_MATRIX_H) return; + SEGMENT.setPixelColorXY(x, y, color); +} + +static inline uint32_t dpxGetPixel(int x, int y) { + if (x < 0 || x >= DPX_MATRIX_W || y < 0 || y >= DPX_MATRIX_H) return 0; + return SEGMENT.getPixelColorXY(x, y); +} + +// ── Fill / clear ─────────────────────────────────────────────────────────────── +static inline void dpxFillRect(int x, int y, int w, int h, uint32_t color) { + for (int row = y; row < y + h; row++) + for (int col = x; col < x + w; col++) + SEGMENT.setPixelColorXY(col, row, color); +} + +static inline void dpxClear() { SEGMENT.fill(0); } + +// ── Rainbow colour for a character index ────────────────────────────────────── +static inline uint32_t dpxRainbowColor(int charIdx, int totalChars) { + // Evenly distribute hue across the string. + // WLED's CRGBW(CHSV) constructor applies the rainbow-method conversion. + uint8_t hue = (totalChars > 1) + ? (uint8_t)((uint32_t)charIdx * 255 / totalChars) + : 0; + CRGBW rgb = CHSV(hue, 255, 255); + return (uint32_t)rgb.r << 16 | (uint32_t)rgb.g << 8 | rgb.b; +} + +// ── Draw a single glyph using AwtrixFont (GFX row-major format) ─────────────── +// x = left edge of where the glyph will land +// baseline = cursor_y (pass DPX_FONT_BASELINE to centre in 8-row display) +// Returns the next cursor X (x + xAdvance). +// Glyphs that land partially off-left are clipped pixel-by-pixel. +static int dpxDrawChar(int x, int baseline, char c, uint32_t color) { + extern const GFXfont AwtrixFont; + uint8_t ci = (uint8_t)c; + if (ci < AwtrixFont.first || ci > AwtrixFont.last) return x + 4; + + GFXglyph g; + memcpy_P(&g, &AwtrixFont.glyph[ci - AwtrixFont.first], sizeof(GFXglyph)); + + // Each row of the glyph is ceil(g.width/8) bytes; here always 1 byte (width=8) + int bytesPerRow = (g.width + 7) / 8; + int glyphTop = baseline + g.yOffset; // top pixel row on matrix + + for (int row = 0; row < (int)g.height; row++) { + int py = glyphTop + row; + if (py < 0 || py >= DPX_MATRIX_H) continue; + for (int b = 0; b < bytesPerRow; b++) { + uint8_t bits = pgm_read_byte( + &((uint8_t*)AwtrixFont.bitmap)[g.bitmapOffset + row * bytesPerRow + b]); + for (int bit = 7; bit >= 0; bit--) { + int col = (b * 8) + (7 - bit); + if (col >= (int)g.width) break; + int px = x + g.xOffset + col; + if (px < 0) continue; + if (px >= DPX_MATRIX_W) break; + if (bits & (1 << bit)) + dpxSetPixel(px, py, color); + } + } + } + return x + g.xAdvance; +} + +// ── Render a text string ─────────────────────────────────────────────────────── +// x = left cursor position +// baseline = cursor_y (use DPX_FONT_BASELINE for centered output) +// rainbow = true overrides color with per-character hue sweep +// Returns the x position after the last character. +static int dpxRenderText(int x, int baseline, const char* text, uint32_t color, bool rainbow = false) { + if (!text) return x; + int n = strlen(text); + int curX = x; + for (int i = 0; i < n; i++) { + uint32_t c = rainbow ? dpxRainbowColor(i, n) : color; + curX = dpxDrawChar(curX, baseline, text[i], c); + if (curX >= DPX_MATRIX_W) break; + } + return curX; +} + +// ── Scroll state (one active scroll per display) ─────────────────────────────── +struct DpxScrollState { + String text; + uint32_t color = 0xFFFFFF; + bool rainbow = false; + int y = DPX_FONT_BASELINE; // baseline row (centred in 8-row display) + int scrollX = DPX_MATRIX_W; // current x offset (starts at right edge) + int speedMs = 50; // ms between scroll steps (lower = faster) + unsigned long lastStepMs = 0; + bool active = false; + int16_t repeat = -1; // scroll repeat count (-1 = infinite) + int16_t repeatsDone = 0; + int textWidth = 0; // cached pixel width + + void start(const String& t, uint32_t col, bool rb, int y_, int speedPct, int16_t rep) { + text = t; + color = col; + rainbow = rb; + y = (y_ == 0) ? DPX_FONT_BASELINE : y_; // default to centred baseline + speedMs = max(10, (int)(50 * 100 / max(1, speedPct))); + repeat = rep; + repeatsDone = 0; + textWidth = dpxTextPixelWidth(t.c_str()); + scrollX = DPX_MATRIX_W; + lastStepMs = 0; + active = true; + } + + void stop() { active = false; } + + // Returns true when the current scroll cycle is complete (scrolled off left). + // Caller should call again to advance another repeat or stop. + bool tick() { + if (!active) return false; + unsigned long now = millis(); + if (now - lastStepMs < (unsigned long)speedMs) return false; + lastStepMs = now; + scrollX--; + // Complete when text has fully scrolled off left + if (scrollX < -(textWidth)) { + repeatsDone++; + if (repeat >= 0 && repeatsDone >= repeat) { + active = false; + return true; // done + } + scrollX = DPX_MATRIX_W; // wrap + } + return false; // still running + } + + // Render current scroll position. Call after tick(). + void render() const { + if (!active) return; + dpxRenderText(scrollX, y, text.c_str(), color, rainbow); + } +}; + +// ── Draw a horizontal line (e.g., progress bar) ─────────────────────────────── +static inline void dpxDrawHLine(int x, int y, int w, uint32_t color) { + for (int i = 0; i < w; i++) dpxSetPixel(x + i, y, color); +} + +// ── Draw a filled rectangle outline ────────────────────────────────────────── +static inline void dpxDrawRect(int x, int y, int w, int h, uint32_t color) { + for (int i = 0; i < w; i++) { dpxSetPixel(x+i, y, color); dpxSetPixel(x+i, y+h-1, color); } + for (int i = 1; i < h-1; i++) { dpxSetPixel(x, y+i, color); dpxSetPixel(x+w-1, y+i, color); } +} + +// ── Progress bar: rows 6-7, full width 32px ─────────────────────────────────── +// pct 0-100, barColor = fill, bgColor = background +static void dpxDrawProgressBar(int pct, uint32_t barColor, uint32_t bgColor) { + int filled = (pct * DPX_MATRIX_W) / 100; + filled = constrain(filled, 0, DPX_MATRIX_W); + for (int x = 0; x < DPX_MATRIX_W; x++) { + uint32_t c = (x < filled) ? barColor : bgColor; + dpxSetPixel(x, 6, c); + dpxSetPixel(x, 7, c); + } +} diff --git a/usermods/dpx_matrix/library.json b/usermods/dpx_matrix/library.json new file mode 100644 index 0000000000..bd07292368 --- /dev/null +++ b/usermods/dpx_matrix/library.json @@ -0,0 +1,5 @@ +{ + "name": "dpx_matrix", + "build": { "libArchive": false }, + "dependencies": {} +} diff --git a/wled00/const.h b/wled00/const.h index 04ff8ded61..b9f64aeeef 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -230,6 +230,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_DPX_MATRIX 59 //Usermod "dpx_matrix" — dubpixel TC002 LED matrix display //Wifi encryption type #ifdef WLED_ENABLE_WPA_ENTERPRISE @@ -695,7 +696,9 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #endif #endif #define DEFAULT_LED_TYPE TYPE_WS2812_RGB -#define DEFAULT_LED_COUNT 30 +#ifndef DEFAULT_LED_COUNT + #define DEFAULT_LED_COUNT 30 +#endif #define INTERFACE_UPDATE_COOLDOWN 1000 // time in ms to wait between websockets, alexa, and MQTT updates