From 6d528ec74c6b8d2c2f3320b1261c13bdf703de2b Mon Sep 17 00:00:00 2001 From: Daan Gerits Date: Wed, 18 Mar 2026 19:31:49 +0100 Subject: [PATCH] Add NeoPixel driver, CLI commands, and fix device slot mapping bug NeoPixel support: adds WS2812/NeoPixel addressable LED strip driver with JSON command handling (fill, set pixel, brightness, clear) via NATS HAL, web UI controls, persistence, and remote config. CLI gets a new `neopixel` command with fill, set, brightness, clear, get, and batch subcommands. Fixes a bug where neopixel strips in device array slot >= 4 silently failed to initialize because the driver used the g_devices[] index directly as the internal strip index (max 4). Adds an indirection layer (s_dev_to_strip[]) that maps device slots to strip slots dynamically. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 71 ++++++++++ cli/ionode | 3 + cli/lib/cmd_neopixel.sh | 275 ++++++++++++++++++++++++++++++++++++++ include/devices.h | 19 ++- include/neopixel_driver.h | 50 +++++++ platformio.ini | 1 + src/devices.cpp | 35 ++++- src/nats_config.cpp | 4 +- src/nats_hal.cpp | 20 ++- src/neopixel_driver.cpp | 234 ++++++++++++++++++++++++++++++++ src/web_config.cpp | 42 +++++- 11 files changed, 746 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md create mode 100644 cli/lib/cmd_neopixel.sh create mode 100644 include/neopixel_driver.h create mode 100644 src/neopixel_driver.cpp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0627037 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IOnode is an ESP32 firmware that turns any ESP32 into a NATS-addressable hardware node. Every GPIO pin, ADC channel, sensor, and actuator becomes reachable over the network via NATS request/reply. Written in C++ using the Arduino framework and built with PlatformIO. + +Supported chips: ESP32-C6 (default), ESP32-S3, ESP32-C3, ESP32 (classic). + +## Build Commands + +```bash +pio run # build default target (esp32-c6) +pio run -e esp32-s3 # build for specific chip +pio run -e esp32-c3 +pio run -e esp32 +pio run -t upload # build + flash firmware +pio run -t uploadfs # upload LittleFS filesystem (config + devices.json) +pio device monitor # serial monitor at 115200 baud +``` + +There are no unit tests or linting tools configured for this project. + +## Architecture + +### Firmware (C++, Arduino framework) + +The firmware is a single-binary PlatformIO project. Key modules: + +- **`src/main.cpp`** — Entry point. WiFi/NATS connection, config loading from LittleFS, main loop (sensor polling, heartbeat, serial commands, NATS reconnect). Global config variables (`cfg_wifi_ssid`, `cfg_device_name`, etc.) live here. +- **`src/devices.cpp` / `include/devices.h`** — Device registry. Manages up to 16 named sensors/actuators. Handles registration, persistence to `/devices.json` on LittleFS, sensor reading (with EMA smoothing and history), actuator control, and threshold event detection. The `DeviceKind` enum defines all supported sensor/actuator types. +- **`src/nats_hal.cpp` / `include/nats_hal.h`** — HAL router. Handles the `{name}.hal.>` wildcard NATS subscription and routes requests to GPIO, ADC, PWM, UART, I2C, system queries, and registered device operations. +- **`src/nats_config.cpp` / `include/nats_config.h`** — Remote configuration via `{name}.config.>` NATS subjects. Device add/remove, tag, heartbeat, event config, rename — all without reflash. +- **`src/i2c_devices.cpp` / `include/i2c_devices.h`** — I2C sensor drivers (BME280, BH1750, SHT31, ADS1115, generic). +- **`src/i2c_display.cpp`** — SSD1306/SH1106 OLED display driver with a template engine (token substitution from sensor values). +- **`src/dht_driver.cpp` / `include/dht_driver.h`** — DHT11/DHT22 single-wire temperature/humidity sensor driver. +- **`src/setup_portal.cpp`** — WiFi AP captive portal for initial device configuration. +- **`src/web_config.cpp`** — On-device HTTP web UI (port 80) for config, device management, GPIO access, and status. + +### NATS Client Library (`lib/nats/`) + +A custom lightweight NATS client library bundled in `lib/nats/`. Provides `nats_atoms.h` as the main API header. Subdirectories: `proto/` (protocol), `parse/` (message parsing), `json/` (JSON handling), `cpp/` (C++ wrapper), `transport/` (TCP transport). + +### Adding a New Sensor Type + +1. Add enum value in `include/devices.h` (`DeviceKind` enum, before the actuator entries) +2. Add read case in `deviceReadSensor()`, string mapping in `deviceKindName()`, and parse case in `kindFromString()` — all in `src/devices.cpp` + +The HAL router, persistence, discovery, web UI, and polling all work automatically from the registry. + +### CLI (`cli/ionode`) + +A bash script that wraps `nats-cli` and `jq` for fleet management. Not part of the firmware build. + +### Web Dashboard (`web/dashboard/`) + +A single-file HTML dashboard connecting to NATS via WebSocket. No build system. + +### Data Files (`data/`) + +Uploaded to LittleFS via `pio run -t uploadfs`. Contains `devices.json` (device registry) and `config.json.example`. + +## Key Conventions + +- Device names are short strings (max 24 chars), kinds are snake_case strings mapped to `DeviceKind` enum values +- Pin 255 (`PIN_NONE`) is the sentinel for virtual sensors (no GPIO pin) +- Max 16 devices per node (`MAX_DEVICES`) +- NATS subjects follow the pattern `{device_name}.hal.{resource}` for hardware access, `{device_name}.config.{action}` for configuration +- Chip-specific code uses `CONFIG_IDF_TARGET_*` preprocessor guards +- Custom partition table in `partitions.csv` (2MB flash layout) diff --git a/cli/ionode b/cli/ionode index a4056eb..a20eec1 100755 --- a/cli/ionode +++ b/cli/ionode @@ -23,6 +23,7 @@ source "${IONODE_CLI_DIR}/lib/cmd_hardware.sh" source "${IONODE_CLI_DIR}/lib/cmd_config.sh" source "${IONODE_CLI_DIR}/lib/cmd_events.sh" source "${IONODE_CLI_DIR}/lib/cmd_watch.sh" +source "${IONODE_CLI_DIR}/lib/cmd_neopixel.sh" # --- Help --- _show_help() { @@ -50,6 +51,7 @@ _show_help() { printf ' %spwm%s %s get|set [v]%s Raw PWM access\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" printf ' %suart%s %s read|write [text]%s UART serial\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" printf ' %si2c%s %s scan|detect|read|write%s I2C bus access\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" + printf ' %sneopixel%s %s %s NeoPixel LED strip\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" printf ' %sdevices%s %s%s List registered devices\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" printf '\n' @@ -172,6 +174,7 @@ case "$COMMAND" in pwm) cmd_pwm "$@" ;; uart) cmd_uart "$@" ;; i2c) cmd_i2c "$@" ;; + neopixel) cmd_neopixel "$@" ;; devices) cmd_devices "$@" ;; # Config diff --git a/cli/lib/cmd_neopixel.sh b/cli/lib/cmd_neopixel.sh new file mode 100644 index 0000000..4dc952e --- /dev/null +++ b/cli/lib/cmd_neopixel.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# IOnode CLI — NeoPixel LED strip commands + +# --- Color name → hex mapping --- +_neo_color() { + local input="${1:-}" + # Strip common prefixes + input="${input#\#}" + input="${input#0x}" + input="${input#0X}" + + # Named colors + case "${input,,}" in + red) echo "FF0000" ;; + green) echo "00FF00" ;; + blue) echo "0000FF" ;; + white) echo "FFFFFF" ;; + off) echo "000000" ;; + yellow) echo "FFFF00" ;; + cyan) echo "00FFFF" ;; + magenta) echo "FF00FF" ;; + orange) echo "FF8C00" ;; + purple) echo "8000FF" ;; + *) + # Validate hex (must be 6 hex chars) + if [[ "${input}" =~ ^[0-9a-fA-F]{6}$ ]]; then + echo "${input^^}" + else + err "invalid color: ${1} (use 6-digit hex or name: red, green, blue, white, off, yellow, cyan, magenta, orange, purple)" + return 1 + fi + ;; + esac +} + +# --- Color swatch (terminal true-color block) --- +_neo_swatch() { + local hex="$1" + local r=$((16#${hex:0:2})) + local g=$((16#${hex:2:2})) + local b=$((16#${hex:4:2})) + printf '%s██%s' "$(_c_bg_rgb "$r" "$g" "$b")" "$(_rst)" +} + +_neo_help() { + printf '\n' + printf ' %s%sNeoPixel Commands%s\n\n' "$(c_accent)" "$(c_bold)" "$(_rst)" + printf ' %susage: ionode neopixel [args...]%s\n\n' "$(c_dim)" "$(_rst)" + printf ' %s%sACTIONS%s\n' "$(c_dim)" "$(c_bold)" "$(_rst)" + printf ' %sfill%s %s%s Fill all pixels\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" + printf ' %sset%s %s %s Set one pixel\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" + printf ' %sbrightness%s %s<0-255>%s Set brightness\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" + printf ' %sclear%s All off\n' "$(c_accent)" "$(_rst)" + printf ' %sget%s Status\n' "$(c_accent)" "$(_rst)" + printf ' %sbatch%s %s[,,...]%s Set multiple pixels\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)" + printf '\n' + printf ' %s%sCOLORS%s\n' "$(c_dim)" "$(c_bold)" "$(_rst)" + printf ' %sHex:%s FF0000, #00FF00, 0x0000FF\n' "$(c_label)" "$(_rst)" + printf ' %sNamed:%s red, green, blue, white, off, yellow, cyan, magenta, orange, purple\n' "$(c_label)" "$(_rst)" + printf '\n' + printf ' %s%sEXAMPLES%s\n' "$(c_dim)" "$(c_bold)" "$(_rst)" + printf ' %sionode neopixel mynode led_strip fill red%s\n' "$(c_dim)" "$(_rst)" + printf ' %sionode neopixel mynode led_strip set 0 00FF00%s\n' "$(c_dim)" "$(_rst)" + printf ' %sionode neopixel mynode led_strip batch 0:FF0000,1:00FF00,2:0000FF%s\n' "$(c_dim)" "$(_rst)" + printf '\n' +} + +cmd_neopixel() { + if [[ -z "${1:-}" ]] || [[ -z "${2:-}" ]] || [[ -z "${3:-}" ]]; then + _neo_help + return 1 + fi + + local node="$1" + local strip="$2" + local action="$3" + shift 3 + + case "$action" in + fill) + if [[ -z "${1:-}" ]]; then + err "missing color" + printf ' %susage: ionode neopixel fill %s\n\n' "$(c_dim)" "$(_rst)" + return 1 + fi + local color + if ! color=$(_neo_color "$1"); then + return 1 + fi + + local result + if ! result=$(nats_req "${node}.hal.${strip}.set" "{\"fill\":\"${color}\"}" "3s") || [[ -z "$result" ]]; then + timeout_msg "$node" + return 1 + fi + + if [[ "$result" == "ok" ]]; then + printf ' %s%s%s %sfill%s %s %s#%s%s\n' \ + "$(c_actuator)" "$strip" "$(_rst)" \ + "$(c_ok)" "$(_rst)" \ + "$(_neo_swatch "$color")" \ + "$(c_dim)" "$color" "$(_rst)" + else + printf ' %s%s%s\n' "$(c_err)" "$result" "$(_rst)" + return 1 + fi + ;; + + set) + if [[ -z "${1:-}" ]] || [[ -z "${2:-}" ]]; then + err "missing arguments" + printf ' %susage: ionode neopixel set %s\n\n' "$(c_dim)" "$(_rst)" + return 1 + fi + local pixel="$1" + local color + if ! color=$(_neo_color "$2"); then + return 1 + fi + + local result + if ! result=$(nats_req "${node}.hal.${strip}.set" "{\"pixel\":${pixel},\"color\":\"${color}\"}" "3s") || [[ -z "$result" ]]; then + timeout_msg "$node" + return 1 + fi + + if [[ "$result" == "ok" ]]; then + printf ' %s%s%s %spx %s%s %s %s#%s%s\n' \ + "$(c_actuator)" "$strip" "$(_rst)" \ + "$(c_ok)" "$pixel" "$(_rst)" \ + "$(_neo_swatch "$color")" \ + "$(c_dim)" "$color" "$(_rst)" + else + printf ' %s%s%s\n' "$(c_err)" "$result" "$(_rst)" + return 1 + fi + ;; + + brightness) + if [[ -z "${1:-}" ]]; then + err "missing brightness value" + printf ' %susage: ionode neopixel brightness <0-255>%s\n\n' "$(c_dim)" "$(_rst)" + return 1 + fi + local bri="$1" + + local result + if ! result=$(nats_req "${node}.hal.${strip}.set" "{\"brightness\":${bri}}" "3s") || [[ -z "$result" ]]; then + timeout_msg "$node" + return 1 + fi + + if [[ "$result" == "ok" ]]; then + local pct=$(( bri * 100 / 255 )) + printf ' %s%s%s %s← brightness %s%s/255 %s(%d%%)%s\n' \ + "$(c_actuator)" "$strip" "$(_rst)" \ + "$(c_ok)" "$bri" "$(_rst)" \ + "$(c_dim)" "$pct" "$(_rst)" + else + printf ' %s%s%s\n' "$(c_err)" "$result" "$(_rst)" + return 1 + fi + ;; + + clear) + local result + if ! result=$(nats_req "${node}.hal.${strip}.set" '{"clear":true}' "3s") || [[ -z "$result" ]]; then + timeout_msg "$node" + return 1 + fi + + if [[ "$result" == "ok" ]]; then + printf ' %s%s%s %scleared%s\n' \ + "$(c_actuator)" "$strip" "$(_rst)" \ + "$(c_ok)" "$(_rst)" + else + printf ' %s%s%s\n' "$(c_err)" "$result" "$(_rst)" + return 1 + fi + ;; + + get) + local result + if ! result=$(nats_req "${node}.hal.${strip}.get" "" "3s") || [[ -z "$result" ]]; then + timeout_msg "$node" + return 1 + fi + + if has_jq && [[ "$result" == "{"* ]]; then + local bri count val + bri=$(json_num "$result" "brightness") + count=$(json_num "$result" "count") + val=$(json_num "$result" "value") + local pct=$(( bri * 100 / 255 )) + + header "NeoPixel · ${node}/${strip}" + printf '\n' + kv "Brightness:" "${bri}/255 (${pct}%)" + kv "Pixels:" "$count" + kv "Value:" "$val" + printf '\n' + else + echo "$result" + fi + ;; + + batch) + if [[ -z "${1:-}" ]]; then + err "missing pixel:color pairs" + printf ' %susage: ionode neopixel batch [,,...]%s\n' "$(c_dim)" "$(_rst)" + printf ' %sexample: ionode neopixel mynode strip batch 0:FF0000,1:00FF00,2:0000FF%s\n\n' "$(c_dim)" "$(_rst)" + return 1 + fi + + local pairs="$1" + local failed=0 + local sent=0 + + IFS=',' read -ra entries <<< "$pairs" + for entry in "${entries[@]}"; do + local pixel="${entry%%:*}" + local raw_color="${entry#*:}" + + if [[ "$entry" != *":"* ]] || [[ -z "$pixel" ]] || [[ -z "$raw_color" ]]; then + err "invalid format: ${entry} (expected pixel:color)" + failed=$((failed + 1)) + continue + fi + + local color + if ! color=$(_neo_color "$raw_color"); then + failed=$((failed + 1)) + continue + fi + + local result + if ! result=$(nats_req "${node}.hal.${strip}.set" "{\"pixel\":${pixel},\"color\":\"${color}\"}" "3s") || [[ -z "$result" ]]; then + err "px ${pixel}: no response" + failed=$((failed + 1)) + continue + fi + + if [[ "$result" == "ok" ]]; then + printf ' %s%s%s %spx %s%s %s %s#%s%s\n' \ + "$(c_actuator)" "$strip" "$(_rst)" \ + "$(c_ok)" "$pixel" "$(_rst)" \ + "$(_neo_swatch "$color")" \ + "$(c_dim)" "$color" "$(_rst)" + sent=$((sent + 1)) + else + printf ' %spx %s: %s%s\n' "$(c_err)" "$pixel" "$result" "$(_rst)" + failed=$((failed + 1)) + fi + done + + printf '\n %s%d set%s' "$(c_dim)" "$sent" "$(_rst)" + if [[ "$failed" -gt 0 ]]; then + printf ', %s%d failed%s' "$(c_err)" "$failed" "$(_rst)" + fi + printf '\n\n' + + [[ "$failed" -eq 0 ]] || return 1 + ;; + + help|--help|-h) + _neo_help + ;; + + *) + err "unknown neopixel action: ${action}" + _neo_help + return 1 + ;; + esac +} diff --git a/include/devices.h b/include/devices.h index 0a3506f..0a9be08 100644 --- a/include/devices.h +++ b/include/devices.h @@ -46,6 +46,7 @@ enum DeviceKind { DEV_ACTUATOR_RGB_LED, /* rgbLedWrite packed 0xRRGGBB */ DEV_ACTUATOR_SSD1306, /* SSD1306 OLED display (text via template) */ DEV_ACTUATOR_SH1106, /* SH1106 OLED display (text via template, 2-col offset) */ + DEV_ACTUATOR_NEOPIXEL, /* WS2812/NeoPixel addressable LED strip */ }; struct Device { @@ -60,8 +61,11 @@ struct Device { float nats_value; char nats_msg[64]; uint16_t nats_sid; - /* Serial text baud rate (only meaningful for DEV_SENSOR_SERIAL_TEXT) */ + /* Serial text baud rate (only meaningful for DEV_SENSOR_SERIAL_TEXT) + For DEV_ACTUATOR_NEOPIXEL: pixel count */ uint32_t baud; + /* NeoPixel color order (only meaningful for DEV_ACTUATOR_NEOPIXEL) */ + uint8_t neo_color_order; /* I2C fields */ uint8_t i2c_addr; /* I2C slave address (0 = not I2C) */ char disp_template[128]; /* display template for SSD1306 actuators */ @@ -91,6 +95,16 @@ struct Device { #define EV_DIR_ABOVE 1 #define EV_DIR_BELOW 2 +/* NeoPixel color order constants */ +#define NEO_ORDER_GRB 0 +#define NEO_ORDER_RGB 1 +#define NEO_ORDER_RBG 2 +#define NEO_ORDER_BRG 3 +#define NEO_ORDER_BGR 4 +#define NEO_ORDER_GBR 5 +#define NEO_ORDER_RGBW 6 +#define NEO_ORDER_GRBW 7 + /* Initialize device registry - loads from /devices.json, auto-registers chip_temp */ void devicesInit(); @@ -117,7 +131,8 @@ bool deviceRegister(const char *name, DeviceKind kind, uint8_t pin, uint8_t i2c_addr = 0, const char *disp_template = nullptr, uint8_t i2c_reg_len = 1, - float i2c_scale = 1.0f); + float i2c_scale = 1.0f, + uint8_t neo_color_order = NEO_ORDER_GRB); /* Remove a device by name. Returns true if found and removed. */ bool deviceRemove(const char *name); diff --git a/include/neopixel_driver.h b/include/neopixel_driver.h new file mode 100644 index 0000000..af59a6f --- /dev/null +++ b/include/neopixel_driver.h @@ -0,0 +1,50 @@ +/** + * @file neopixel_driver.h + * @brief WS2812/NeoPixel addressable LED strip driver + * + * Manages up to NEOPIXEL_MAX_STRIPS strips via Adafruit NeoPixel library. + * Strips are indexed by device slot in g_devices[]. + */ + +#ifndef NEOPIXEL_DRIVER_H +#define NEOPIXEL_DRIVER_H + +#include + +#define NEOPIXEL_MAX_STRIPS 4 + +/* All functions take devSlot = index into g_devices[] array (0..MAX_DEVICES-1). + The driver internally maps device slots to strip slots (0..MAX_STRIPS-1). */ + +/* Initialize a NeoPixel strip for a given device slot. + colorOrder: NEO_ORDER_GRB (0) through NEO_ORDER_GRBW (7) */ +void neopixelInit(int devSlot, uint8_t pin, uint16_t numPixels, uint8_t colorOrder = 0); + +/* Deinitialize and free a NeoPixel strip */ +void neopixelDeinit(int devSlot); + +/* Fill all pixels with a color (0xRRGGBB) */ +void neopixelFill(int devSlot, uint32_t color); + +/* Set a single pixel to a color */ +void neopixelSetPixel(int devSlot, uint16_t pixel, uint32_t color); + +/* Set brightness (0-255) */ +void neopixelSetBrightness(int devSlot, uint8_t brightness); + +/* Turn off all pixels */ +void neopixelClear(int devSlot); + +/* Get current brightness */ +uint8_t neopixelGetBrightness(int devSlot); + +/* Get pixel count */ +uint16_t neopixelGetCount(int devSlot); + +/* Get last fill color */ +uint32_t neopixelGetColor(int devSlot); + +/* Handle a JSON command payload, returns true if handled */ +bool neopixelHandleJson(int devSlot, const char *json, char *reply, int reply_len); + +#endif /* NEOPIXEL_DRIVER_H */ diff --git a/platformio.ini b/platformio.ini index 37631cc..142da3d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -18,6 +18,7 @@ board_build.partitions = partitions.csv board_upload.flash_size = 2MB monitor_speed = 115200 monitor_filters = colorize, time +lib_deps = adafruit/Adafruit NeoPixel@^1.12.0 [env:esp32-c6] board = esp32-c6-devkitc-1 diff --git a/src/devices.cpp b/src/devices.cpp index dc46d98..f30f84c 100644 --- a/src/devices.cpp +++ b/src/devices.cpp @@ -7,6 +7,7 @@ #include "nats_hal.h" #include "i2c_devices.h" #include "dht_driver.h" +#include "neopixel_driver.h" #include #include #if !defined(CONFIG_IDF_TARGET_ESP32) @@ -73,6 +74,7 @@ const char *deviceKindName(DeviceKind kind) { case DEV_ACTUATOR_RGB_LED: return "rgb_led"; case DEV_ACTUATOR_SSD1306: return "ssd1306"; case DEV_ACTUATOR_SH1106: return "sh1106"; + case DEV_ACTUATOR_NEOPIXEL: return "neopixel"; default: return "unknown"; } } @@ -103,6 +105,7 @@ static DeviceKind kindFromString(const char *s) { if (strcmp(s, "rgb_led") == 0) return DEV_ACTUATOR_RGB_LED; if (strcmp(s, "ssd1306") == 0) return DEV_ACTUATOR_SSD1306; if (strcmp(s, "sh1106") == 0) return DEV_ACTUATOR_SH1106; + if (strcmp(s, "neopixel") == 0) return DEV_ACTUATOR_NEOPIXEL; return DEV_SENSOR_DIGITAL; } @@ -130,7 +133,8 @@ bool deviceRegister(const char *name, DeviceKind kind, uint8_t pin, const char *unit, bool inverted, const char *nats_subject, uint32_t baud, uint8_t i2c_addr, const char *disp_template, - uint8_t i2c_reg_len, float i2c_scale) { + uint8_t i2c_reg_len, float i2c_scale, + uint8_t neo_color_order) { /* Reject HAL reserved names */ if (halIsReservedName(name)) return false; @@ -164,6 +168,7 @@ bool deviceRegister(const char *name, DeviceKind kind, uint8_t pin, g_devices[i].nats_msg[0] = '\0'; g_devices[i].nats_sid = 0; g_devices[i].baud = baud; + g_devices[i].neo_color_order = neo_color_order; /* I2C fields */ g_devices[i].i2c_addr = i2c_addr; @@ -199,8 +204,14 @@ bool deviceRegister(const char *name, DeviceKind kind, uint8_t pin, pinMode(pin, INPUT_PULLUP); } + /* Initialize NeoPixel strip */ + if (kind == DEV_ACTUATOR_NEOPIXEL && pin != PIN_NONE) { + neopixelInit(i, pin, baud > 0 ? (uint16_t)baud : 1, neo_color_order); + } + /* Configure GPIO for non-I2C actuators */ - if (deviceIsActuator(kind) && !deviceIsI2c(kind) && pin != PIN_NONE) { + if (deviceIsActuator(kind) && !deviceIsI2c(kind) && + kind != DEV_ACTUATOR_NEOPIXEL && pin != PIN_NONE) { pinMode(pin, OUTPUT); } @@ -228,6 +239,10 @@ bool deviceRemove(const char *name) { dev->kind == DEV_SENSOR_DHT22_TEMP || dev->kind == DEV_SENSOR_DHT22_HUMI) { dhtCacheInvalidate(dev->pin); } + if (dev->kind == DEV_ACTUATOR_NEOPIXEL) { + int slot = dev - deviceGetAll(); + neopixelDeinit(slot); + } dev->used = false; dev->name[0] = '\0'; return true; @@ -453,6 +468,12 @@ bool deviceSetActuator(Device *dev, int value) { } return true; + case DEV_ACTUATOR_NEOPIXEL: { + int slot = dev - deviceGetAll(); + neopixelFill(slot, (uint32_t)value); + return true; + } + default: return false; } @@ -659,6 +680,10 @@ void devicesSave() { ",\"sc\":%.6g", d->i2c_scale); } } + if (d->kind == DEV_ACTUATOR_NEOPIXEL && d->neo_color_order != NEO_ORDER_GRB) { + w += snprintf(buf + w, sizeof(buf) - w, + ",\"co\":%d", d->neo_color_order); + } /* Persist last value for relay/digital_out (safe to restore on boot) */ if ((d->kind == DEV_ACTUATOR_RELAY || d->kind == DEV_ACTUATOR_DIGITAL) && d->last_value != 0) { @@ -755,12 +780,13 @@ static void devicesLoad() { devJsonGetString(objBuf, "dt", disp_tmpl, sizeof(disp_tmpl)); uint8_t i2c_reg_len = (uint8_t)devJsonGetInt(objBuf, "rl", 1); float i2c_scale = devJsonGetFloat(objBuf, "sc", 1.0f); + uint8_t neo_co = (uint8_t)devJsonGetInt(objBuf, "co", NEO_ORDER_GRB); DeviceKind kind = kindFromString(kind_str); deviceRegister(name, kind, (uint8_t)pin, unit, inverted, nats_subj[0] ? nats_subj : nullptr, baud, i2c_addr, disp_tmpl[0] ? disp_tmpl : nullptr, - i2c_reg_len, i2c_scale); + i2c_reg_len, i2c_scale, neo_co); /* Restore persisted actuator value for relay/digital_out */ if (kind == DEV_ACTUATOR_RELAY || kind == DEV_ACTUATOR_DIGITAL) { @@ -810,6 +836,9 @@ void devicesClear() { } i2cDeinit(); } + if (g_devices[i].used && g_devices[i].kind == DEV_ACTUATOR_NEOPIXEL) { + neopixelDeinit(i); + } } memset(g_devices, 0, sizeof(g_devices)); } diff --git a/src/nats_config.cpp b/src/nats_config.cpp index 8b24670..1775499 100644 --- a/src/nats_config.cpp +++ b/src/nats_config.cpp @@ -186,6 +186,7 @@ static void cfgDeviceAdd(nats_client_t *client, const nats_msg_t *msg, else if (strcmp(kind_str, "dht11_humi") == 0) kind = DEV_SENSOR_DHT11_HUMI; else if (strcmp(kind_str, "dht22_temp") == 0) kind = DEV_SENSOR_DHT22_TEMP; else if (strcmp(kind_str, "dht22_humi") == 0) kind = DEV_SENSOR_DHT22_HUMI; + else if (strcmp(kind_str, "neopixel") == 0) kind = DEV_ACTUATOR_NEOPIXEL; else { cfgError(client, msg, "unknown_kind", kind_str); return; @@ -197,11 +198,12 @@ static void cfgDeviceAdd(nats_client_t *client, const nats_msg_t *msg, cfgJsonGetString(payload, "dt", disp_tmpl, sizeof(disp_tmpl)); uint8_t i2c_reg_len = (uint8_t)cfgJsonGetInt(payload, "rl", 1); float i2c_scale = cfgJsonGetFloat(payload, "sc", 1.0f); + uint8_t neo_co = (uint8_t)cfgJsonGetInt(payload, "co", NEO_ORDER_GRB); bool ok = deviceRegister(name, kind, (uint8_t)pin, unit[0] ? unit : nullptr, inverted, nats_subj[0] ? nats_subj : nullptr, baud, i2c_addr, disp_tmpl[0] ? disp_tmpl : nullptr, - i2c_reg_len, i2c_scale); + i2c_reg_len, i2c_scale, neo_co); if (!ok) { cfgError(client, msg, "register_failed", "duplicate name or registry full"); return; diff --git a/src/nats_hal.cpp b/src/nats_hal.cpp index 540d582..6415dc5 100644 --- a/src/nats_hal.cpp +++ b/src/nats_hal.cpp @@ -12,6 +12,7 @@ #include "nats_hal.h" #include "devices.h" #include "i2c_devices.h" +#include "neopixel_driver.h" #include "soc/soc_caps.h" #if !defined(CONFIG_IDF_TARGET_ESP32) #include "driver/temperature_sensor.h" @@ -595,6 +596,16 @@ static void halDeviceLookup(nats_client_t *client, const nats_msg_t *msg, return; } + /* NeoPixel: intercept JSON payloads */ + if (dev->kind == DEV_ACTUATOR_NEOPIXEL && payload[0] == '{') { + int slot = dev - deviceGetAll(); + if (neopixelHandleJson(slot, payload, g_hal_reply, sizeof(g_hal_reply))) { + if (msg->reply_len > 0) + nats_msg_respond_str(client, msg, g_hal_reply); + return; + } + } + int val = payload[0] ? atoi(payload) : 0; deviceSetActuator(dev, val); if (msg->reply_len > 0) @@ -603,7 +614,14 @@ static void halDeviceLookup(nats_client_t *client, const nats_msg_t *msg, } if (suffix && strcmp(suffix, "get") == 0) { - if (deviceIsActuator(dev->kind)) { + if (dev->kind == DEV_ACTUATOR_NEOPIXEL) { + int slot = dev - deviceGetAll(); + snprintf(g_hal_reply, sizeof(g_hal_reply), + "{\"brightness\":%d,\"count\":%d,\"value\":%u}", + neopixelGetBrightness(slot), + neopixelGetCount(slot), + (unsigned)neopixelGetColor(slot)); + } else if (deviceIsActuator(dev->kind)) { snprintf(g_hal_reply, sizeof(g_hal_reply), "%d", dev->last_value); } else { float val = deviceReadSensor(dev); diff --git a/src/neopixel_driver.cpp b/src/neopixel_driver.cpp new file mode 100644 index 0000000..cecf0dd --- /dev/null +++ b/src/neopixel_driver.cpp @@ -0,0 +1,234 @@ +/** + * @file neopixel_driver.cpp + * @brief WS2812/NeoPixel addressable LED strip driver + */ + +#include "neopixel_driver.h" +#include "devices.h" +#include + +static Adafruit_NeoPixel *s_strips[NEOPIXEL_MAX_STRIPS] = {nullptr}; +static uint32_t s_last_color[NEOPIXEL_MAX_STRIPS] = {0}; + +/* Map device-array index (0..MAX_DEVICES-1) → internal strip slot (0..MAX_STRIPS-1). + -1 means no strip allocated for that device slot. */ +static int s_dev_to_strip[MAX_DEVICES]; +static bool s_map_init = false; + +static void ensureMapInit() { + if (s_map_init) return; + for (int i = 0; i < MAX_DEVICES; i++) s_dev_to_strip[i] = -1; + s_map_init = true; +} + +/* Resolve a device slot to an internal strip index, or -1 if none */ +static int resolveSlot(int devSlot) { + ensureMapInit(); + if (devSlot < 0 || devSlot >= MAX_DEVICES) return -1; + return s_dev_to_strip[devSlot]; +} + +static neoPixelType neoColorOrderType(uint8_t order) { + switch (order) { + default: + case NEO_ORDER_GRB: return NEO_GRB + NEO_KHZ800; + case NEO_ORDER_RGB: return NEO_RGB + NEO_KHZ800; + case NEO_ORDER_RBG: return NEO_RBG + NEO_KHZ800; + case NEO_ORDER_BRG: return NEO_BRG + NEO_KHZ800; + case NEO_ORDER_BGR: return NEO_BGR + NEO_KHZ800; + case NEO_ORDER_GBR: return NEO_GBR + NEO_KHZ800; + case NEO_ORDER_RGBW: return NEO_RGBW + NEO_KHZ800; + case NEO_ORDER_GRBW: return NEO_GRBW + NEO_KHZ800; + } +} + +void neopixelInit(int devSlot, uint8_t pin, uint16_t numPixels, uint8_t colorOrder) { + ensureMapInit(); + if (devSlot < 0 || devSlot >= MAX_DEVICES) return; + if (numPixels == 0) numPixels = 1; + + /* If this device already has a strip, deinit it first */ + if (s_dev_to_strip[devSlot] >= 0) { + neopixelDeinit(devSlot); + } + + /* Find a free internal strip slot */ + int strip = -1; + for (int i = 0; i < NEOPIXEL_MAX_STRIPS; i++) { + if (!s_strips[i]) { strip = i; break; } + } + if (strip < 0) { + Serial.printf("NeoPixel: no free strip slot for device %d\n", devSlot); + return; + } + + s_dev_to_strip[devSlot] = strip; + s_strips[strip] = new Adafruit_NeoPixel(numPixels, pin, neoColorOrderType(colorOrder)); + s_strips[strip]->begin(); + s_strips[strip]->clear(); + s_strips[strip]->show(); + s_last_color[strip] = 0; + Serial.printf("NeoPixel: dev %d -> strip %d, pin %d, %d pixels, order %d\n", + devSlot, strip, pin, numPixels, colorOrder); +} + +void neopixelDeinit(int devSlot) { + int strip = resolveSlot(devSlot); + if (strip < 0 || !s_strips[strip]) return; + s_strips[strip]->clear(); + s_strips[strip]->show(); + delete s_strips[strip]; + s_strips[strip] = nullptr; + s_last_color[strip] = 0; + s_dev_to_strip[devSlot] = -1; +} + +void neopixelFill(int devSlot, uint32_t color) { + int strip = resolveSlot(devSlot); + if (strip < 0 || !s_strips[strip]) return; + s_strips[strip]->fill(color); + s_strips[strip]->show(); + s_last_color[strip] = color; +} + +void neopixelSetPixel(int devSlot, uint16_t pixel, uint32_t color) { + int strip = resolveSlot(devSlot); + if (strip < 0 || !s_strips[strip]) return; + if (pixel >= s_strips[strip]->numPixels()) return; + s_strips[strip]->setPixelColor(pixel, color); + s_strips[strip]->show(); +} + +void neopixelSetBrightness(int devSlot, uint8_t brightness) { + int strip = resolveSlot(devSlot); + if (strip < 0 || !s_strips[strip]) return; + s_strips[strip]->setBrightness(brightness); + s_strips[strip]->show(); +} + +void neopixelClear(int devSlot) { + int strip = resolveSlot(devSlot); + if (strip < 0 || !s_strips[strip]) return; + s_strips[strip]->clear(); + s_strips[strip]->show(); + s_last_color[strip] = 0; +} + +uint8_t neopixelGetBrightness(int devSlot) { + int strip = resolveSlot(devSlot); + if (strip < 0 || !s_strips[strip]) return 0; + return s_strips[strip]->getBrightness(); +} + +uint16_t neopixelGetCount(int devSlot) { + int strip = resolveSlot(devSlot); + if (strip < 0 || !s_strips[strip]) return 0; + return s_strips[strip]->numPixels(); +} + +uint32_t neopixelGetColor(int devSlot) { + int strip = resolveSlot(devSlot); + if (strip < 0) return 0; + return s_last_color[strip]; +} + +/* Parse hex color string (e.g. "FF0000") to uint32_t */ +static uint32_t parseHexColor(const char *s) { + uint32_t color = 0; + for (int i = 0; i < 6 && s[i]; i++) { + char c = s[i]; + uint8_t nibble = 0; + if (c >= '0' && c <= '9') nibble = c - '0'; + else if (c >= 'a' && c <= 'f') nibble = 10 + c - 'a'; + else if (c >= 'A' && c <= 'F') nibble = 10 + c - 'A'; + else break; + color = (color << 4) | nibble; + } + return color; +} + +/* Extract a JSON string value for a key into dst. Returns true if found. */ +static bool jsonStr(const char *json, const char *key, char *dst, int dst_len) { + char pattern[32]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char *p = strstr(json, pattern); + if (!p) return false; + p += strlen(pattern); + while (*p == ' ' || *p == ':') p++; + if (*p != '"') return false; + p++; + int w = 0; + while (*p && *p != '"' && w < dst_len - 1) { + if (*p == '\\' && *(p + 1)) p++; + dst[w++] = *p++; + } + dst[w] = '\0'; + return w > 0; +} + +/* Extract a JSON integer value for a key. Returns default_val if not found. */ +static int jsonInt(const char *json, const char *key, int default_val) { + char pattern[32]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char *p = strstr(json, pattern); + if (!p) return default_val; + p += strlen(pattern); + while (*p == ' ' || *p == ':') p++; + return atoi(p); +} + +/* Check if a JSON boolean key is true */ +static bool jsonBool(const char *json, const char *key) { + char pattern[32]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char *p = strstr(json, pattern); + if (!p) return false; + p += strlen(pattern); + while (*p == ' ' || *p == ':') p++; + return strncmp(p, "true", 4) == 0; +} + +bool neopixelHandleJson(int devSlot, const char *json, char *reply, int reply_len) { + int slot = resolveSlot(devSlot); + if (slot < 0 || !s_strips[slot]) return false; + + char color_str[8]; + + /* {"clear":true} */ + if (jsonBool(json, "clear")) { + neopixelClear(devSlot); + snprintf(reply, reply_len, "ok"); + return true; + } + + /* {"brightness":128} */ + if (strstr(json, "\"brightness\"")) { + int b = jsonInt(json, "brightness", -1); + if (b >= 0 && b <= 255) { + neopixelSetBrightness(devSlot, (uint8_t)b); + snprintf(reply, reply_len, "ok"); + return true; + } + } + + /* {"pixel":3,"color":"FF0000"} */ + if (strstr(json, "\"pixel\"")) { + int pixel = jsonInt(json, "pixel", -1); + if (pixel >= 0 && jsonStr(json, "color", color_str, sizeof(color_str))) { + uint32_t color = parseHexColor(color_str); + neopixelSetPixel(devSlot, (uint16_t)pixel, color); + snprintf(reply, reply_len, "ok"); + return true; + } + } + + /* {"fill":"00FF00"} */ + if (jsonStr(json, "fill", color_str, sizeof(color_str))) { + uint32_t color = parseHexColor(color_str); + neopixelFill(devSlot, color); + snprintf(reply, reply_len, "ok"); + return true; + } + + return false; +} diff --git a/src/web_config.cpp b/src/web_config.cpp index 8dbaf37..de2ecf7 100644 --- a/src/web_config.cpp +++ b/src/web_config.cpp @@ -15,6 +15,7 @@ #include "devices.h" #include "nats_hal.h" #include "i2c_devices.h" +#include "neopixel_driver.h" /* Externs from main.cpp */ extern char cfg_wifi_ssid[64]; @@ -366,6 +367,12 @@ static void handleGetDevices() { d->ev_armed ? "true" : "false"); } + /* Append pixel count and color order for neopixel devices */ + if (d->kind == DEV_ACTUATOR_NEOPIXEL) { + w += snprintf(buf + w, sizeof(buf) - w, ",\"pixels\":%d,\"color_order\":%d", + neopixelGetCount(i), d->neo_color_order); + } + /* Append history array for sensors with recorded readings */ int hcount = d->history_full ? DEV_HISTORY_LEN : d->history_idx; if (hcount > 0) { @@ -450,6 +457,7 @@ static void handleAddDevice() { else if (strcmp(kind_str, "dht11_humi") == 0) kind = DEV_SENSOR_DHT11_HUMI; else if (strcmp(kind_str, "dht22_temp") == 0) kind = DEV_SENSOR_DHT22_TEMP; else if (strcmp(kind_str, "dht22_humi") == 0) kind = DEV_SENSOR_DHT22_HUMI; + else if (strcmp(kind_str, "neopixel") == 0) kind = DEV_ACTUATOR_NEOPIXEL; else { server.send(400, "application/json", "{\"ok\":false,\"error\":\"unknown kind\"}"); return; @@ -480,9 +488,12 @@ static void handleAddDevice() { /* OLED display defaults (SSD1306/SH1106) */ if (deviceIsDisplay(kind)) pin = (uint8_t)wcJsonGetInt(body, "pin", 0); + /* NeoPixel color order */ + uint8_t neo_co = (uint8_t)wcJsonGetInt(body, "color_order", NEO_ORDER_GRB); + bool ok = deviceRegister(name, kind, (uint8_t)pin, unit, inverted, nullptr, baud, i2c_addr, disp_tmpl[0] ? disp_tmpl : nullptr, - i2c_reg_len, i2c_scale); + i2c_reg_len, i2c_scale, neo_co); if (ok) devicesSave(); static char resp[128]; @@ -966,6 +977,7 @@ nav button{padding:0.4rem 0.6rem;font-size:0.8rem} + @@ -1007,6 +1019,21 @@ nav button{padding:0.4rem 0.6rem;font-size:0.8rem} +
@@ -1133,6 +1160,7 @@ document.getElementById('ad_chan_wrap').classList.toggle('hidden',!isMultiChan); document.getElementById('ad_unit_wrap').classList.toggle('hidden',!isI2c); document.getElementById('ad_tmpl_wrap').classList.toggle('hidden',!isDisp); document.getElementById('ad_generic_wrap').classList.toggle('hidden',k!=='i2c_generic'); +document.getElementById('ad_neo_wrap').classList.toggle('hidden',k!=='neopixel'); document.getElementById('ad_scan_btn').classList.toggle('hidden',!isI2c); if(k==='i2c_bme280'){document.getElementById('ad_chan_hint').textContent='0=temp, 1=humidity, 2=pressure';document.getElementById('ad_chan').max=2} else if(k==='i2c_sht31'){document.getElementById('ad_chan_hint').textContent='0=temp, 1=humidity';document.getElementById('ad_chan').max=1} @@ -1165,6 +1193,12 @@ d.pin=parseInt(document.getElementById('ad_reg').value)||0; d.reg_len=parseInt(document.getElementById('ad_reglen').value)||1; d.scale=parseFloat(document.getElementById('ad_scale').value)||1; } +}else if(kind==='neopixel'){ +var pin=parseInt(document.getElementById('ad_pin').value); +if(isNaN(pin)){toast('Pin required',false);return} +d.pin=pin; +d.baud=parseInt(document.getElementById('ad_pixels').value)||1; +d.color_order=parseInt(document.getElementById('ad_color_order').value)||0; }else{ var pin=parseInt(document.getElementById('ad_pin').value); if(isNaN(pin)){toast('Pin required',false);return} @@ -1198,6 +1232,12 @@ var hex='#'+('000000'+d.raw.toString(16)).slice(-6); h+='
pin '+d.pin+'
'; h+='
'+hex+''; h+='
'; +}else if(d.kind==='neopixel'){ +var hex='#'+('000000'+d.raw.toString(16)).slice(-6); +var coNames=['GRB','RGB','RBG','BRG','BGR','GBR','RGBW','GRBW']; +h+='
pin '+d.pin+' · '+d.pixels+' pixels · '+(coNames[d.color_order]||'GRB')+'
'; +h+='
'+hex+''; +h+='
'; }else if(d.kind==='digital_out'||d.kind==='relay'){ var isOn=d.raw!==0; h+='
pin '+d.pin+'
';