Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions cli/ionode
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -50,6 +51,7 @@ _show_help() {
printf ' %spwm%s %s<node> <pin> get|set [v]%s Raw PWM access\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)"
printf ' %suart%s %s<node> read|write [text]%s UART serial\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)"
printf ' %si2c%s %s<node> scan|detect|read|write%s I2C bus access\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)"
printf ' %sneopixel%s %s<node> <strip> <action>%s NeoPixel LED strip\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)"
printf ' %sdevices%s %s<node>%s List registered devices\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)"
printf '\n'

Expand Down Expand Up @@ -172,6 +174,7 @@ case "$COMMAND" in
pwm) cmd_pwm "$@" ;;
uart) cmd_uart "$@" ;;
i2c) cmd_i2c "$@" ;;
neopixel) cmd_neopixel "$@" ;;
devices) cmd_devices "$@" ;;

# Config
Expand Down
275 changes: 275 additions & 0 deletions cli/lib/cmd_neopixel.sh
Original file line number Diff line number Diff line change
@@ -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 <node> <strip> <action> [args...]%s\n\n' "$(c_dim)" "$(_rst)"
printf ' %s%sACTIONS%s\n' "$(c_dim)" "$(c_bold)" "$(_rst)"
printf ' %sfill%s %s<color>%s Fill all pixels\n' "$(c_accent)" "$(_rst)" "$(c_dim)" "$(_rst)"
printf ' %sset%s %s<pixel> <color>%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<p:c>[,<p:c>,...]%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 <node> <strip> fill <color>%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 <node> <strip> set <pixel> <color>%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 <node> <strip> 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 <node> <strip> batch <p:c>[,<p:c>,...]%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
}
Loading