From 05be118dd1f4009cd94ab7cf8b62e3c1792ed965 Mon Sep 17 00:00:00 2001 From: localhorst Date: Mon, 5 Jan 2026 21:01:26 +0100 Subject: [PATCH] Port to ESP32 --- .gitignore | 295 ++++++++++++++++ ARCHITECTURE.md | 390 ++++++++++++++++++++++ CMakeLists.txt | 5 + README.md | 194 +++++++++-- main/CMakeLists.txt | 14 + main/animation.c | 471 ++++++++++++++++++++++++++ main/animation.h | 63 ++++ main/control.c | 686 ++++++++++++++++++++++++++++++++++++++ main/control.h | 87 +++++ main/led.c | 476 ++++++++++++++++++++++++++ main/led.h | 136 ++++++++ main/main.c | 94 ++++++ main/rcsignal.c | 202 +++++++++++ main/rcsignal.h | 60 ++++ partitions.csv | 6 + tools/dev_https_server.py | 120 +++++++ tools/requirements.txt | 5 + webapp/app/app.js | 322 ++++++++++++++++++ webapp/css/style.css | 313 +++++++++++++++++ webapp/data/favicon.ico | 0 webapp/index.html | 115 +++++++ 21 files changed, 4025 insertions(+), 29 deletions(-) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CMakeLists.txt create mode 100644 main/CMakeLists.txt create mode 100644 main/animation.c create mode 100644 main/animation.h create mode 100644 main/control.c create mode 100644 main/control.h create mode 100644 main/led.c create mode 100644 main/led.h create mode 100644 main/main.c create mode 100644 main/rcsignal.c create mode 100644 main/rcsignal.h create mode 100644 partitions.csv create mode 100644 tools/dev_https_server.py create mode 100644 tools/requirements.txt create mode 100644 webapp/app/app.js create mode 100644 webapp/css/style.css create mode 100644 webapp/data/favicon.ico create mode 100644 webapp/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01aae50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,295 @@ +# ---> esp-idf +# gitignore template for esp-idf, the official development framework for ESP32 +# https://github.com/espressif/esp-idf + +build/ +sdkconfig +sdkconfig.old + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# ---> esp-idf +# gitignore template for esp-idf, the official development framework for ESP32 +# https://github.com/espressif/esp-idf + +build/ +sdkconfig +sdkconfig.old + +# ---> CMake +CMakeLists.txt.user +CMakeCache.txt +CMakeFiles +CMakeScripts +Testing +Makefile +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +_deps + +# ---> C +# Prerequisites +*.d + +# Object files +*.o +*.ko +*.obj +*.elf + +# Linker output +*.ilk +*.map +*.exp + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb + +# Kernel Module Compile Results +*.mod* +*.cmd +.tmp_versions/ +modules.order +Module.symvers +Mkfile.old +dkms.conf + +# ---> C++ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +.vscode/settings.json +sdkconfig.defaults diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..8a4bce1 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,390 @@ +# Architecture Documentation + +## System Overview + +The ESP32 LED Controller is a real-time embedded system designed for model aircraft LED control with the following key components: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ ┌──────────┐ ┌───────────┐ ┌──────────────────────┐ │ +│ │ main.c │ │ Web-BLE │ │ Animation Patterns │ │ +│ └────┬─────┘ └─────┬─────┘ └──────────┬───────────┘ │ +└───────┼──────────────┼────────────────────┼─────────────┘ + │ │ │ +┌───────┴──────────────┴────────────────────┴─────────────┐ +│ Control Layer │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ control.c - System Orchestration │ │ +│ │ - BLE GATT Server │ │ +│ │ - NVS Configuration Storage │ │ +│ │ - OTA Firmware Update │ │ +│ │ - Subsystem Initialization │ │ +│ └──────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ │ │ +┌───────┼──────────────┼────────────────────┼─────────────┐ +│ │ │ │ │ +│ ┌────▼────┐ ┌────▼─────┐ ┌────▼─────────┐ │ +│ │ LED │ │ RC Signal│ │ Animation │ │ +│ │ Driver │ │ Reader │ │ Engine │ │ +│ └─────────┘ └──────────┘ └──────────────┘ │ +│ │ +│ Hardware Layer │ +└──────────────────────────────────────────────────────────┘ + │ │ │ +┌───────┼──────────────┼────────────────────┼─────────────┐ +│ ┌────▼────┐ ┌────▼─────┐ │ +│ │ RMT │ │ GPIO │ │ +│ │ (WS2812)│ │ (PWM) │ │ +│ └─────────┘ └──────────┘ │ +│ │ +│ ESP-IDF HAL & Drivers │ +└──────────────────────────────────────────────────────────┘ +``` + +## Module Details + +### 1. main.c - Application Entry Point +**Responsibilities:** +- System initialization orchestration +- Animation task creation and management +- System health monitoring +- Error handling and recovery + +**Key Functions:** +- `app_main()`: Entry point, initializes control system +- `animation_task()`: FreeRTOS task for 60 FPS animation updates + +**Task Priority:** Animation task runs at priority 5 (above default) + +--- + +### 2. control.c/h - System Control & Orchestration +**Responsibilities:** +- BLE GATT server implementation +- Configuration management (NVS) +- OTA firmware update handling +- Subsystem initialization and coordination + +**BLE Service Structure:** +``` +Service UUID: 0x00FF +├── Config Characteristic (0xFF01) +│ ├── Read: Get current configuration +│ └── Write: Update and persist configuration +├── Mode Characteristic (0xFF02) +│ ├── Read: Get current animation mode +│ └── Write: Set animation mode +├── PWM Emulation (0xFF03) +│ └── Write: Trigger mode change +└── OTA Characteristic (0xFF04) + └── Write: Stream firmware binary +``` + +**Configuration Storage:** +```c +typedef struct { + int8_t led_pin_strip_a; // -1 = disabled + int8_t led_pin_strip_b; // -1 = disabled + int8_t pwm_pin; // -1 = disabled + ble_timeout_t ble_timeout; // 0, 60, 300 seconds + uint32_t magic; // 0xDEADBEEF validation +} controller_config_t; +``` + +**NVS Namespace:** `led_ctrl` +**NVS Key:** `config` + +**BLE Timeout Logic:** +- Timer starts at boot +- Pauses when device connects +- Resumes when device disconnects +- Disables BLE advertising on timeout + +**OTA Update Flow:** +1. Client writes firmware data in 512-byte chunks +2. ESP32 writes to inactive OTA partition +3. Last chunk (< 512 bytes) triggers completion +4. Validates partition, sets boot partition +5. Resets configuration and restarts + +--- + +### 3. led.c/h - WS2812B LED Driver +**Technology:** ESP32 RMT (Remote Control) peripheral + +**Why RMT?** +- Hardware timing generation (no CPU overhead) +- Precise WS2812B timing requirements: + - T0H: 350ns ±150ns + - T0L: 900ns ±150ns + - T1H: 900ns ±150ns + - T1L: 350ns ±150ns + - Reset: >280µs + +**Implementation:** +``` +RMT Channel → Custom Encoder → WS2812B Strip + ↓ ↓ ↓ + 80 MHz GRB Encoding Serial Data +``` + +**Color Management:** +- Internal RGB buffer for each strip +- Thread-safe access via mutex +- Hardware converts RGB → GRB for WS2812B +- Support for HSV → RGB conversion + +**Key Functions:** +- `led_init()`: Configure RMT channels +- `led_set_pixel_a/b()`: Set individual LED +- `led_fill_a/b()`: Set all LEDs same color +- `led_show()`: Update physical LEDs +- `led_fade_to_black()`: Fade effect for trails + +**Memory Usage:** +- Strip A buffer: num_leds * 3 bytes (RGB) +- Strip B buffer: num_leds * 3 bytes (RGB) +- Default: 44 LEDs * 2 strips * 3 = 264 bytes + +--- + +### 4. rcsignal.c/h - PWM Signal Reader +**Technology:** GPIO interrupt + software edge detection + +**PWM Signal Specification:** +- Standard RC PWM: 1000-2000µs pulse width +- Detection threshold: 1500µs +- Timeout: 100ms (signal loss detection) + +**Mode Change Logic:** +``` +PWM < 1500µs → Set "pull_detected" flag + ↓ +PWM > 1500µs AND pull_detected → Mode++ + ↓ +pull_detected = false +``` + +**Implementation:** +- ISR captures rising/falling edges +- Calculates pulse width in microseconds +- Monitor task (10ms interval) detects mode changes +- Callback notifies animation system + +**Thread Safety:** +- Volatile variables for ISR communication +- Monitor task runs at priority 5 + +--- + +### 5. animation.c/h - Animation Engine +**Update Rate:** 60 FPS (16.67ms per frame) + +**Global State:** +- `global_hue`: Slow color cycling (updates every 3 frames) +- `frame_counter`: Frame synchronization +- `current_mode`: Active animation pattern + +**Animation Techniques:** + +#### Fade Effects +```c +// Smooth trails for chase animations +led_fade_to_black(amount); +// Each LED: color = (color * (255 - amount)) / 255 +``` + +#### Beat Synchronization +```c +// Sine wave based on BPM and time +beatsin16(bpm, min_val, max_val); +// Returns position oscillating between min and max +``` + +#### HSV Color Space +- Hue: 0-255 (color wheel) +- Saturation: 0-255 (color intensity) +- Value: 0-255 (brightness) +- Automatic RGB conversion + +**Animation Modes Breakdown:** + +1. **Static Colors** (Black, Red, Blue, Green, White) + - Single `led_fill()` call + - No per-frame updates needed + +2. **Rainbow** + - HSV hue gradient across strip + - Hue offset per LED: `global_hue + (i * 7)` + - Global hue increments for animation + +3. **Rainbow Glitter** + - Base rainbow + random white sparkles + - 80/255 chance per frame + - Random LED position + +4. **Confetti** + - Fade to black (10/255 per frame) + - Random position + random hue + - Creates "fireworks" effect + +5. **Sinelon** + - Sweeping dot using sine wave + - Position: `beatsin16(13 BPM)` + - 20/255 fade creates trails + +6. **BPM** + - Color palette based on party colors + - Beat: `beatsin8(33 BPM)` + - Brightness modulation per LED + +7. **Navigation** + - Fixed positions for aviation lights + - Red: LEDs 0-2 (left) + - Green: Last 3 LEDs (right) + - White blink: 30 Hz (half frame rate) + +8. **Chase (Red)** + - Red dot with ±2 LED trail + - Position: `beatsin16(40 BPM)` + - No fade (instant clear) + +9. **Chase RGB** + - Same as Chase but HSV color cycling + - Hue: `global_hue` + +10. **Random** + - Random LED, random color each frame + - Rare full clear event + +--- + +## Data Flow + +### Configuration Update Flow +``` +Web Browser → BLE Write → control.c → NVS Save → Restart (if pins changed) +``` + +### Animation Update Flow +``` +animation_task (60Hz) → animation_update() → LED buffer → led_show() → RMT → LEDs +``` + +### PWM Mode Change Flow +``` +RC Signal → GPIO ISR → rcsignal.c → Callback → control.c → animation.c +``` + +### OTA Update Flow +``` +Web Browser → BLE Write (chunks) → control.c → esp_ota → Flash → Restart +``` + +## Thread Safety + +### Mutexes Used +1. **led_mutex**: Protects LED buffer access + - Used by: animation_update(), led_show() + - Type: FreeRTOS mutex + +### ISR Safety +- **rcsignal.c**: Volatile variables for ISR communication +- **Minimal ISR work**: Only timestamp and edge detection +- **Deferred processing**: Monitor task handles logic + +### Task Priorities +``` +Priority 5: animation_task, rcsignal_monitor_task +Priority 1: BLE stack tasks (default) +Priority 0: Idle task +``` + +## Memory Management + +### Static Allocation +- Configuration structure: 11 bytes (NVS) +- Animation state: ~100 bytes (global variables) + +### Dynamic Allocation +- LED buffers: `num_leds * 3 * 2` bytes (both strips) +- RMT encoder: ~200 bytes per strip +- BLE stack: ~30KB (ESP-IDF managed) + +### Flash Usage +- Code: ~500KB (with BLE stack) +- OTA partitions: 2x 1MB (dual-boot) +- NVS: 24KB +- Factory: 1MB + +### Heap Usage Estimate +- Total: ~50KB during operation +- Available: ~250KB on ESP32 + +## Power Optimization + +### Active Mode +- CPU: 240 MHz (animation processing) +- BLE: Active (advertising/connected) +- Power: ~180mA (ESP32 only) + +### BLE Disabled Mode +- CPU: 240 MHz (animation only) +- BLE: Disabled after timeout +- Power: ~100mA (ESP32 only) + +### LED Power +- Per LED: ~60mA at full white +- 44 LEDs full white: ~2.6A +- Typical animation: ~500mA average + +## ESP32 vs ESP32-C3 Differences + +### ESP32 (Xtensa) +- Dual-core: FreeRTOS symmetric multiprocessing +- BLE + Classic Bluetooth controller +- More GPIO pins available +- Recommended for complex projects + +### ESP32-C3 (RISC-V) +- Single-core: Simpler task management +- BLE only (no Classic Bluetooth) +- Fewer GPIO pins +- Lower cost option + +### Compatibility +- Same codebase works on both +- Pin numbers differ (check datasheet) +- RMT peripheral identical +- BLE functionality identical + +## Performance Characteristics + +### Latency +- **PWM detection**: <10ms +- **BLE command**: <100ms +- **Mode change**: <20ms (next frame) +- **LED update**: 16.67ms (60 FPS locked) + +### Throughput +- **LED data**: ~13.44 Mbps theoretical (RMT) +- **BLE**: ~1 Mbps (limited by MTU) +- **OTA**: ~40 KB/s (BLE transfer) + +### Timing Precision +- **Animation frame**: ±0.5ms jitter +- **WS2812B timing**: ±50ns (RMT hardware) +- **PWM measurement**: ±1µs (ISR timing) + +--- + + +## Future Enhancement Ideas TODO + +1. **Dynamic LED Count**: Auto-detect number of LEDs +2. **Multi-Strip Sync**: Synchronized patterns +3. **Pattern Editor**: Visual animation designer +4. **Scheduler**: Time-based mode changes + diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0c80f0e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,5 @@ +# ESP32 LED Controller Firmware +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(led_controller) diff --git a/README.md b/README.md index 910652c..fa6b7dd 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,174 @@ -# WS2812B-LED-RC-Controller -WS2812B Controller for RC plane night flying with spotlight +# ESP32 LED Controller for Model Aircraft -#### Fast overview: Video will come soon. +Professional LED controller firmware for ESP32 with Web-BLE configuration interface. Designed for model aircraft with WS2812B LED strips. -[![IMAGE ALT TEXT HERE](https://img.youtube.com/vi//0.jpg)] -(https://www.youtube.com/watch?v=) +## Features -## 1.Hardware: -- Atmel Atmega328p -- WS2812B stirp (got mine from https://www.banggood.com/5M-45W-150SMD-WS2812B-LED-RGB-Colorful-Strip-Light-Waterproof-IP65-WhiteBlack-PCB-DC5V-p-1035640.html) -- 3W 12V LED -- Transistor 2N3904 -- Capacitors - 10uF/25V -- Socket for Atmega -- Some servo wires -- Crystal 16MHz -- Capacitor Ceramic 22pF -- PCB of your choice +### Hardware Support +- **ESP32 DevKitC** and **ESP32-C3 MINI** Development Board +- Dual WS2812B LED strip support (configurable GPIOs) +- PWM signal input for RC control +- Real-time LED animation at 60 FPS -## 2.Software: -- get your isp-programmer (ex. USBasp) working, linux is your friend -- install latest Arduino IDE and drivers -- install FastLED https://github.com/FastLED/FastLED +### Animation Modes +1. **Black** - All LEDs off +2. **Red** - Solid red +3. **Blue** - Solid blue +4. **Green** - Solid green +5. **White** - Solid white +6. **Rainbow** - Smooth rainbow gradient +7. **Rainbow with Glitter** - Rainbow with sparkles +8. **Confetti** - Random colored speckles +9. **Sinelon** - Sweeping colored dot with trails +10. **BPM** - Pulsing stripes at 33 BPM +11. **Navigation** - Aviation lights (red left, green right) +12. **Chase (Red)** - Red dot chase effect +13. **Chase (RGB)** - RGB cycling chase effect +14. **Random** - Random LED colors -## 3.Libraries used in this project: -- FastLED from https://github.com/FastLED/FastLED +### Web-BLE Configuration +- **Pin Configuration**: Set GPIO pins for LED strips and PWM input +- **BLE Auto-Off**: Configure timeout (Never/1min/5min) +- **Manual Control**: Select animation modes from web interface +- **PWM Emulation**: Test mode switching without RC signal +- **OTA Firmware Update**: Upload new firmware via Bluetooth -### Installation: -1. prepare Hardware. Ground to Ground and the rest like the schematics (comming soon). -2. Upload the sketch to the Arduino with the ISP-Programmer. -3. Set the switches on your RC control for the two channels. -7. Power everything up. -8. Enjoy your WS2812B-LED-RC-Controller +## Project Structure -Bug and Features: Please report bugs and wishes to me. Thanks! +``` +led-controller-firmware/ +├── main/ +│ ├── main.c # Application entry point +│ ├── control.c/h # BLE, NVS, initialization +│ ├── led.c/h # WS2812B control (RMT driver) +│ ├── rcsignal.c/h # PWM signal reading +│ └── animation.c/h # LED animation patterns +├── webapp/ +│ ├── index.html # Web-BLE interface +│ ├── app/app.js # BLE communication logic +│ ├── css/style.css # UI styling +│ └── data/favicon.ico +├── CMakeLists.txt +├── sdkconfig.defaults +└── partitions.csv # OTA-enabled partition table +``` +## Build Instructions + +### Prerequisites +1. Install ESP-IDF v5.0 or later + +2. For ESP32-C3, ensure RISC-V toolchain is installed + +### Building + +```bash +cd led-controller-firmware + +# For ESP32 DevKitC +idf.py set-target esp32 +idf.py build + +# For ESP32-C3 MINI +idf.py set-target esp32c3 +idf.py build +``` + +### Flashing + +```bash +idf.py -p /dev/ttyUSB0 flash monitor +``` + +Replace `/dev/ttyUSB0` with your serial port (COM3 on Windows). + +## Hardware Setup + +### Wiring +``` +ESP32 Pin -> Component +----------- ---------- +GPIO XX -> WS2812B Strip A Data +GPIO XX -> WS2812B Strip B Data +GPIO XX -> RC PWM Signal +GND -> Common Ground +5V -> LED Strip Power (if current < 500mA) +``` + +### LED Strips +- **WS2812B** strips require 5V power +- Each LED draws ~60mA at full white +- Use external power supply for >10 LEDs +- Add 100-500µF capacitor near strips +- Add 330Ω resistor on data line + +### PWM Signal +- Standard RC PWM: 1000-2000µs pulse width +- 1500µs threshold for mode switching +- Rising edge >1500µs after <1500µs triggers next mode + +## Web-BLE Configuration + +### Access the Interface + +1. Open `webapp/index.html` in Chrome, Edge, or Opera (Web Bluetooth required) +2. Click "Connect via BLE" +3. Select "LED-Controller" from device list +4. Configure settings and control LEDs + +### Configuration Options + +#### Pin Setup +- **LED Strip A GPIO**: -1 to disable, 0-48 for GPIO pin +- **LED Strip B GPIO**: -1 to disable, 0-48 for GPIO pin +- **PWM Input GPIO**: -1 to disable, 0-48 for GPIO pin + +#### BLE Timeout +- **Never**: BLE stays on until manually disabled +- **1 Minute**: Auto-disable after 1 min of boot (unless connected) +- **5 Minutes**: Auto-disable after 5 min of boot (unless connected) + +#### Firmware Update +1. Build firmware: `idf.py build` +2. Find binary: `build/led_controller.bin` +3. Upload via Web-BLE interface +4. Device restarts with new firmware (settings reset) + +## Default Configuration + +On first boot or after reset: +- All pins: **Not configured** (-1) +- BLE timeout: **Never** +- Animation mode: **Black** (off) + +Configure via Web-BLE before use. + +## Development + +### Adding New Animations + +1. Add mode to `animation_mode_t` enum in `animation.h` +2. Implement animation function in `animation.c` +3. Add case to `animation_update()` switch statement +4. Update `MODE_NAMES` array in `webapp/app/app.js` + +### Modifying LED Count + +Edit `DEFAULT_NUM_LEDS_A` and `DEFAULT_NUM_LEDS_B` in `control.c`. TODO + +### Testing + +```bash +# Build and flash +idf.py build flash + +# Monitor output +idf.py monitor + +# Exit monitor: Ctrl+] +``` + +## License + +See [LICENSE](LICENSE) diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..a68f73b --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,14 @@ +idf_component_register( + SRCS + "main.c" + "control.c" + "led.c" + "rcsignal.c" + "animation.c" + INCLUDE_DIRS "." + EMBED_FILES + "../webapp/index.html" + "../webapp/app/app.js" + "../webapp/css/style.css" + "../webapp/data/favicon.ico" +) diff --git a/main/animation.c b/main/animation.c new file mode 100644 index 0000000..6200539 --- /dev/null +++ b/main/animation.c @@ -0,0 +1,471 @@ +/** + * @file animation.c + * @brief LED animation patterns implementation + */ + +#include "animation.h" +#include "led.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "esp_random.h" +#include +#include + +static const char *TAG = "ANIMATION"; + +#define FRAMES_PER_SECOND 60 +#define NUM_LEDS_DEFAULT 44 //TODO: Default from proof-of-concept + +static animation_mode_t current_mode = ANIM_BLACK; +static uint8_t global_hue = 0; +static uint32_t frame_counter = 0; + +// Beat calculation helper (similar to FastLED beatsin16) +static int16_t beatsin16(uint8_t bpm, int16_t min_val, int16_t max_val) +{ + uint32_t ms = esp_timer_get_time() / 1000; + uint32_t beat = (ms * bpm * 256) / 60000; + uint8_t beat8 = (beat >> 8) & 0xFF; + + // Sin approximation + float angle = (beat8 / 255.0f) * 2.0f * M_PI; + float sin_val = sinf(angle); + + int16_t range = max_val - min_val; + int16_t result = min_val + (int16_t)((sin_val + 1.0f) * range / 2.0f); + + return result; +} + +// Beat calculation helper (beatsin8 variant) +static uint8_t beatsin8(uint8_t bpm, uint8_t min_val, uint8_t max_val) +{ + return (uint8_t)beatsin16(bpm, min_val, max_val); +} + +// Random helper +static uint8_t random8(void) +{ + return esp_random() & 0xFF; +} + +static uint16_t random16(uint16_t max) +{ + if (max == 0) + return 0; + return esp_random() % max; +} + +// Animation implementations +static void anim_black(void) +{ + rgb_t black = {0, 0, 0}; + led_fill_a(black); + led_fill_b(black); +} + +static void anim_red(void) +{ + rgb_t red = {255, 0, 0}; + led_fill_a(red); + led_fill_b(red); +} + +static void anim_blue(void) +{ + rgb_t blue = {0, 0, 255}; + led_fill_a(blue); + led_fill_b(blue); +} + +static void anim_green(void) +{ + rgb_t green = {0, 255, 0}; + led_fill_a(green); + led_fill_b(green); +} + +static void anim_white(void) +{ + rgb_t white = {255, 255, 255}; + led_fill_a(white); + led_fill_b(white); +} + +static void anim_rainbow(void) +{ + // FastLED's built-in rainbow generator + uint16_t num_leds_a = led_get_num_leds_a(); + uint16_t num_leds_b = led_get_num_leds_b(); + + for (uint16_t i = 0; i < num_leds_a; i++) + { + hsv_t hsv = {(uint8_t)(global_hue + (i * 7)), 255, 255}; + led_set_pixel_a(i, led_hsv_to_rgb(hsv)); + } + + for (uint16_t i = 0; i < num_leds_b; i++) + { + hsv_t hsv = {(uint8_t)(global_hue + (i * 7)), 255, 255}; + led_set_pixel_b(i, led_hsv_to_rgb(hsv)); + } +} + +static void add_glitter(uint8_t chance_of_glitter) +{ + if (random8() < chance_of_glitter) + { + uint16_t num_leds = led_get_num_leds_a() + led_get_num_leds_b(); + uint16_t pos = random16(num_leds); + rgb_t white = {255, 255, 255}; + + if (pos < led_get_num_leds_a()) + { + led_add_pixel_a(pos, white); + } + else + { + led_add_pixel_b(pos - led_get_num_leds_a(), white); + } + } +} + +static void anim_rainbow_glitter(void) +{ + anim_rainbow(); + add_glitter(80); +} + +static void anim_confetti(void) +{ + // Random colored speckles that blink in and fade smoothly + led_fade_to_black(10); + + uint16_t num_leds = led_get_num_leds_a() + led_get_num_leds_b(); + uint16_t pos = random16(num_leds); + + hsv_t hsv = {(uint8_t)(global_hue + random8()), 200, 255}; + rgb_t color = led_hsv_to_rgb(hsv); + + if (pos < led_get_num_leds_a()) + { + led_add_pixel_a(pos, color); + } + else + { + led_add_pixel_b(pos - led_get_num_leds_a(), color); + } +} + +static void anim_sinelon(void) +{ + // A colored dot sweeping back and forth, with fading trails + led_fade_to_black(20); + + uint16_t num_leds = led_get_num_leds_a() + led_get_num_leds_b(); + int16_t pos = beatsin16(13, 0, num_leds - 1); + + hsv_t hsv = {global_hue, 255, 192}; + rgb_t color = led_hsv_to_rgb(hsv); + + if (pos < led_get_num_leds_a()) + { + led_add_pixel_a(pos, color); + } + else + { + led_add_pixel_b(pos - led_get_num_leds_a(), color); + } +} + +static void anim_bpm(void) +{ + // Colored stripes pulsing at 33 BPM + uint8_t bpm = 33; + uint8_t beat = beatsin8(bpm, 64, 255); + + uint16_t num_leds_a = led_get_num_leds_a(); + uint16_t num_leds_b = led_get_num_leds_b(); + + // PartyColors palette simulation + const uint8_t palette_colors[] = { + 170, 240, 90, 150, 210, 30, 180, 0, + 210, 270, 150, 240, 330, 60, 300, 120}; + + for (uint16_t i = 0; i < num_leds_a; i++) + { + uint8_t color_index = (global_hue + (i * 2)) & 0x0F; + uint8_t brightness = beat - global_hue + (i * 10); + hsv_t hsv = {palette_colors[color_index], 255, brightness}; + led_set_pixel_a(i, led_hsv_to_rgb(hsv)); + } + + for (uint16_t i = 0; i < num_leds_b; i++) + { + uint8_t color_index = (global_hue + ((i + num_leds_a) * 2)) & 0x0F; + uint8_t brightness = beat - global_hue + ((i + num_leds_a) * 10); + hsv_t hsv = {palette_colors[color_index], 255, brightness}; + led_set_pixel_b(i, led_hsv_to_rgb(hsv)); + } +} + +static void anim_navigation(void) +{ + // Navigation lights: left red, right green, with blinking white + static uint8_t blink_state = 0; + + led_clear_all(); + + uint16_t num_leds_a = led_get_num_leds_a(); + uint16_t num_leds_b = led_get_num_leds_b(); + + rgb_t red = {255, 0, 0}; + rgb_t green = {0, 255, 0}; + rgb_t white = {255, 255, 255}; + + // Left side red (first 3 LEDs of strip A) + if (num_leds_a >= 3) + { + led_set_pixel_a(0, red); + led_set_pixel_a(1, red); + led_set_pixel_a(2, red); + } + + // Right side green (last 3 LEDs) + if (num_leds_b >= 3) + { + led_set_pixel_b(num_leds_b - 1, green); + led_set_pixel_b(num_leds_b - 2, green); + led_set_pixel_b(num_leds_b - 3, green); + } + else if (num_leds_a >= 6) + { + led_set_pixel_a(num_leds_a - 1, green); + led_set_pixel_a(num_leds_a - 2, green); + led_set_pixel_a(num_leds_a - 3, green); + } + + // Blinking white lights (positions 5-6 and 37-38 from original) + if (blink_state < FRAMES_PER_SECOND / 2) + { + if (num_leds_a > 6) + { + led_set_pixel_a(5, white); + led_set_pixel_a(6, white); + } + if (num_leds_b > 2) + { + led_set_pixel_b(1, white); + led_set_pixel_b(2, white); + } + else if (num_leds_a > 38) + { + led_set_pixel_a(37, white); + led_set_pixel_a(38, white); + } + } + + blink_state = (blink_state + 1) % FRAMES_PER_SECOND; +} + +static void anim_chase(void) +{ + // Red dot sweeping with trailing dots + led_clear_all(); + + uint16_t num_leds = led_get_num_leds_a() + led_get_num_leds_b(); + int16_t pos = beatsin16(40, 0, num_leds - 1); + + rgb_t red = {255, 0, 0}; + + // Set main dot and trailing dots + for (int offset = -2; offset <= 2; offset++) + { + int16_t led_pos = pos + offset; + if (led_pos >= 0 && led_pos < num_leds) + { + if (led_pos < led_get_num_leds_a()) + { + led_set_pixel_a(led_pos, red); + } + else + { + led_set_pixel_b(led_pos - led_get_num_leds_a(), red); + } + } + } +} + +static void anim_chase_rgb(void) +{ + // RGB cycling dot sweeping with trailing dots + led_clear_all(); + + uint16_t num_leds = led_get_num_leds_a() + led_get_num_leds_b(); + int16_t pos = beatsin16(40, 0, num_leds - 1); + + hsv_t hsv = {global_hue, 255, 192}; + rgb_t color = led_hsv_to_rgb(hsv); + + // Set main dot and trailing dots + for (int offset = -2; offset <= 2; offset++) + { + int16_t led_pos = pos + offset; + if (led_pos >= 0 && led_pos < num_leds) + { + if (led_pos < led_get_num_leds_a()) + { + led_add_pixel_a(led_pos, color); + } + else + { + led_add_pixel_b(led_pos - led_get_num_leds_a(), color); + } + } + } +} + +static void anim_random(void) +{ + // Random LEDs get random colors + uint16_t num_leds = led_get_num_leds_a() + led_get_num_leds_b(); + uint16_t random_pos = random16(num_leds); + + // Randomly clear all (rare event) + if (random_pos == num_leds - 1 && random8() > 200) + { + led_clear_all(); + return; + } + + // Set random LED to random color + rgb_t random_color = { + random8(), + random8(), + random8()}; + + if (random_pos < led_get_num_leds_a()) + { + led_set_pixel_a(random_pos, random_color); + } + else + { + led_set_pixel_b(random_pos - led_get_num_leds_a(), random_color); + } +} + +esp_err_t animation_init(void) +{ + current_mode = ANIM_BLACK; + global_hue = 0; + frame_counter = 0; + + ESP_LOGI(TAG, "Animation system initialized"); + return ESP_OK; +} + +void animation_set_mode(animation_mode_t mode) +{ + if (mode >= ANIM_MODE_COUNT) + { + mode = ANIM_BLACK; + } + + current_mode = mode; + frame_counter = 0; + + ESP_LOGI(TAG, "Animation mode set to: %s", animation_get_mode_name(mode)); +} + +animation_mode_t animation_get_mode(void) +{ + return current_mode; +} + +void animation_update(void) +{ + // Update global hue every frame (slowly cycles colors) + frame_counter++; + if (frame_counter % 3 == 0) + { + global_hue++; + } + + // Execute current animation + switch (current_mode) + { + case ANIM_BLACK: + anim_black(); + break; + case ANIM_RED: + anim_red(); + break; + case ANIM_BLUE: + anim_blue(); + break; + case ANIM_GREEN: + anim_green(); + break; + case ANIM_WHITE: + anim_white(); + break; + case ANIM_RAINBOW: + anim_rainbow(); + break; + case ANIM_RAINBOW_GLITTER: + anim_rainbow_glitter(); + break; + case ANIM_CONFETTI: + anim_confetti(); + break; + case ANIM_SINELON: + anim_sinelon(); + break; + case ANIM_BPM: + anim_bpm(); + break; + case ANIM_NAVIGATION: + anim_navigation(); + break; + case ANIM_CHASE: + anim_chase(); + break; + case ANIM_CHASE_RGB: + anim_chase_rgb(); + break; + case ANIM_RANDOM: + anim_random(); + break; + default: + anim_black(); + break; + } + + led_show(); +} + +const char *animation_get_mode_name(animation_mode_t mode) +{ + static const char *mode_names[] = { + "Black", + "Red", + "Blue", + "Green", + "White", + "Rainbow", + "Rainbow with Glitter", + "Confetti", + "Sinelon", + "BPM", + "Navigation", + "Chase", + "Chase RGB", + "Random"}; + + if (mode >= ANIM_MODE_COUNT) + { + return "Unknown"; + } + + return mode_names[mode]; +} diff --git a/main/animation.h b/main/animation.h new file mode 100644 index 0000000..0430f0f --- /dev/null +++ b/main/animation.h @@ -0,0 +1,63 @@ +/** + * @file animation.h + * @brief LED animation patterns + */ + +#ifndef ANIMATION_H +#define ANIMATION_H + +#include +#include "esp_err.h" + +/** + * @brief Animation modes + */ +typedef enum { + ANIM_BLACK = 0, // All off + ANIM_RED = 1, // All red + ANIM_BLUE = 2, // All blue + ANIM_GREEN = 3, // All green + ANIM_WHITE = 4, // All white + ANIM_RAINBOW = 5, // FastLED rainbow + ANIM_RAINBOW_GLITTER = 6, // Rainbow with glitter + ANIM_CONFETTI = 7, // Random colored speckles + ANIM_SINELON = 8, // Colored dot sweeping (RGB cycling) + ANIM_BPM = 9, // Colored stripes @ 33 BPM + ANIM_NAVIGATION = 10, // Navigation lights (red left, green right) + ANIM_CHASE = 11, // Red dot sweeping + ANIM_CHASE_RGB = 12, // RGB cycling dot sweeping + ANIM_RANDOM = 13, // Random mode + ANIM_MODE_COUNT +} animation_mode_t; + +/** + * @brief Initialize animation system + * @return ESP_OK on success + */ +esp_err_t animation_init(void); + +/** + * @brief Set current animation mode + * @param mode Animation mode + */ +void animation_set_mode(animation_mode_t mode); + +/** + * @brief Get current animation mode + * @return Current mode + */ +animation_mode_t animation_get_mode(void); + +/** + * @brief Update animation (call periodically, e.g., 30-60 FPS) + */ +void animation_update(void); + +/** + * @brief Get animation mode name + * @param mode Animation mode + * @return Mode name string + */ +const char* animation_get_mode_name(animation_mode_t mode); + +#endif // ANIMATION_H diff --git a/main/control.c b/main/control.c new file mode 100644 index 0000000..824c39e --- /dev/null +++ b/main/control.c @@ -0,0 +1,686 @@ +/** + * @file control.c + * @brief Control module implementation with BLE, NVS, and OTA + */ + +#include "control.h" +#include "led.h" +#include "rcsignal.h" +#include "animation.h" + +#include "esp_log.h" +#include "esp_system.h" +#include "nvs_flash.h" +#include "nvs.h" +#include "esp_timer.h" +#include "esp_ota_ops.h" +#include "esp_http_server.h" + +#include "esp_bt.h" +#include "esp_gap_ble_api.h" +#include "esp_gatts_api.h" +#include "esp_bt_main.h" +#include "esp_gatt_common_api.h" + +#include + +static const char *TAG = "CONTROL"; + +#define NVS_NAMESPACE "led_ctrl" +#define CONFIG_MAGIC 0xDEADBEEF +#define DEFAULT_NUM_LEDS_A 44 +#define DEFAULT_NUM_LEDS_B 44 + +// BLE Configuration +#define GATTS_SERVICE_UUID 0x00FF +#define GATTS_CHAR_UUID_CONFIG 0xFF01 +#define GATTS_CHAR_UUID_MODE 0xFF02 +#define GATTS_CHAR_UUID_PWM 0xFF03 +#define GATTS_CHAR_UUID_OTA 0xFF04 +#define GATTS_NUM_HANDLE_TEST 8 + +#define DEVICE_NAME "LED-Controller" +#define ADV_CONFIG_FLAG (1 << 0) +#define SCAN_RSP_CONFIG_FLAG (1 << 1) + +// Global state +static controller_config_t current_config = { + .led_pin_strip_a = -1, + .led_pin_strip_b = -1, + .pwm_pin = -1, + .ble_timeout = BLE_TIMEOUT_NEVER, + .magic = CONFIG_MAGIC}; + +static bool ble_enabled = true; +static uint8_t current_animation_mode = 0; +static esp_timer_handle_t ble_timeout_timer = NULL; +static bool ble_connected = false; + +// OTA state +static const esp_partition_t *update_partition = NULL; +static esp_ota_handle_t update_handle = 0; +static size_t ota_bytes_written = 0; + +// BLE variables +static uint8_t adv_config_done = 0; +static uint16_t gatts_if_global = ESP_GATT_IF_NONE; +static uint16_t conn_id_global = 0; +static uint16_t service_handle = 0; + +// BLE advertising parameters +static esp_ble_adv_params_t adv_params = { + .adv_int_min = 0x20, + .adv_int_max = 0x40, + .adv_type = ADV_TYPE_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, +}; + +// Characteristic handles +static struct +{ + uint16_t config_handle; + uint16_t mode_handle; + uint16_t pwm_handle; + uint16_t ota_handle; +} char_handles = {0}; + +// Forward declarations +static void ble_timeout_callback(void *arg); +static void on_mode_change(uint8_t new_mode); + +// NVS Functions +static esp_err_t load_config_from_nvs(void) +{ + nvs_handle_t nvs_handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs_handle); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "NVS not found, using defaults"); + return ESP_ERR_NOT_FOUND; + } + + size_t required_size = sizeof(controller_config_t); + err = nvs_get_blob(nvs_handle, "config", ¤t_config, &required_size); + nvs_close(nvs_handle); + + if (err != ESP_OK || current_config.magic != CONFIG_MAGIC) + { + ESP_LOGW(TAG, "Invalid config in NVS, using defaults"); + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Loaded config from NVS"); + ESP_LOGI(TAG, " Strip A: GPIO%d", current_config.led_pin_strip_a); + ESP_LOGI(TAG, " Strip B: GPIO%d", current_config.led_pin_strip_b); + ESP_LOGI(TAG, " PWM Pin: GPIO%d", current_config.pwm_pin); + ESP_LOGI(TAG, " BLE Timeout: %d", current_config.ble_timeout); + + return ESP_OK; +} + +static esp_err_t save_config_to_nvs(void) +{ + nvs_handle_t nvs_handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs_handle); + if (err != ESP_OK) + { + return err; + } + + current_config.magic = CONFIG_MAGIC; + err = nvs_set_blob(nvs_handle, "config", ¤t_config, sizeof(controller_config_t)); + if (err == ESP_OK) + { + err = nvs_commit(nvs_handle); + } + + nvs_close(nvs_handle); + + if (err == ESP_OK) + { + ESP_LOGI(TAG, "Config saved to NVS"); + } + else + { + ESP_LOGE(TAG, "Failed to save config: %s", esp_err_to_name(err)); + } + + return err; +} + +esp_err_t control_reset_config(void) +{ + current_config.led_pin_strip_a = -1; + current_config.led_pin_strip_b = -1; + current_config.pwm_pin = -1; + current_config.ble_timeout = BLE_TIMEOUT_NEVER; + current_config.magic = CONFIG_MAGIC; + + return save_config_to_nvs(); +} + +const controller_config_t *control_get_config(void) +{ + return ¤t_config; +} + +esp_err_t control_update_config(const controller_config_t *config) +{ + if (!config) + { + return ESP_ERR_INVALID_ARG; + } + + // Reinitialize if pins changed + bool pins_changed = (current_config.led_pin_strip_a != config->led_pin_strip_a) || + (current_config.led_pin_strip_b != config->led_pin_strip_b) || + (current_config.pwm_pin != config->pwm_pin); + + memcpy(¤t_config, config, sizeof(controller_config_t)); + esp_err_t err = save_config_to_nvs(); + + if (err == ESP_OK && pins_changed) + { + ESP_LOGI(TAG, "Restarting to apply new pin configuration..."); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + } + + return err; +} + +// BLE timeout handling +static void ble_timeout_callback(void *arg) +{ + if (!ble_connected) + { + ESP_LOGI(TAG, "BLE timeout reached, disabling BLE"); + control_disable_ble(); + } +} + +static void start_ble_timeout(void) +{ + if (current_config.ble_timeout == BLE_TIMEOUT_NEVER) + { + return; + } + + if (ble_timeout_timer == NULL) + { + esp_timer_create_args_t timer_args = { + .callback = ble_timeout_callback, + .name = "ble_timeout"}; + esp_timer_create(&timer_args, &ble_timeout_timer); + } + + esp_timer_stop(ble_timeout_timer); + esp_timer_start_once(ble_timeout_timer, (uint64_t)current_config.ble_timeout * 1000000ULL); + ESP_LOGI(TAG, "BLE timeout started: %d seconds", current_config.ble_timeout); +} + +void control_disable_ble(void) +{ + if (!ble_enabled) + return; + + ble_enabled = false; + + if (ble_timeout_timer) + { + esp_timer_stop(ble_timeout_timer); + } + + // Stop BLE advertising + esp_ble_gap_stop_advertising(); + + ESP_LOGI(TAG, "BLE disabled"); +} + +bool control_is_ble_enabled(void) +{ + return ble_enabled; +} + +// Animation mode change callback +static void on_mode_change(uint8_t new_mode) +{ + current_animation_mode = new_mode; + animation_set_mode((animation_mode_t)new_mode); +} + +void control_set_animation_mode(uint8_t mode) +{ + if (mode >= ANIM_MODE_COUNT) + { + mode = 0; + } + on_mode_change(mode); +} + +uint8_t control_get_animation_mode(void) +{ + return current_animation_mode; +} + +void control_emulate_pwm_pulse(void) +{ + rcsignal_trigger_mode_change(); +} + +// Embedded web files (will be linked) +extern const uint8_t index_html_start[] asm("_binary_index_html_start"); +extern const uint8_t index_html_end[] asm("_binary_index_html_end"); +extern const uint8_t app_js_start[] asm("_binary_app_js_start"); +extern const uint8_t app_js_end[] asm("_binary_app_js_end"); +extern const uint8_t style_css_start[] asm("_binary_style_css_start"); +extern const uint8_t style_css_end[] asm("_binary_style_css_end"); +extern const uint8_t favicon_ico_start[] asm("_binary_favicon_ico_start"); +extern const uint8_t favicon_ico_end[] asm("_binary_favicon_ico_end"); + +// BLE GAP event handler +static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) +{ + ESP_LOGI(TAG, "gap_event_handler() event: %i\n", event); + switch (event) + { + case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: + adv_config_done &= (~ADV_CONFIG_FLAG); + if (adv_config_done == 0) + { + esp_ble_gap_start_advertising(&adv_params); + } + break; + + case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: + adv_config_done &= (~SCAN_RSP_CONFIG_FLAG); + if (adv_config_done == 0) + { + esp_ble_gap_start_advertising(&adv_params); + } + break; + + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + if (param->adv_start_cmpl.status == ESP_BT_STATUS_SUCCESS) + { + ESP_LOGI(TAG, "BLE advertising started"); + } + break; + + default: + break; + } +} + +// BLE GATTS event handler +static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) +{ + switch (event) + { + case ESP_GATTS_REG_EVT: + ESP_LOGI(TAG, "GATTS register, status %d, app_id %d", param->reg.status, param->reg.app_id); + gatts_if_global = gatts_if; + + // Set device name + esp_ble_gap_set_device_name(DEVICE_NAME); + + // Config advertising data + esp_ble_adv_data_t adv_data = { + .set_scan_rsp = false, + .include_name = true, + .include_txpower = true, + .min_interval = 0x0006, + .max_interval = 0x0010, + .appearance = 0x00, + .manufacturer_len = 0, + .p_manufacturer_data = NULL, + .service_data_len = 0, + .p_service_data = NULL, + .service_uuid_len = sizeof(uint16_t), + .p_service_uuid = (uint8_t[]){GATTS_SERVICE_UUID & 0xFF, (GATTS_SERVICE_UUID >> 8) & 0xFF}, + .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT), + }; + esp_ble_gap_config_adv_data(&adv_data); + adv_config_done |= ADV_CONFIG_FLAG; + + // Create service + esp_gatt_srvc_id_t service_id = { + .is_primary = true, + .id.inst_id = 0, + .id.uuid.len = ESP_UUID_LEN_16, + .id.uuid.uuid.uuid16 = GATTS_SERVICE_UUID, + }; + esp_ble_gatts_create_service(gatts_if, &service_id, GATTS_NUM_HANDLE_TEST); + break; + + case ESP_GATTS_CREATE_EVT: + ESP_LOGI(TAG, "CREATE_SERVICE_EVT, status %d, service_handle %d", param->create.status, param->create.service_handle); + service_handle = param->create.service_handle; + + esp_ble_gatts_start_service(service_handle); + + // Add characteristics + esp_bt_uuid_t char_uuid; + char_uuid.len = ESP_UUID_LEN_16; + + // Config characteristic + char_uuid.uuid.uuid16 = GATTS_CHAR_UUID_CONFIG; + esp_ble_gatts_add_char(service_handle, &char_uuid, + ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, + ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE, + NULL, NULL); + + // Mode characteristic + char_uuid.uuid.uuid16 = GATTS_CHAR_UUID_MODE; + esp_ble_gatts_add_char(service_handle, &char_uuid, + ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, + ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_NOTIFY, + NULL, NULL); + + // PWM emulation characteristic + char_uuid.uuid.uuid16 = GATTS_CHAR_UUID_PWM; + esp_ble_gatts_add_char(service_handle, &char_uuid, + ESP_GATT_PERM_WRITE, + ESP_GATT_CHAR_PROP_BIT_WRITE, + NULL, NULL); + + // OTA characteristic + char_uuid.uuid.uuid16 = GATTS_CHAR_UUID_OTA; + esp_ble_gatts_add_char(service_handle, &char_uuid, + ESP_GATT_PERM_WRITE, + ESP_GATT_CHAR_PROP_BIT_WRITE, + NULL, NULL); + break; + + case ESP_GATTS_ADD_CHAR_EVT: + ESP_LOGI(TAG, "ADD_CHAR_EVT, status %d, char_handle %d", param->add_char.status, param->add_char.attr_handle); + + // Store handles + if (param->add_char.char_uuid.uuid.uuid16 == GATTS_CHAR_UUID_CONFIG) + { + char_handles.config_handle = param->add_char.attr_handle; + } + else if (param->add_char.char_uuid.uuid.uuid16 == GATTS_CHAR_UUID_MODE) + { + char_handles.mode_handle = param->add_char.attr_handle; + } + else if (param->add_char.char_uuid.uuid.uuid16 == GATTS_CHAR_UUID_PWM) + { + char_handles.pwm_handle = param->add_char.attr_handle; + } + else if (param->add_char.char_uuid.uuid.uuid16 == GATTS_CHAR_UUID_OTA) + { + char_handles.ota_handle = param->add_char.attr_handle; + } + break; + + case ESP_GATTS_CONNECT_EVT: + ESP_LOGI(TAG, "BLE device connected"); + conn_id_global = param->connect.conn_id; + ble_connected = true; + + // Stop timeout timer when connected + if (ble_timeout_timer) + { + esp_timer_stop(ble_timeout_timer); + } + break; + + case ESP_GATTS_DISCONNECT_EVT: + ESP_LOGI(TAG, "BLE device disconnected"); + ble_connected = false; + + // Restart advertising and timeout + if (ble_enabled) + { + esp_ble_gap_start_advertising(&adv_params); + start_ble_timeout(); + } + break; + + case ESP_GATTS_READ_EVT: + ESP_LOGI(TAG, "GATTS_READ_EVT, handle %d", param->read.handle); + + esp_gatt_rsp_t rsp; + memset(&rsp, 0, sizeof(esp_gatt_rsp_t)); + rsp.attr_value.handle = param->read.handle; + + if (param->read.handle == char_handles.config_handle) + { + rsp.attr_value.len = sizeof(controller_config_t); + memcpy(rsp.attr_value.value, ¤t_config, sizeof(controller_config_t)); + } + else if (param->read.handle == char_handles.mode_handle) + { + rsp.attr_value.len = 1; + rsp.attr_value.value[0] = current_animation_mode; + } + + esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, + ESP_GATT_OK, &rsp); + break; + + case ESP_GATTS_WRITE_EVT: + ESP_LOGI(TAG, "GATTS_WRITE_EVT, handle %d, len %d", param->write.handle, param->write.len); + + if (param->write.handle == char_handles.config_handle) + { + // Update configuration + if (param->write.len == sizeof(controller_config_t)) + { + controller_config_t new_config; + memcpy(&new_config, param->write.value, sizeof(controller_config_t)); + control_update_config(&new_config); + } + } + else if (param->write.handle == char_handles.mode_handle) + { + // Set animation mode + if (param->write.len == 1) + { + control_set_animation_mode(param->write.value[0]); + } + } + else if (param->write.handle == char_handles.pwm_handle) + { + // Emulate PWM pulse + control_emulate_pwm_pulse(); + } + else if (param->write.handle == char_handles.ota_handle) + { + // Handle OTA data + if (ota_bytes_written == 0) + { + // First packet - start OTA + ESP_LOGI(TAG, "Starting OTA update..."); + update_partition = esp_ota_get_next_update_partition(NULL); + if (update_partition == NULL) + { + ESP_LOGE(TAG, "No OTA partition found"); + break; + } + + esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "OTA begin failed: %s", esp_err_to_name(err)); + break; + } + } + + // Write OTA data + esp_err_t err = esp_ota_write(update_handle, param->write.value, param->write.len); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "OTA write failed: %s", esp_err_to_name(err)); + esp_ota_abort(update_handle); + ota_bytes_written = 0; + break; + } + + ota_bytes_written += param->write.len; + ESP_LOGI(TAG, "OTA progress: %d bytes", ota_bytes_written); + + // Check if this is the last packet (indicated by packet size < MTU) + if (param->write.len < 512) + { + ESP_LOGI(TAG, "OTA complete, total bytes: %d", ota_bytes_written); + + err = esp_ota_end(update_handle); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "OTA end failed: %s", esp_err_to_name(err)); + break; + } + + err = esp_ota_set_boot_partition(update_partition); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "OTA set boot partition failed: %s", esp_err_to_name(err)); + break; + } + + // Reset configuration + control_reset_config(); + + ESP_LOGI(TAG, "OTA successful, restarting..."); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + } + } + + if (!param->write.is_prep) + { + esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, + ESP_GATT_OK, NULL); + } + break; + + default: + break; + } +} + +// Initialize BLE +static esp_err_t init_ble(void) +{ + if (!ble_enabled) + { + ESP_LOGI(TAG, "BLE disabled by configuration"); + return ESP_OK; + } + + esp_err_t ret; + + // Initialize BT controller + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + ret = esp_bt_controller_init(&bt_cfg); + if (ret) + { + ESP_LOGE(TAG, "BT controller init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (ret) + { + ESP_LOGE(TAG, "BT controller enable failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = esp_bluedroid_init(); + if (ret) + { + ESP_LOGE(TAG, "Bluedroid init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = esp_bluedroid_enable(); + if (ret) + { + ESP_LOGE(TAG, "Bluedroid enable failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Register callbacks + esp_ble_gatts_register_callback(gatts_event_handler); + esp_ble_gap_register_callback(gap_event_handler); + esp_ble_gatts_app_register(0); + + // Set MTU + esp_ble_gatt_set_local_mtu(517); + + // Start timeout timer + start_ble_timeout(); + + esp_ble_gatts_app_register(0); + + vTaskDelay(pdMS_TO_TICKS(100)); + esp_ble_gap_start_advertising(&adv_params); + + ESP_LOGI(TAG, "BLE initialized"); + + return ESP_OK; +} + +// Main initialization +esp_err_t control_init(void) +{ + esp_err_t ret; + + ESP_LOGI(TAG, "Initializing LED Controller..."); + + // Initialize NVS + ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) + { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + // Load configuration + load_config_from_nvs(); + + // Initialize LED strips + ret = led_init(current_config.led_pin_strip_a, current_config.led_pin_strip_b, + DEFAULT_NUM_LEDS_A, DEFAULT_NUM_LEDS_B); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "LED init failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Initialize animation system + ret = animation_init(); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "Animation init failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Initialize RC signal + ret = rcsignal_init(current_config.pwm_pin); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "RC signal init failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Register mode change callback + rcsignal_register_callback(on_mode_change); + + // Initialize BLE + ret = init_ble(); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "BLE init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ESP_LOGI(TAG, "Control system initialized successfully"); + + return ESP_OK; +} diff --git a/main/control.h b/main/control.h new file mode 100644 index 0000000..cc15cdf --- /dev/null +++ b/main/control.h @@ -0,0 +1,87 @@ +/** + * @file control.h + * @brief Control module for LED controller - handles initialization of LEDs, PWM, and Bluetooth + */ + +#ifndef CONTROL_H +#define CONTROL_H + +#include "esp_err.h" +#include +#include + +/** + * @brief BLE auto-off timeout options + */ +typedef enum { + BLE_TIMEOUT_NEVER = 0, + BLE_TIMEOUT_1MIN = 60, + BLE_TIMEOUT_5MIN = 300 +} ble_timeout_t; + +/** + * @brief Configuration structure stored in NVS + */ +typedef struct { + int8_t led_pin_strip_a; // GPIO pin for LED strip A (-1 = not configured) + int8_t led_pin_strip_b; // GPIO pin for LED strip B (-1 = not configured) + int8_t pwm_pin; // GPIO pin for PWM input (-1 = not configured) + ble_timeout_t ble_timeout; // BLE auto-off timeout + uint32_t magic; // Magic number to validate config (0xDEADBEEF) +} controller_config_t; + +/** + * @brief Initialize the control system + * Loads configuration from NVS and initializes subsystems + * @return ESP_OK on success + */ +esp_err_t control_init(void); + +/** + * @brief Get current configuration + * @return Pointer to current configuration (read-only) + */ +const controller_config_t* control_get_config(void); + +/** + * @brief Update configuration and save to NVS + * @param config New configuration + * @return ESP_OK on success + */ +esp_err_t control_update_config(const controller_config_t* config); + +/** + * @brief Reset configuration to defaults + * @return ESP_OK on success + */ +esp_err_t control_reset_config(void); + +/** + * @brief Get BLE enabled status + * @return true if BLE is enabled + */ +bool control_is_ble_enabled(void); + +/** + * @brief Manually disable BLE + */ +void control_disable_ble(void); + +/** + * @brief Emulate PWM pulse (for web button) + */ +void control_emulate_pwm_pulse(void); + +/** + * @brief Set animation mode manually + * @param mode Animation mode (0-13) + */ +void control_set_animation_mode(uint8_t mode); + +/** + * @brief Get current animation mode + * @return Current mode (0-13) + */ +uint8_t control_get_animation_mode(void); + +#endif // CONTROL_H diff --git a/main/led.c b/main/led.c new file mode 100644 index 0000000..d3bd2e4 --- /dev/null +++ b/main/led.c @@ -0,0 +1,476 @@ +/** + * @file led.c + * @brief WS2812B LED strip control implementation using RMT + */ + +#include "led.h" +#include "driver/rmt_tx.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include +#include + +static const char *TAG = "LED"; + +// WS2812B timing (in nanoseconds) +#define WS2812_T0H_NS 350 +#define WS2812_T0L_NS 900 +#define WS2812_T1H_NS 900 +#define WS2812_T1L_NS 350 +#define WS2812_RESET_US 280 + +// LED strip data structures +typedef struct +{ + rmt_channel_handle_t rmt_channel; + rmt_encoder_handle_t encoder; + rgb_t *buffer; + uint16_t num_leds; + int8_t gpio_pin; + bool initialized; +} led_strip_t; + +static led_strip_t strip_a = {0}; +static led_strip_t strip_b = {0}; +static SemaphoreHandle_t led_mutex = NULL; + +// RMT encoder for WS2812B +typedef struct +{ + rmt_encoder_t base; + rmt_encoder_t *bytes_encoder; + rmt_encoder_t *copy_encoder; + int state; + rmt_symbol_word_t reset_code; +} rmt_led_strip_encoder_t; + +static size_t rmt_encode_led_strip(rmt_encoder_t *encoder, rmt_channel_handle_t channel, + const void *primary_data, size_t data_size, + rmt_encode_state_t *ret_state) +{ + rmt_led_strip_encoder_t *led_encoder = __containerof(encoder, rmt_led_strip_encoder_t, base); + rmt_encode_state_t session_state = RMT_ENCODING_RESET; + rmt_encode_state_t state = RMT_ENCODING_RESET; + size_t encoded_symbols = 0; + + switch (led_encoder->state) + { + case 0: // send RGB data + encoded_symbols += led_encoder->bytes_encoder->encode(led_encoder->bytes_encoder, channel, + primary_data, data_size, &session_state); + if (session_state & RMT_ENCODING_COMPLETE) + { + led_encoder->state = 1; // switch to next state when current encoding session finished + } + if (session_state & RMT_ENCODING_MEM_FULL) + { + state |= RMT_ENCODING_MEM_FULL; + goto out; + } + // fall-through + case 1: // send reset code + encoded_symbols += led_encoder->copy_encoder->encode(led_encoder->copy_encoder, channel, + &led_encoder->reset_code, + sizeof(led_encoder->reset_code), &session_state); + if (session_state & RMT_ENCODING_COMPLETE) + { + led_encoder->state = RMT_ENCODING_RESET; + state |= RMT_ENCODING_COMPLETE; + } + if (session_state & RMT_ENCODING_MEM_FULL) + { + state |= RMT_ENCODING_MEM_FULL; + goto out; + } + } +out: + *ret_state = state; + return encoded_symbols; +} + +static esp_err_t rmt_del_led_strip_encoder(rmt_encoder_t *encoder) +{ + rmt_led_strip_encoder_t *led_encoder = __containerof(encoder, rmt_led_strip_encoder_t, base); + rmt_del_encoder(led_encoder->bytes_encoder); + rmt_del_encoder(led_encoder->copy_encoder); + free(led_encoder); + return ESP_OK; +} + +static esp_err_t rmt_led_strip_encoder_reset(rmt_encoder_t *encoder) +{ + rmt_led_strip_encoder_t *led_encoder = __containerof(encoder, rmt_led_strip_encoder_t, base); + rmt_encoder_reset(led_encoder->bytes_encoder); + rmt_encoder_reset(led_encoder->copy_encoder); + led_encoder->state = RMT_ENCODING_RESET; + return ESP_OK; +} + +static esp_err_t rmt_new_led_strip_encoder(rmt_encoder_handle_t *ret_encoder) +{ + esp_err_t ret = ESP_OK; + rmt_led_strip_encoder_t *led_encoder = calloc(1, sizeof(rmt_led_strip_encoder_t)); + if (!led_encoder) + { + return ESP_ERR_NO_MEM; + } + + led_encoder->base.encode = rmt_encode_led_strip; + led_encoder->base.del = rmt_del_led_strip_encoder; + led_encoder->base.reset = rmt_led_strip_encoder_reset; + + // WS2812 timing + rmt_bytes_encoder_config_t bytes_encoder_config = { + .bit0 = { + .level0 = 1, + .duration0 = WS2812_T0H_NS * 80 / 1000, // 80MHz clock + .level1 = 0, + .duration1 = WS2812_T0L_NS * 80 / 1000, + }, + .bit1 = { + .level0 = 1, + .duration0 = WS2812_T1H_NS * 80 / 1000, + .level1 = 0, + .duration1 = WS2812_T1L_NS * 80 / 1000, + }, + .flags.msb_first = 1, + }; + ret = rmt_new_bytes_encoder(&bytes_encoder_config, &led_encoder->bytes_encoder); + if (ret != ESP_OK) + { + goto err; + } + + rmt_copy_encoder_config_t copy_encoder_config = {}; + ret = rmt_new_copy_encoder(©_encoder_config, &led_encoder->copy_encoder); + if (ret != ESP_OK) + { + goto err; + } + + uint32_t reset_ticks = WS2812_RESET_US * 80; // 80MHz + led_encoder->reset_code = (rmt_symbol_word_t){ + .level0 = 0, + .duration0 = reset_ticks & 0x7FFF, + .level1 = 0, + .duration1 = reset_ticks & 0x7FFF, + }; + + *ret_encoder = &led_encoder->base; + return ESP_OK; + +err: + if (led_encoder->bytes_encoder) + { + rmt_del_encoder(led_encoder->bytes_encoder); + } + if (led_encoder->copy_encoder) + { + rmt_del_encoder(led_encoder->copy_encoder); + } + free(led_encoder); + return ret; +} + +static esp_err_t init_strip(led_strip_t *strip, int8_t pin, uint16_t num_leds) +{ + if (pin < 0 || num_leds == 0) + { + return ESP_OK; // Skip if not configured + } + + strip->buffer = calloc(num_leds, sizeof(rgb_t)); + if (!strip->buffer) + { + return ESP_ERR_NO_MEM; + } + + strip->num_leds = num_leds; + strip->gpio_pin = pin; + + rmt_tx_channel_config_t tx_chan_config = { + .clk_src = RMT_CLK_SRC_DEFAULT, + .gpio_num = pin, + .mem_block_symbols = 64, + .resolution_hz = 80000000, // 80MHz + .trans_queue_depth = 4, + }; + + ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_chan_config, &strip->rmt_channel)); + ESP_ERROR_CHECK(rmt_new_led_strip_encoder(&strip->encoder)); + ESP_ERROR_CHECK(rmt_enable(strip->rmt_channel)); + + strip->initialized = true; + ESP_LOGI(TAG, "Initialized strip on GPIO%d with %d LEDs", pin, num_leds); + + return ESP_OK; +} + +esp_err_t led_init(int8_t pin_a, int8_t pin_b, uint16_t num_leds_a, uint16_t num_leds_b) +{ + if (led_mutex == NULL) + { + led_mutex = xSemaphoreCreateMutex(); + if (!led_mutex) + { + return ESP_ERR_NO_MEM; + } + } + + esp_err_t ret = ESP_OK; + + if (pin_a >= 0) + { + ret = init_strip(&strip_a, pin_a, num_leds_a); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "Failed to init strip A: %s", esp_err_to_name(ret)); + return ret; + } + } + + if (pin_b >= 0) + { + ret = init_strip(&strip_b, pin_b, num_leds_b); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "Failed to init strip B: %s", esp_err_to_name(ret)); + return ret; + } + } + + return ESP_OK; +} + +void led_deinit(void) +{ + if (strip_a.initialized) + { + rmt_disable(strip_a.rmt_channel); + rmt_del_channel(strip_a.rmt_channel); + free(strip_a.buffer); + strip_a.initialized = false; + } + + if (strip_b.initialized) + { + rmt_disable(strip_b.rmt_channel); + rmt_del_channel(strip_b.rmt_channel); + free(strip_b.buffer); + strip_b.initialized = false; + } +} + +void led_set_pixel_a(uint16_t index, rgb_t color) +{ + if (!strip_a.initialized || index >= strip_a.num_leds) + return; + xSemaphoreTake(led_mutex, portMAX_DELAY); + strip_a.buffer[index] = color; + xSemaphoreGive(led_mutex); +} + +void led_set_pixel_b(uint16_t index, rgb_t color) +{ + if (!strip_b.initialized || index >= strip_b.num_leds) + return; + xSemaphoreTake(led_mutex, portMAX_DELAY); + strip_b.buffer[index] = color; + xSemaphoreGive(led_mutex); +} + +void led_fill_a(rgb_t color) +{ + if (!strip_a.initialized) + return; + xSemaphoreTake(led_mutex, portMAX_DELAY); + for (uint16_t i = 0; i < strip_a.num_leds; i++) + { + strip_a.buffer[i] = color; + } + xSemaphoreGive(led_mutex); +} + +void led_fill_b(rgb_t color) +{ + if (!strip_b.initialized) + return; + xSemaphoreTake(led_mutex, portMAX_DELAY); + for (uint16_t i = 0; i < strip_b.num_leds; i++) + { + strip_b.buffer[i] = color; + } + xSemaphoreGive(led_mutex); +} + +void led_clear_all(void) +{ + rgb_t black = {0, 0, 0}; + led_fill_a(black); + led_fill_b(black); +} + +static void show_strip(led_strip_t *strip) +{ + if (!strip->initialized) + return; + + // Convert RGB to GRB for WS2812B + uint8_t *grb_data = malloc(strip->num_leds * 3); + if (!grb_data) + return; + + for (uint16_t i = 0; i < strip->num_leds; i++) + { + grb_data[i * 3 + 0] = strip->buffer[i].g; + grb_data[i * 3 + 1] = strip->buffer[i].r; + grb_data[i * 3 + 2] = strip->buffer[i].b; + } + + rmt_transmit_config_t tx_config = { + .loop_count = 0, + }; + + rmt_transmit(strip->rmt_channel, strip->encoder, grb_data, strip->num_leds * 3, &tx_config); + free(grb_data); +} + +void led_show(void) +{ + xSemaphoreTake(led_mutex, portMAX_DELAY); + show_strip(&strip_a); + show_strip(&strip_b); + xSemaphoreGive(led_mutex); +} + +void led_fade_to_black(uint8_t amount) +{ + xSemaphoreTake(led_mutex, portMAX_DELAY); + + if (strip_a.initialized) + { + for (uint16_t i = 0; i < strip_a.num_leds; i++) + { + strip_a.buffer[i].r = (strip_a.buffer[i].r * (255 - amount)) / 255; + strip_a.buffer[i].g = (strip_a.buffer[i].g * (255 - amount)) / 255; + strip_a.buffer[i].b = (strip_a.buffer[i].b * (255 - amount)) / 255; + } + } + + if (strip_b.initialized) + { + for (uint16_t i = 0; i < strip_b.num_leds; i++) + { + strip_b.buffer[i].r = (strip_b.buffer[i].r * (255 - amount)) / 255; + strip_b.buffer[i].g = (strip_b.buffer[i].g * (255 - amount)) / 255; + strip_b.buffer[i].b = (strip_b.buffer[i].b * (255 - amount)) / 255; + } + } + + xSemaphoreGive(led_mutex); +} + +rgb_t led_hsv_to_rgb(hsv_t hsv) +{ + rgb_t rgb = {0}; + uint8_t region, remainder, p, q, t; + + if (hsv.s == 0) + { + rgb.r = hsv.v; + rgb.g = hsv.v; + rgb.b = hsv.v; + return rgb; + } + + region = hsv.h / 43; + remainder = (hsv.h - (region * 43)) * 6; + + p = (hsv.v * (255 - hsv.s)) >> 8; + q = (hsv.v * (255 - ((hsv.s * remainder) >> 8))) >> 8; + t = (hsv.v * (255 - ((hsv.s * (255 - remainder)) >> 8))) >> 8; + + switch (region) + { + case 0: + rgb.r = hsv.v; + rgb.g = t; + rgb.b = p; + break; + case 1: + rgb.r = q; + rgb.g = hsv.v; + rgb.b = p; + break; + case 2: + rgb.r = p; + rgb.g = hsv.v; + rgb.b = t; + break; + case 3: + rgb.r = p; + rgb.g = q; + rgb.b = hsv.v; + break; + case 4: + rgb.r = t; + rgb.g = p; + rgb.b = hsv.v; + break; + default: + rgb.r = hsv.v; + rgb.g = p; + rgb.b = q; + break; + } + + return rgb; +} + +uint16_t led_get_num_leds_a(void) { return strip_a.num_leds; } +uint16_t led_get_num_leds_b(void) { return strip_b.num_leds; } + +rgb_t led_get_pixel_a(uint16_t index) +{ + rgb_t color = {0}; + if (!strip_a.initialized || index >= strip_a.num_leds) + return color; + xSemaphoreTake(led_mutex, portMAX_DELAY); + color = strip_a.buffer[index]; + xSemaphoreGive(led_mutex); + return color; +} + +rgb_t led_get_pixel_b(uint16_t index) +{ + rgb_t color = {0}; + if (!strip_b.initialized || index >= strip_b.num_leds) + return color; + xSemaphoreTake(led_mutex, portMAX_DELAY); + color = strip_b.buffer[index]; + xSemaphoreGive(led_mutex); + return color; +} + +void led_add_pixel_a(uint16_t index, rgb_t color) +{ + if (!strip_a.initialized || index >= strip_a.num_leds) + return; + xSemaphoreTake(led_mutex, portMAX_DELAY); + strip_a.buffer[index].r = (strip_a.buffer[index].r + color.r > 255) ? 255 : strip_a.buffer[index].r + color.r; + strip_a.buffer[index].g = (strip_a.buffer[index].g + color.g > 255) ? 255 : strip_a.buffer[index].g + color.g; + strip_a.buffer[index].b = (strip_a.buffer[index].b + color.b > 255) ? 255 : strip_a.buffer[index].b + color.b; + xSemaphoreGive(led_mutex); +} + +void led_add_pixel_b(uint16_t index, rgb_t color) +{ + if (!strip_b.initialized || index >= strip_b.num_leds) + return; + xSemaphoreTake(led_mutex, portMAX_DELAY); + strip_b.buffer[index].r = (strip_b.buffer[index].r + color.r > 255) ? 255 : strip_b.buffer[index].r + color.r; + strip_b.buffer[index].g = (strip_b.buffer[index].g + color.g > 255) ? 255 : strip_b.buffer[index].g + color.g; + strip_b.buffer[index].b = (strip_b.buffer[index].b + color.b > 255) ? 255 : strip_b.buffer[index].b + color.b; + xSemaphoreGive(led_mutex); +} diff --git a/main/led.h b/main/led.h new file mode 100644 index 0000000..edec26a --- /dev/null +++ b/main/led.h @@ -0,0 +1,136 @@ +/** + * @file led.h + * @brief LED strip control module for WS2812B + */ + +#ifndef LED_H +#define LED_H + +#include +#include "esp_err.h" + +#define LED_STRIP_MAX_LEDS 100 // Maximum LEDs per strip + +/** + * @brief RGB color structure + */ +typedef struct { + uint8_t r; + uint8_t g; + uint8_t b; +} rgb_t; + +/** + * @brief HSV color structure + */ +typedef struct { + uint8_t h; // Hue: 0-255 + uint8_t s; // Saturation: 0-255 + uint8_t v; // Value/Brightness: 0-255 +} hsv_t; + +/** + * @brief Initialize LED strips + * @param pin_a GPIO pin for strip A (-1 to disable) + * @param pin_b GPIO pin for strip B (-1 to disable) + * @param num_leds_a Number of LEDs in strip A + * @param num_leds_b Number of LEDs in strip B + * @return ESP_OK on success + */ +esp_err_t led_init(int8_t pin_a, int8_t pin_b, uint16_t num_leds_a, uint16_t num_leds_b); + +/** + * @brief Deinitialize LED strips + */ +void led_deinit(void); + +/** + * @brief Set pixel color on strip A + * @param index LED index + * @param color RGB color + */ +void led_set_pixel_a(uint16_t index, rgb_t color); + +/** + * @brief Set pixel color on strip B + * @param index LED index + * @param color RGB color + */ +void led_set_pixel_b(uint16_t index, rgb_t color); + +/** + * @brief Set all pixels on strip A to same color + * @param color RGB color + */ +void led_fill_a(rgb_t color); + +/** + * @brief Set all pixels on strip B to same color + * @param color RGB color + */ +void led_fill_b(rgb_t color); + +/** + * @brief Clear all pixels on both strips (set to black) + */ +void led_clear_all(void); + +/** + * @brief Refresh/update LED strips to show changes + */ +void led_show(void); + +/** + * @brief Fade all pixels towards black + * @param amount Fade amount (0-255) + */ +void led_fade_to_black(uint8_t amount); + +/** + * @brief Convert HSV to RGB + * @param hsv HSV color + * @return RGB color + */ +rgb_t led_hsv_to_rgb(hsv_t hsv); + +/** + * @brief Get number of LEDs in strip A + * @return Number of LEDs + */ +uint16_t led_get_num_leds_a(void); + +/** + * @brief Get number of LEDs in strip B + * @return Number of LEDs + */ +uint16_t led_get_num_leds_b(void); + +/** + * @brief Get current color of pixel on strip A + * @param index LED index + * @return RGB color + */ +rgb_t led_get_pixel_a(uint16_t index); + +/** + * @brief Get current color of pixel on strip B + * @param index LED index + * @return RGB color + */ +rgb_t led_get_pixel_b(uint16_t index); + +/** + * @brief Add color to existing pixel (blending) + * @param index LED index on strip A + * @param color RGB color to add + */ +void led_add_pixel_a(uint16_t index, rgb_t color); + +/** + * @brief Add color to existing pixel (blending) + * @param index LED index on strip B + * @param color RGB color to add + */ +void led_add_pixel_b(uint16_t index, rgb_t color); + +#endif // LED_H diff --git a/main/main.c b/main/main.c new file mode 100644 index 0000000..0229b06 --- /dev/null +++ b/main/main.c @@ -0,0 +1,94 @@ +/** + * @file main.c + * @brief Main application entry point for LED Controller + */ + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "esp_system.h" + +#include "control.h" +#include "animation.h" +#include "led.h" + +static const char *TAG = "MAIN"; + +#define ANIMATION_UPDATE_RATE_MS 16 // ~60 FPS + +/** + * @brief Animation update task + * Runs continuously to update LED animations + */ +static void animation_task(void *pvParameters) +{ + ESP_LOGI(TAG, "Animation task started"); + + TickType_t last_wake_time = xTaskGetTickCount(); + const TickType_t update_interval = pdMS_TO_TICKS(ANIMATION_UPDATE_RATE_MS); + + while (1) + { + animation_update(); + vTaskDelayUntil(&last_wake_time, update_interval); + } +} + +/** + * @brief Main application entry point + */ +void app_main(void) +{ + ESP_LOGI(TAG, "=============================================="); + ESP_LOGI(TAG, " ESP32 LED Controller for Model Aircraft"); + ESP_LOGI(TAG, "=============================================="); + + // Initialize control system (LEDs, PWM, BLE) + esp_err_t ret = control_init(); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "Failed to initialize control system: %s", esp_err_to_name(ret)); + ESP_LOGE(TAG, "System halted. Please reset the device."); + while (1) + { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + + // Create animation update task + BaseType_t task_ret = xTaskCreate( + animation_task, + "animation", + 4096, + NULL, + 5, + NULL); + + if (task_ret != pdPASS) + { + ESP_LOGE(TAG, "Failed to create animation task"); + ESP_LOGE(TAG, "System halted. Please reset the device."); + while (1) + { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + + ESP_LOGI(TAG, "System initialized successfully"); + ESP_LOGI(TAG, "BLE Device Name: LED-Controller"); + ESP_LOGI(TAG, "Connect via Web-BLE to configure"); + + // Main loop - just monitor system status + while (1) + { + vTaskDelay(pdMS_TO_TICKS(5000)); + + // Periodic status logging + //const controller_config_t *config = control_get_config(); + ESP_LOGI(TAG, "Status - Mode: %d, BLE: %s, PWM Active: %s", + control_get_animation_mode(), + control_is_ble_enabled() ? "ON" : "OFF", + "N/A"); // Could add rcsignal_is_active() here + } +} diff --git a/main/rcsignal.c b/main/rcsignal.c new file mode 100644 index 0000000..13ad38f --- /dev/null +++ b/main/rcsignal.c @@ -0,0 +1,202 @@ +/** + * @file rcsignal.c + * @brief RC PWM signal reading implementation using edge capture + */ + +#include "rcsignal.h" +#include "driver/gpio.h" +#include "esp_timer.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include + +static const char *TAG = "RCSIGNAL"; + +#define MAX_MODES 14 +#define PULSE_THRESHOLD_US 1500 +#define SIGNAL_TIMEOUT_MS 100 + +static struct +{ + int8_t gpio_pin; + volatile uint32_t pulse_width_us; + volatile int64_t last_edge_time; + volatile int64_t pulse_start_time; + volatile bool last_level; + volatile bool signal_active; + volatile bool pull_detected; + uint8_t current_mode; + rcsignal_mode_change_callback_t callback; + bool initialized; + TaskHandle_t monitor_task; +} rcsignal = { + .gpio_pin = -1, + .pulse_width_us = 0, + .last_edge_time = 0, + .pulse_start_time = 0, + .last_level = false, + .signal_active = false, + .pull_detected = false, + .current_mode = 0, + .callback = NULL, + .initialized = false, + .monitor_task = NULL, +}; + +static void IRAM_ATTR gpio_isr_handler(void *arg) +{ + int64_t now = esp_timer_get_time(); + bool level = gpio_get_level(rcsignal.gpio_pin); + + if (level && !rcsignal.last_level) + { + // Rising edge - start of pulse + rcsignal.pulse_start_time = now; + } + else if (!level && rcsignal.last_level) + { + // Falling edge - end of pulse + if (rcsignal.pulse_start_time > 0) + { + rcsignal.pulse_width_us = (uint32_t)(now - rcsignal.pulse_start_time); + rcsignal.last_edge_time = now; + rcsignal.signal_active = true; + } + } + + rcsignal.last_level = level; +} + +static void monitor_task(void *arg) +{ + uint32_t last_pulse_width = 0; + + while (1) + { + vTaskDelay(pdMS_TO_TICKS(10)); + + // Check for signal timeout + int64_t now = esp_timer_get_time(); + if (rcsignal.signal_active && (now - rcsignal.last_edge_time) > (SIGNAL_TIMEOUT_MS * 1000)) + { + rcsignal.signal_active = false; + rcsignal.pulse_width_us = 0; + } + + // Detect mode change (rising edge on PWM signal > 1500us) + if (rcsignal.pulse_width_us != last_pulse_width) + { + last_pulse_width = rcsignal.pulse_width_us; + + if (rcsignal.pulse_width_us < PULSE_THRESHOLD_US) + { + rcsignal.pull_detected = true; + } + + if (rcsignal.pulse_width_us > PULSE_THRESHOLD_US && rcsignal.pull_detected) + { + // Mode change detected + rcsignal.pull_detected = false; + rcsignal.current_mode = (rcsignal.current_mode + 1) % MAX_MODES; + + ESP_LOGI(TAG, "Mode changed to: %d (pulse: %lu us)", + rcsignal.current_mode, rcsignal.pulse_width_us); + + if (rcsignal.callback) + { + rcsignal.callback(rcsignal.current_mode); + } + } + } + } +} + +esp_err_t rcsignal_init(int8_t pin) +{ + if (pin < 0) + { + ESP_LOGI(TAG, "RC signal disabled (no pin configured)"); + return ESP_OK; + } + + rcsignal.gpio_pin = pin; + + // Configure GPIO + gpio_config_t io_conf = { + .pin_bit_mask = (1ULL << pin), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_ANYEDGE, + }; + ESP_ERROR_CHECK(gpio_config(&io_conf)); + + // Install ISR service + ESP_ERROR_CHECK(gpio_install_isr_service(0)); + ESP_ERROR_CHECK(gpio_isr_handler_add(pin, gpio_isr_handler, NULL)); + + // Create monitor task + BaseType_t ret = xTaskCreate(monitor_task, "rcsignal_monitor", 2048, NULL, 5, &rcsignal.monitor_task); + if (ret != pdPASS) + { + gpio_isr_handler_remove(pin); + gpio_uninstall_isr_service(); + return ESP_FAIL; + } + + rcsignal.initialized = true; + ESP_LOGI(TAG, "RC signal initialized on GPIO%d", pin); + + return ESP_OK; +} + +void rcsignal_deinit(void) +{ + if (!rcsignal.initialized) + return; + + if (rcsignal.monitor_task) + { + vTaskDelete(rcsignal.monitor_task); + rcsignal.monitor_task = NULL; + } + + if (rcsignal.gpio_pin >= 0) + { + gpio_isr_handler_remove(rcsignal.gpio_pin); + } + + rcsignal.initialized = false; +} + +void rcsignal_register_callback(rcsignal_mode_change_callback_t callback) +{ + rcsignal.callback = callback; +} + +uint32_t rcsignal_get_pulse_width(void) +{ + return rcsignal.pulse_width_us; +} + +bool rcsignal_is_active(void) +{ + return rcsignal.signal_active; +} + +void rcsignal_trigger_mode_change(void) +{ + rcsignal.current_mode = (rcsignal.current_mode + 1) % MAX_MODES; + ESP_LOGI(TAG, "Manual mode change to: %d", rcsignal.current_mode); + + if (rcsignal.callback) + { + rcsignal.callback(rcsignal.current_mode); + } +} + +uint8_t rcsignal_get_current_mode(void) +{ + return rcsignal.current_mode; +} diff --git a/main/rcsignal.h b/main/rcsignal.h new file mode 100644 index 0000000..05d34ac --- /dev/null +++ b/main/rcsignal.h @@ -0,0 +1,60 @@ +/** + * @file rcsignal.h + * @brief RC PWM signal reading and parsing module + */ + +#ifndef RCSIGNAL_H +#define RCSIGNAL_H + +#include +#include +#include "esp_err.h" + +/** + * @brief Callback function type for mode changes + * @param new_mode New animation mode (0-13) + */ +typedef void (*rcsignal_mode_change_callback_t)(uint8_t new_mode); + +/** + * @brief Initialize RC signal reading + * @param pin GPIO pin for PWM input (-1 to disable) + * @return ESP_OK on success + */ +esp_err_t rcsignal_init(int8_t pin); + +/** + * @brief Deinitialize RC signal reading + */ +void rcsignal_deinit(void); + +/** + * @brief Register callback for mode changes + * @param callback Callback function + */ +void rcsignal_register_callback(rcsignal_mode_change_callback_t callback); + +/** + * @brief Get current PWM pulse width in microseconds + * @return Pulse width in µs (0 if no signal) + */ +uint32_t rcsignal_get_pulse_width(void); + +/** + * @brief Check if PWM signal is active + * @return true if signal detected in last 100ms + */ +bool rcsignal_is_active(void); + +/** + * @brief Manually trigger mode change (for emulation) + */ +void rcsignal_trigger_mode_change(void); + +/** + * @brief Get current mode + * @return Current animation mode (0-13) + */ +uint8_t rcsignal_get_current_mode(void); + +#endif // RCSIGNAL_H diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..794474a --- /dev/null +++ b/partitions.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +# Optimized for 2MB flash - 2 large OTA slots (no factory partition) +nvs, data, nvs, 0x9000, 0x4000, +phy_init, data, phy, 0xd000, 0x1000, +ota_0, app, ota_0, 0x10000, 0xF0000, +ota_1, app, ota_1, 0x100000, 0xF0000, \ No newline at end of file diff --git a/tools/dev_https_server.py b/tools/dev_https_server.py new file mode 100644 index 0000000..2ac67bc --- /dev/null +++ b/tools/dev_https_server.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Ad-hoc Flask HTTPS Server for LED Controller Webapp +Serves the webapp over HTTPS (required for Web Bluetooth API) +""" + +import os +import sys +from flask import Flask, send_from_directory, send_file +from pathlib import Path + +# Configuration +HOST = '0.0.0.0' # Listen on all interfaces +PORT = 5000 # HTTPS port +DEBUG = True + +# Get webapp directory (one level up from tools/) +SCRIPT_DIR = Path(__file__).parent +WEBAPP_DIR = SCRIPT_DIR.parent / 'webapp' + +# Verify webapp directory exists +if not WEBAPP_DIR.exists(): + print(f"❌ ERROR: Webapp directory not found at {WEBAPP_DIR}") + print(f" Please run this script from the base repository directory") + sys.exit(1) + +# Create Flask app +app = Flask(__name__) + +@app.route('/') +def index(): + """Serve index.html""" + return send_file(WEBAPP_DIR / 'index.html') + +@app.route('/app/') +def serve_app(filename): + """Serve files from app/ directory""" + return send_from_directory(WEBAPP_DIR / 'app', filename) + +@app.route('/css/') +def serve_css(filename): + """Serve files from css/ directory""" + return send_from_directory(WEBAPP_DIR / 'css', filename) + +@app.route('/data/') +def serve_data(filename): + """Serve files from data/ directory""" + return send_from_directory(WEBAPP_DIR / 'data', filename) + +@app.route('/favicon.ico') +def favicon(): + """Serve favicon""" + return send_file(WEBAPP_DIR / 'data' / 'favicon.ico') + +def print_banner(): + """Print startup banner with instructions""" + print("=" * 70) + print(" 🚀 LED Controller HTTPS Development Server") + print("=" * 70) + print() + print("📱 Web Bluetooth requires HTTPS!") + print(" This server provides a self-signed certificate for development.") + print() + print("🌐 Access the webapp at:") + print(f" https://localhost:{PORT}") + print(f" https://127.0.0.1:{PORT}") + print(f" https://:{PORT}") + print() + print("⚠️ Browser Security Warning:") + print(" You'll see a 'Not Secure' warning - this is normal!") + print(" Click 'Advanced' → 'Proceed to localhost' (or similar)") + print() + print("🔧 To stop the server: Press Ctrl+C") + print("=" * 70) + print() + +def get_local_ip(): + """Get local IP address for convenience""" + import socket + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except: + return "unknown" + +if __name__ == '__main__': + print_banner() + + # Print local IP for convenience + local_ip = get_local_ip() + if local_ip != "unknown": + print(f"💡 Your local IP: {local_ip}") + print(f" Access from phone: https://{local_ip}:{PORT}") + print() + + print("🔄 Starting HTTPS server...") + print() + + try: + # Run with ad-hoc SSL context (self-signed certificate) + # Flask will automatically generate a certificate + app.run( + host=HOST, + port=PORT, + debug=DEBUG, + ssl_context='adhoc' # Auto-generate self-signed cert + ) + except OSError as e: + if "Address already in use" in str(e): + print(f"❌ ERROR: Port {PORT} is already in use!") + print(f" Try a different port or stop the other service.") + sys.exit(1) + else: + raise + except KeyboardInterrupt: + print("\n\n👋 Server stopped by user") + sys.exit(0) diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 0000000..8779ea6 --- /dev/null +++ b/tools/requirements.txt @@ -0,0 +1,5 @@ +# Flask HTTPS Development Server Requirements +# Install with: pip install -r requirements.txt + +Flask>=2.3.0 +pyOpenSSL>=23.0.0 # Required for adhoc SSL certificate generation diff --git a/webapp/app/app.js b/webapp/app/app.js new file mode 100644 index 0000000..e2fb368 --- /dev/null +++ b/webapp/app/app.js @@ -0,0 +1,322 @@ +/** + * LED Controller Web-BLE Application + * Communicates with ESP32 LED Controller via Bluetooth Low Energy + */ + +// BLE Service and Characteristic UUIDs +const SERVICE_UUID = 0x00FF; +const CHAR_CONFIG_UUID = 0xFF01; +const CHAR_MODE_UUID = 0xFF02; +const CHAR_PWM_UUID = 0xFF03; +const CHAR_OTA_UUID = 0xFF04; + +// Animation mode names +const MODE_NAMES = [ + 'Black', 'Red', 'Blue', 'Green', 'White', + 'Rainbow', 'Rainbow Glitter', 'Confetti', 'Sinelon', + 'BPM Pulse', 'Navigation', 'Chase (Red)', 'Chase (RGB)', 'Random' +]; + +class LEDController { + constructor() { + this.device = null; + this.server = null; + this.service = null; + this.characteristics = { + config: null, + mode: null, + pwm: null, + ota: null + }; + this.connected = false; + + this.initUI(); + } + + initUI() { + // Connect button + document.getElementById('connect-btn').addEventListener('click', () => this.connect()); + + // Save configuration button + document.getElementById('save-config-btn').addEventListener('click', () => this.saveConfig()); + + // PWM emulation button + document.getElementById('pwm-emulate-btn').addEventListener('click', () => this.emulatePWM()); + + // Mode selection buttons + document.querySelectorAll('.btn-mode').forEach(btn => { + btn.addEventListener('click', (e) => { + const mode = parseInt(e.target.dataset.mode); + this.setMode(mode); + }); + }); + + // Firmware file selection + document.getElementById('firmware-file').addEventListener('change', (e) => { + const uploadBtn = document.getElementById('ota-upload-btn'); + uploadBtn.disabled = !e.target.files.length; + }); + + // OTA upload button + document.getElementById('ota-upload-btn').addEventListener('click', () => this.uploadFirmware()); + } + + async connect() { + try { + console.log('Requesting Bluetooth Device...'); + + this.device = await navigator.bluetooth.requestDevice({ + filters: [{ name: 'LED-Controller' }], + optionalServices: [SERVICE_UUID] + }); + + console.log('Connecting to GATT Server...'); + this.server = await this.device.gatt.connect(); + + console.log('Getting Service...'); + this.service = await this.server.getPrimaryService(SERVICE_UUID); + + console.log('Getting Characteristics...'); + this.characteristics.config = await this.service.getCharacteristic(CHAR_CONFIG_UUID); + this.characteristics.mode = await this.service.getCharacteristic(CHAR_MODE_UUID); + this.characteristics.pwm = await this.service.getCharacteristic(CHAR_PWM_UUID); + this.characteristics.ota = await this.service.getCharacteristic(CHAR_OTA_UUID); + + this.connected = true; + this.updateConnectionStatus(true); + + // Load current configuration + await this.loadConfig(); + await this.loadCurrentMode(); + + // Show control sections + document.getElementById('config-section').classList.remove('hidden'); + document.getElementById('control-section').classList.remove('hidden'); + document.getElementById('ota-section').classList.remove('hidden'); + + console.log('Connected successfully!'); + + // Handle disconnection + this.device.addEventListener('gattserverdisconnected', () => { + this.onDisconnected(); + }); + + } catch (error) { + console.error('Connection failed:', error); + alert('Failed to connect: ' + error.message); + } + } + + async loadConfig() { + try { + const value = await this.characteristics.config.readValue(); + const config = this.parseConfig(value); + + document.getElementById('led-pin-a').value = config.ledPinA; + document.getElementById('led-pin-b').value = config.ledPinB; + document.getElementById('pwm-pin').value = config.pwmPin; + document.getElementById('ble-timeout').value = config.bleTimeout; + + console.log('Configuration loaded:', config); + } catch (error) { + console.error('Failed to load config:', error); + } + } + + async loadCurrentMode() { + try { + const value = await this.characteristics.mode.readValue(); + const mode = value.getUint8(0); + this.updateCurrentMode(mode); + } catch (error) { + console.error('Failed to load current mode:', error); + } + } + + parseConfig(dataView) { + return { + ledPinA: dataView.getInt8(0), + ledPinB: dataView.getInt8(1), + pwmPin: dataView.getInt8(2), + bleTimeout: dataView.getUint32(3, true), + magic: dataView.getUint32(7, true) + }; + } + + createConfigBuffer(config) { + const buffer = new ArrayBuffer(11); + const view = new DataView(buffer); + + view.setInt8(0, config.ledPinA); + view.setInt8(1, config.ledPinB); + view.setInt8(2, config.pwmPin); + view.setUint32(3, config.bleTimeout, true); + view.setUint32(7, 0xDEADBEEF, true); // Magic number + + return buffer; + } + + async saveConfig() { + try { + const config = { + ledPinA: parseInt(document.getElementById('led-pin-a').value), + ledPinB: parseInt(document.getElementById('led-pin-b').value), + pwmPin: parseInt(document.getElementById('pwm-pin').value), + bleTimeout: parseInt(document.getElementById('ble-timeout').value) + }; + + const buffer = this.createConfigBuffer(config); + await this.characteristics.config.writeValue(buffer); + + alert('Configuration saved! Device will restart if pins changed.'); + console.log('Configuration saved:', config); + } catch (error) { + console.error('Failed to save config:', error); + alert('Failed to save configuration: ' + error.message); + } + } + + async setMode(mode) { + try { + const buffer = new Uint8Array([mode]); + await this.characteristics.mode.writeValue(buffer); + this.updateCurrentMode(mode); + console.log('Mode set to:', MODE_NAMES[mode]); + } catch (error) { + console.error('Failed to set mode:', error); + } + } + + async emulatePWM() { + try { + const buffer = new Uint8Array([1]); + await this.characteristics.pwm.writeValue(buffer); + console.log('PWM pulse emulated'); + + // Read back the new mode + setTimeout(() => this.loadCurrentMode(), 100); + } catch (error) { + console.error('Failed to emulate PWM:', error); + } + } + + async uploadFirmware() { + const fileInput = document.getElementById('firmware-file'); + const file = fileInput.files[0]; + + if (!file) { + alert('Please select a firmware file'); + return; + } + + if (!confirm('This will update the firmware and reset all settings. Continue?')) { + return; + } + + try { + const arrayBuffer = await file.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + + const progressBar = document.getElementById('ota-progress-bar'); + const progressText = document.getElementById('ota-progress-text'); + const progressContainer = document.getElementById('ota-progress'); + + progressContainer.classList.remove('hidden'); + + const chunkSize = 512; + const totalChunks = Math.ceil(data.length / chunkSize); + + console.log(`Starting OTA upload: ${data.length} bytes, ${totalChunks} chunks`); + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, data.length); + const chunk = data.slice(start, end); + + await this.characteristics.ota.writeValue(chunk); + + const progress = Math.round((i + 1) / totalChunks * 100); + progressBar.style.width = progress + '%'; + progressText.textContent = progress + '%'; + + console.log(`Upload progress: ${progress}%`); + + // Small delay to prevent overwhelming the device + await new Promise(resolve => setTimeout(resolve, 20)); + } + + alert('Firmware uploaded successfully! Device will restart.'); + console.log('OTA upload complete'); + + } catch (error) { + console.error('OTA upload failed:', error); + alert('Firmware upload failed: ' + error.message); + } + } + + updateCurrentMode(mode) { + const modeName = MODE_NAMES[mode] || 'Unknown'; + document.getElementById('current-mode').textContent = modeName; + } + + updateConnectionStatus(connected) { + const indicator = document.getElementById('status-indicator'); + const statusText = document.getElementById('connection-status'); + const connectBtn = document.getElementById('connect-btn'); + + if (connected) { + indicator.classList.remove('disconnected'); + indicator.classList.add('connected'); + statusText.textContent = 'Connected'; + connectBtn.textContent = 'Disconnect'; + connectBtn.onclick = () => this.disconnect(); + } else { + indicator.classList.remove('connected'); + indicator.classList.add('disconnected'); + statusText.textContent = 'Disconnected'; + connectBtn.textContent = 'Connect via BLE'; + connectBtn.onclick = () => this.connect(); + } + } + + disconnect() { + if (this.device && this.device.gatt.connected) { + this.device.gatt.disconnect(); + } + this.onDisconnected(); + } + + onDisconnected() { + console.log('Disconnected from device'); + this.connected = false; + this.device = null; + this.server = null; + this.service = null; + this.characteristics = { + config: null, + mode: null, + pwm: null, + ota: null + }; + + this.updateConnectionStatus(false); + + // Hide control sections + document.getElementById('config-section').classList.add('hidden'); + document.getElementById('control-section').classList.add('hidden'); + document.getElementById('ota-section').classList.add('hidden'); + } +} + +// Initialize app when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + // Check if Web Bluetooth is supported + if (!navigator.bluetooth) { + alert('Web Bluetooth is not supported in this browser. Please use Chrome, Edge, or Opera.'); + document.getElementById('connect-btn').disabled = true; + return; + } + + console.log('LED Controller Web-BLE Interface loaded'); + new LEDController(); +}); diff --git a/webapp/css/style.css b/webapp/css/style.css new file mode 100644 index 0000000..e3142e7 --- /dev/null +++ b/webapp/css/style.css @@ -0,0 +1,313 @@ +/* LED Controller Web-BLE Interface Styles */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary-color: #2196F3; + --success-color: #4CAF50; + --warning-color: #FF9800; + --danger-color: #F44336; + --dark-bg: #1a1a2e; + --card-bg: #16213e; + --text-primary: #ffffff; + --text-secondary: #b0b0b0; + --border-color: #0f4c75; + --hover-bg: #1e3a5f; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, var(--dark-bg) 0%, #0f3460 100%); + color: var(--text-primary); + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 800px; + margin: 0 auto; +} + +header { + text-align: center; + margin-bottom: 30px; +} + +header h1 { + font-size: 2.5em; + margin-bottom: 10px; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); +} + +.subtitle { + color: var(--text-secondary); + font-size: 1.1em; +} + +.card { + background: var(--card-bg); + border-radius: 12px; + padding: 25px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color); +} + +.card h2 { + margin-bottom: 20px; + color: var(--primary-color); + font-size: 1.5em; +} + +.hidden { + display: none !important; +} + +/* Connection Status */ +.connection-status { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; + font-size: 1.1em; +} + +.status-indicator { + width: 16px; + height: 16px; + border-radius: 50%; + animation: pulse 2s infinite; +} + +.status-indicator.disconnected { + background: var(--danger-color); +} + +.status-indicator.connected { + background: var(--success-color); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Form Elements */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: var(--text-primary); +} + +.form-group input[type="number"], +.form-group select { + width: 100%; + padding: 12px; + background: var(--dark-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 1em; +} + +.form-group input[type="number"]:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary-color); +} + +.form-group small { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 0.9em; +} + +.form-group input[type="file"] { + width: 100%; + padding: 10px; + background: var(--dark-bg); + border: 2px dashed var(--border-color); + border-radius: 6px; + color: var(--text-primary); + cursor: pointer; +} + +/* Buttons */ +.btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-size: 1em; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + width: 100%; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-success { + background: var(--success-color); + color: white; +} + +.btn-warning { + background: var(--warning-color); + color: white; +} + +.btn-secondary { + background: var(--border-color); + color: white; +} + +.btn-mode { + background: var(--hover-bg); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-mode:hover { + background: var(--primary-color); +} + +/* Button Grid */ +.button-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +/* Current Mode Display */ +.current-mode { + background: var(--dark-bg); + padding: 15px; + border-radius: 6px; + margin-bottom: 20px; + text-align: center; + font-size: 1.1em; + border: 1px solid var(--border-color); +} + +.current-mode span { + color: var(--primary-color); + font-weight: bold; +} + +/* Progress Bar */ +.progress-container { + width: 100%; + height: 40px; + background: var(--dark-bg); + border-radius: 20px; + overflow: hidden; + margin: 20px 0; + position: relative; + border: 1px solid var(--border-color); +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--success-color)); + transition: width 0.3s ease; + width: 0%; +} + +.progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-weight: bold; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +/* Warning Box */ +.warning { + background: rgba(255, 152, 0, 0.1); + border: 1px solid var(--warning-color); + border-radius: 6px; + padding: 15px; + margin-bottom: 20px; + color: var(--warning-color); + font-weight: 500; +} + +/* Divider */ +.divider { + height: 1px; + background: var(--border-color); + margin: 20px 0; +} + +/* Footer */ +footer { + text-align: center; + margin-top: 40px; + padding: 20px; + color: var(--text-secondary); +} + +footer small { + font-size: 0.9em; +} + +/* Responsive Design */ +@media (max-width: 600px) { + header h1 { + font-size: 2em; + } + + .button-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + } + + .btn { + font-size: 0.9em; + padding: 10px 16px; + } +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card { + animation: fadeIn 0.5s ease; +} diff --git a/webapp/data/favicon.ico b/webapp/data/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..1edec70 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,115 @@ + + + + + + LED Controller Config + + + + +
+
+

✈️ LED Controller

+

Model Aircraft LED Configuration via Web-BLE

+
+ +
+

🔗 Connection

+
+ + Disconnected +
+ +
+ + + + + + + +
+

ESP32 LED Controller © 2026

+

Supports ESP32 DevKitC & ESP32-C3 MINI

+
+
+ + + +