diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..479c528 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +# Top-level convenience targets wrapping PlatformIO (software/) and the +# doc-gif generation pipeline (tools/gifgen/). + +PIO := pio +PIO_ENV := esp12e +SOFTWARE_DIR := software + +.PHONY: build upload uploadAndMonitor uploadOTA doc-gifs + +build: + cd $(SOFTWARE_DIR) && $(PIO) run -e $(PIO_ENV) + +upload: build + cd $(SOFTWARE_DIR) && $(PIO) run -e $(PIO_ENV) -t upload + +uploadAndMonitor: build + cd $(SOFTWARE_DIR) && $(PIO) run -e $(PIO_ENV) -t upload -t monitor + +# Flash a board over WiFi via ArduinoOTA. Prompts for the target device's MAC +# address rather than an IP, since the board's OTA hostname is derived from +# its MAC (see initOTA() in src/main.cpp) and mDNS (.local) resolution finds +# it regardless of its current DHCP-assigned IP. +uploadOTA: build + @read -p "Enter device MAC address (e.g. 84:F3:EB:B7:09:A5): " mac; \ + host="espled-$$(echo "$$mac" | tr -d ':')"; \ + auth=$$(grep -oE 'OTA_PASSWORD *"[^"]*"' $(SOFTWARE_DIR)/config/device_config.h | grep -oE '"[^"]*"' | tr -d '"'); \ + if [ -z "$$auth" ]; then \ + echo "OTA_PASSWORD not found in $(SOFTWARE_DIR)/config/device_config.h - see config/device_config.h.example"; \ + exit 1; \ + fi; \ + echo "Flashing $$host.local ..."; \ + cd $(SOFTWARE_DIR) && PLATFORMIO_UPLOAD_PROTOCOL=espota PLATFORMIO_UPLOAD_PORT=$$host.local PLATFORMIO_UPLOAD_FLAGS="--auth=$$auth" $(PIO) run -e $(PIO_ENV) -t upload + +doc-gifs: + python3 tools/gifgen/generate.py diff --git a/Readme.md b/Readme.md index 61b87a6..ed1bcb7 100644 --- a/Readme.md +++ b/Readme.md @@ -61,25 +61,12 @@ Software: ## Which parts do I need? See the Bill-Of-Materials file named ESPLED.csv in the hardware folder. -## Suggested MODES - -| Modenumber | Name | Description | used-Arguments-from-Var-Silo| -|--------------|------------------|-------------|-----------------------------| -|1| Fade to color | Also used for jumps -> time = 0 | RGBWW-value, Transitiontime | -|2| Blink color | Stores current State, Fades to color and back - Maybe posibility of selectiong number of blinks | RGBWW-value, Transition time, OnTime, NumOfBlinks | -|3|Strobe|Strobe selectable color and Freq|RGBWW-value, Freq | -|4|Sound2LIGHT|adjust brightness according to soundvalue|RGBWW-value, sensivitity | -|5|Breathing|Strip turns brighter and darker periodically (some randomness) |RGBWW-value, frequency how often it should "breath"| -|6|TempVis|Visualizes Temperatur Value of Temp Sensor - Cold(Blue)<>Normal(Green)<>Hot(Red)|| -|7|||| -|8|||| -|101|Lagerfeuer Animation|(gibt es online fertig)|centerposition| -|102|Noise||RGBWW,update Rate| -|103|VU-Meter|| RGbWW, Sensitivity, update rate, centerposition| -|104|StrobeParts|Small x-LED long "Parts" light up in strobe mode|RGBWW, Freq, centerposition, PartLength| -|105|Moving Parts|Small x-LED long "Parts" travel from centerpos(=Startpos) - to the end(s) of the strip|RGBWW-value, update rate, centerposition, | -|106|American Police Light|red/blue left/right (half of adress room) flash|update rate| -|107|Rainbow|fills strip with rainbow maybe moves it|| +## Supported MODES + +The full, up-to-date list of supported modes — numbers, parameters, JSON examples, and animated +previews — lives in [`software/README.md`](/software/README.md#modes), generated directly from +the actual firmware source. This section used to duplicate that table as a pre-implementation +design sketch; it's been removed here to avoid the two drifting out of sync. ## nice Projects with led animations for insperation diff --git a/doc/gifs/0000_fade2Color.gif b/doc/gifs/0000_fade2Color.gif new file mode 100644 index 0000000..ad26f85 Binary files /dev/null and b/doc/gifs/0000_fade2Color.gif differ diff --git a/doc/gifs/0001_strobe.gif b/doc/gifs/0001_strobe.gif new file mode 100644 index 0000000..6a528ba Binary files /dev/null and b/doc/gifs/0001_strobe.gif differ diff --git a/doc/gifs/0002_breathe.gif b/doc/gifs/0002_breathe.gif new file mode 100644 index 0000000..bd8b42e Binary files /dev/null and b/doc/gifs/0002_breathe.gif differ diff --git a/doc/gifs/0003_ocean.gif b/doc/gifs/0003_ocean.gif new file mode 100644 index 0000000..8e75a69 Binary files /dev/null and b/doc/gifs/0003_ocean.gif differ diff --git a/doc/gifs/0004_forest.gif b/doc/gifs/0004_forest.gif new file mode 100644 index 0000000..3ee7ccd Binary files /dev/null and b/doc/gifs/0004_forest.gif differ diff --git a/doc/gifs/0005_dusk.gif b/doc/gifs/0005_dusk.gif new file mode 100644 index 0000000..c78c5cd Binary files /dev/null and b/doc/gifs/0005_dusk.gif differ diff --git a/doc/gifs/0006_fireFlicker.gif b/doc/gifs/0006_fireFlicker.gif new file mode 100644 index 0000000..86e097b Binary files /dev/null and b/doc/gifs/0006_fireFlicker.gif differ diff --git a/doc/gifs/0007_heartBeat.gif b/doc/gifs/0007_heartBeat.gif new file mode 100644 index 0000000..06a4c3d Binary files /dev/null and b/doc/gifs/0007_heartBeat.gif differ diff --git a/doc/gifs/0050_sound2Light.gif b/doc/gifs/0050_sound2Light.gif new file mode 100644 index 0000000..b5d6cdc Binary files /dev/null and b/doc/gifs/0050_sound2Light.gif differ diff --git a/doc/gifs/0051_bassReact.gif b/doc/gifs/0051_bassReact.gif new file mode 100644 index 0000000..fb6e854 Binary files /dev/null and b/doc/gifs/0051_bassReact.gif differ diff --git a/doc/gifs/0100_wipe.gif b/doc/gifs/0100_wipe.gif new file mode 100644 index 0000000..d2cfc44 Binary files /dev/null and b/doc/gifs/0100_wipe.gif differ diff --git a/doc/gifs/0101_fire.gif b/doc/gifs/0101_fire.gif new file mode 100644 index 0000000..04dbe3a Binary files /dev/null and b/doc/gifs/0101_fire.gif differ diff --git a/doc/gifs/0102_noise.gif b/doc/gifs/0102_noise.gif new file mode 100644 index 0000000..4476bb5 Binary files /dev/null and b/doc/gifs/0102_noise.gif differ diff --git a/doc/gifs/0103_noiseRandomColor.gif b/doc/gifs/0103_noiseRandomColor.gif new file mode 100644 index 0000000..4f31d4d Binary files /dev/null and b/doc/gifs/0103_noiseRandomColor.gif differ diff --git a/doc/gifs/0104_nightSky.gif b/doc/gifs/0104_nightSky.gif new file mode 100644 index 0000000..9166daf Binary files /dev/null and b/doc/gifs/0104_nightSky.gif differ diff --git a/doc/gifs/0105_movingParts.gif b/doc/gifs/0105_movingParts.gif new file mode 100644 index 0000000..3bbbff7 Binary files /dev/null and b/doc/gifs/0105_movingParts.gif differ diff --git a/doc/gifs/0106_movingBackAndForth.gif b/doc/gifs/0106_movingBackAndForth.gif new file mode 100644 index 0000000..d79054e Binary files /dev/null and b/doc/gifs/0106_movingBackAndForth.gif differ diff --git a/doc/gifs/0107_rainbowCycle.gif b/doc/gifs/0107_rainbowCycle.gif new file mode 100644 index 0000000..2e1937f Binary files /dev/null and b/doc/gifs/0107_rainbowCycle.gif differ diff --git a/doc/gifs/0108_theaterChase.gif b/doc/gifs/0108_theaterChase.gif new file mode 100644 index 0000000..f79668e Binary files /dev/null and b/doc/gifs/0108_theaterChase.gif differ diff --git a/doc/gifs/0109_comet.gif b/doc/gifs/0109_comet.gif new file mode 100644 index 0000000..7de50fd Binary files /dev/null and b/doc/gifs/0109_comet.gif differ diff --git a/doc/gifs/0110_cometBackAndForth.gif b/doc/gifs/0110_cometBackAndForth.gif new file mode 100644 index 0000000..9f4f83f Binary files /dev/null and b/doc/gifs/0110_cometBackAndForth.gif differ diff --git a/doc/gifs/0111_strobeParts.gif b/doc/gifs/0111_strobeParts.gif new file mode 100644 index 0000000..0bc5b4a Binary files /dev/null and b/doc/gifs/0111_strobeParts.gif differ diff --git a/doc/gifs/0112_policeLights.gif b/doc/gifs/0112_policeLights.gif new file mode 100644 index 0000000..244f8b3 Binary files /dev/null and b/doc/gifs/0112_policeLights.gif differ diff --git a/doc/gifs/0113_progressBar.gif b/doc/gifs/0113_progressBar.gif new file mode 100644 index 0000000..36096a0 Binary files /dev/null and b/doc/gifs/0113_progressBar.gif differ diff --git a/doc/gifs/0500_vuMeter.gif b/doc/gifs/0500_vuMeter.gif new file mode 100644 index 0000000..3a386df Binary files /dev/null and b/doc/gifs/0500_vuMeter.gif differ diff --git a/doc/gifs/0501_vuMeterPeak.gif b/doc/gifs/0501_vuMeterPeak.gif new file mode 100644 index 0000000..cfa2f4a Binary files /dev/null and b/doc/gifs/0501_vuMeterPeak.gif differ diff --git a/doc/gifs/0502_spectrum.gif b/doc/gifs/0502_spectrum.gif new file mode 100644 index 0000000..bfd94c7 Binary files /dev/null and b/doc/gifs/0502_spectrum.gif differ diff --git a/doc/gifs/1000_pixelArray.gif b/doc/gifs/1000_pixelArray.gif new file mode 100644 index 0000000..ad3286e Binary files /dev/null and b/doc/gifs/1000_pixelArray.gif differ diff --git a/doc/gifs/1001_blinkColor.gif b/doc/gifs/1001_blinkColor.gif new file mode 100644 index 0000000..5826b19 Binary files /dev/null and b/doc/gifs/1001_blinkColor.gif differ diff --git a/doc/modes_description.md b/doc/modes_description.md deleted file mode 100644 index 437e9c8..0000000 --- a/doc/modes_description.md +++ /dev/null @@ -1,124 +0,0 @@ -# Modes an mqtt - -## 0 fade2Color - -### Description: -This is the most basic mode to change the strips color. You send the color a transition time. A transition time of 0 leads to an instant color jump. - -### Parameters: -| Parameter | Effect | Unit | min. | man. | -|-----------|--------|------|------|------| -| mode | changes Mode | int | 0 | 0 | -| color | defines color in an Red-Green-Blue-WarmWhite-ColdWhite array | PWM | 0 | 1023 | -| time | time until the end color is reached | milliseconds | 0 | 4294967295 ~ 49.7d | - -### Example JSON: -``` JSON -{ - "mode":0, - "color":[ - 1023, - 1023, - 1023, - 1023, - 1023 - ], - "time":1000 -} -``` - - -## 1 blinkColor - -### Description: -This mode should be used for notifictaion. The Controller will store it's current state and blink in the selected color. You may select a number of blinks with the length parameter. - -### Parameters: -| Parameter | Effect | Unit | min. | man. | -|-----------|--------|------|------|------| -| mode | changes Mode | int | 1 | 1 | -| color | defines color in an Red-Green-Blue-WarmWhite-ColdWhite array | PWM | 0 | 1023 | -| time | duration of one blink | milliseconds | 0 | 4294967295 ~ 49.7d | -| legth | number of blinks | int | 0 | ? | - -### Example JSON: -``` JSON -{ - "mode":1, - "color":[ - 1023, - 1023, - 1023, - 1023, - 1023 - ], - "time":1000, - "legth":3 -} -``` - -## 2 strobe -### Description: -This mode lights up the strip in the selected color and blinks it on/off in the selected frequency - no fading. - -### Parameters: -| Parameter | Effect | Unit | min. | man. | -|-----------|--------|------|------|------| -| mode | changes Mode | t | 2 | 2 | -| color | defines color in an Red-Green-Blue-WarmWhite-ColdWhite array | PWM | 0 | 1023 | -| frequency | changes the strobe frequency | Hz | 1 | 60 | - -### Example JSON: -``` JSON -{ - "mode":4, - "color":[ - 1023, - 0, - 0, - 0, - 0 - ], - "time":6000, - "minBrightnes":0, - "maxBrightnes":1023, - "timeVariance":3000, - "maxBrightnesVariance":500 -} -``` - -## 3 sound2Light - -## 4 breathe -### Description: -This mode realizes a breathing function. This means the whole strip gets dimmed up and down. You can add some randomness to that through timeVariance and maxBrightnessVariance. - -### Parameters: -| Parameter | Effect | Unit | min. | man. | -|-----------|--------|------|------|------| -| mode | changes Mode | t | 4 | 4 | -| color | defines color in an Red-Green-Blue-WarmWhite-ColdWhite array | PWM | 0 | 1023 | -| time | Period of whole "breath" (effected by time Variance) | milliseconds | 0 | 4294967295 ~ 49.7d | -| minBrightnes | minimum Brightness | PWM | 0 | 1023 | -| maxBrightnes | maximum Brightness (effected by maxBrightnesVariance) | PWM | 0 | 1023 | -| timeVariance | if set each breath a random amount of ms within the Interval [-timeVariance <> +timeVariance] gets added to the breath time| millisceonds | 0 | 4294967295 ~ 49.7d | -| maxBrightnesVariance | if set each breath a random amount of brightnes within the Interval [-maxBrightnesVariance <> +maxBrightnesVariance] gets added to maxBrightnes| PWM | 0 | 1023 | - -### Example JSON: -``` JSON -{ - "mode":4, - "color":[ - 1023, - 0, - 0, - 0, - 0 - ], - "time":6000, - "minBrightnes":0, - "maxBrightnes":1023, - "timeVariance":3000, - "maxBrightnesVariance":500 -} -``` \ No newline at end of file diff --git a/software/.gitignore b/software/.gitignore index 79c96b7..7fa6258 100644 --- a/software/.gitignore +++ b/software/.gitignore @@ -4,3 +4,4 @@ .clang_complete .gcc-flags.json .vscode +config/device_config.h diff --git a/software/DEVNOTES.md b/software/DEVNOTES.md index 3fb481b..c1fff3b 100644 --- a/software/DEVNOTES.md +++ b/software/DEVNOTES.md @@ -33,3 +33,302 @@ else * For Timing-Measurements we use the millis-command * For Accessing the LEDs both AnimationHandlers hold attributes of the Type FastLedEndpoint and StripControleEndpoint which do the lower Level Stuff * All Modes implemented in the AnimationHandlerPWM should also be Implemented in the AnimationHandlerBus +* Uniform-color modes (mode < 100, shared by both handlers) are implemented as `CRGBWW`-returning + methods on the `AnimationHandler` base class. Addressable-only modes (mode >= 100, Bus only) are + implemented as `void` methods on `AnimationHandlerBus` that write directly into its `leds[]` + buffer. Either way, **one file per mode** under that class's `Modes/` folder + (`lib/AnimationHandler/Modes/*.cpp`, `lib/AnimationHandlerBus/Modes/*.cpp`) - never add a new mode + body directly inside `AnimationHandler.cpp`/`AnimationHandlerBus.cpp` themselves, those files are + reserved for the handler's own dispatch/lifecycle code. +* `AnimationHandlerBus::handle()` samples audio into `audioRingBuffer` every tick (via the inherited + `recordAudioSample()`), the same way `AnimationHandlerPWM::handle()` already did - needed for any + addressable audio-reactive mode (`VU_METER`). +* `recordAudioSample()` used to nudge `audioBias` toward every single incoming sample (a plain + per-sample exponential moving average). That had no protection against a loud or sustained sound + dragging the bias toward itself - every audio-reactive mode measures loudness as deviation from + `audioBias`, so once the bias got corrupted, ordinary quiet audio afterward read as "loud" forever + (frozen at peak) until the same tiny per-sample nudge slowly drifted back, which could take a long + time. Fixed by switching to periodic, gated recalibration (`ModeMath::recalibrateBias()`, unit + tested): once a second, a candidate new bias (the ring buffer's mean) is only committed if it's + both internally quiet (low peak-to-peak spread) AND close to the current bias. Both checks matter + - a hard-clipped/rail-pinned loud signal is perfectly *flat* (zero spread) despite being nowhere + near silence, so spread alone isn't enough; the "close to current bias" check catches that case + too. A window that fails either check is simply skipped and retried next second, so `audioBias` + just holds at its last known-good value for as long as things stay loud. +* That `audioBias` fix turned out not to be enough in practice: `vuMeter()`/`vuMeterPeak()` (500/501) + still measured loudness as deviation from `audioBias`, and on real hardware the combination of + bias-tracking + gain ended up flat-lining the reading at/near peak most of the time rather than + actually being fixed. Rather than keep tuning the bias-tracking approach, it was dropped entirely + for these two modes: `ModeMath::peakToPeakAmplitude()` (half the ring buffer's own max-min spread) + is a loudness measure that doesn't need to know where the signal's DC center sits at all, so there's + no bias to get wrong in the first place. `silo->sensitivity` is now the only amplification knob, + applied as a plain gain on top of that raw value - no other automatic leveling/normalization. + `SOUND_2_LIGHT`/`BASS_REACT` (50/51) still use the older bias-relative approach as of this writing; + revisit them the same way if they show the same symptom. +* `COMET`/`COMET_BACK_AND_FORTH` (109/110) used to render their trail via `fadeToBlackBy()` decaying + whatever was already lit, plus setting only the single current head pixel each tick. That leaves + gaps whenever the head moves more than one pixel between ticks (routine, depending on + time/stripLength) - skipped pixels only ever got darker, never relit, so the "trail" had dark holes + in it instead of being continuous. Fixed by rendering the whole trail explicitly every tick, walking + backward from the head in `silo->length + 1`-pixel steps (so it's always a complete, correctly- + shaped pack regardless of the head's actual per-tick movement), with brightness decaying + per-*step* now instead of per-tick. `COMET_BACK_AND_FORTH` additionally needs + `ModeMath::isBounceMovingForward()` to know which side of the head to extend the trail on, since + unlike `COMET` (always one direction) its trail has to flip sides whenever the head reverses. +* `PROGRESS_BAR` (mode 113) is the one mode where `frequency=0` is valid rather than rejected - it + means "don't blink the leading pixel," a real and useful setting, not a nonsensical input like `0` + is for every other rate-based mode. Guarded by `ModeMath::isValidOptionalRateHz()` (0-60, vs the + usual `isValidRateHz()`'s 1-60) rather than reusing the standard guard. Its left/right anchor reuses + `mapLogicalToPhysical()` (the same reversal helper `fire()` uses for its base anchor) rather than + inventing new index math, just with only two options since a progress bar has no "center" concept. +* `PIXEL_ARRAY` (mode 1000) is the odd one out: its per-LED data doesn't come through `varSilo`/the + JSON control channel at all, since a 500-LED payload is too big for that. It arrives as a raw + binary payload on a separate `/pixels` MQTT topic, handled by + `AnimationHandlerBus::setPixelData()` and rendered by `Modes/1000_pixelArray.cpp`. +* `BLINK_COLOR` (mode 1001, `Modes/1001_blinkColor.cpp`) pauses whatever mode was running, flashes the + whole strip, then resumes it without a visible "jump" - the trick is entirely in timestamp math, + not pixels. `AnimationHandler::snapshotSilo()` copies `*silo` and `lastChange` into + `savedSilo`/`savedLastChange` - it must be called by `main.cpp`'s MQTT `callback()` *before* + overwriting `Silo`'s fields with the blink's own parameters, since that's the only point where + the previous mode's live parameters still exist (by the time any mode method runs, `*silo` + already holds whatever's currently selected). On resume, `blinkColor()` restores `*silo` and + shifts `lastChange` by exactly how long the blink itself ran, so `now - lastChange` - which is + what nearly every mode derives its position/progress from - computes to the same value it would + have had if the blink had never happened, instead of jumping forward by the blink's duration. + Since `AnimationHandlerPWM`/`AnimationHandlerBus` both privately inherit `AnimationHandler`, + `snapshotSilo()` needs a one-line public forwarding method on each of them for `main.cpp` to + actually call it - private inheritance makes the base's public members inaccessible from outside + the derived class, even though they're public on the base itself. +* Every `Modes/*.cpp` file is named `_.cpp` (e.g. `1000_pixelArray.cpp`), + matching the mode's numeric value in the `Mode` enum (`lib/Definitions/TypeDefinitions.h`) - makes + it possible to eyeball which mode numbers exist/are free at a glance from a directory listing. + Mode numbers are grouped by band, not assigned sequentially - see the enum's own comment and + `README.md`'s "Modes" section for the current band layout. +* `POLICE_LIGHTS` (mode 112) lays out alternating red/blue sections via + `ModeMath::policeLightsLayoutFor()`: as many `length`-pixel sections as fit with a 4px base gap + between them, then left-over pixels are spread evenly across those gaps first, and whatever + doesn't divide evenly is split between a start/end margin (a final odd pixel goes to the start) - + kept pure/host-testable like the rest of `ModeMath` rather than inlined into the mode itself. +* `SPECTRUM` (mode 502, `AnimationHandlerBus/Modes/0502_spectrum.cpp`) is the only mode that uses the + `arduinoFFT` library - `SOUND_2_LIGHT` (mode 50) used to depend on it too but was rewritten to use + the same cheaper rectified-amplitude envelope follower as `VU_METER`/`BASS_REACT` instead (no FFT + needed to drive overall brightness from volume). Unlike FastLED/Arduino.h, + `arduinoFFT`'s own header guards its Arduino-specific includes behind `#ifdef ARDUINO`, falling + back to plain stdlib/math.h includes on a bare host build - so the real vendored source compiles + as-is for `tools/gifgen/`'s host-side preview, no shim stand-in needed (see `build.sh`). Visual + bin/gap widths use `ModeMath::binWidthFor()`, kept pure/host-testable like the rest of `ModeMath`. + +## Project layout: `config/` vs `lib/` +* `config/` holds only things a user needs to tune for their own build: `board_config.h` (fixed + ESPLED board GPIO wiring, git-tracked) and `device_config.h` (WiFi/MQTT secrets + strip settings, + git-ignored, template in `device_config.h.example`). `lib/` holds only code/classes/algorithms - + `lib/Definitions/TypeDefinitions.h` is the one exception living there since it's shared type/enum + code, not a setting. +* The settings file is deliberately **not** called `user_config.h` - the ESP8266 NONOS SDK's own + `osapi.h` does `#include "user_config.h"` to pull in a framework-level config header, so a project + file with that exact name on the include path shadows it and breaks the build. Hence + `device_config.h`. +* `platformio.ini` needs both `[platformio] include_dir = config` *and* an explicit + `build_flags = -Iconfig`: PlatformIO's Library Dependency Finder builds each `lib/*` source with + its own scoped include path and does not automatically add `include_dir` to those, only to + `src/`-level compiles - discovered by building, not just reading the docs. +* `src/main.cpp` guards its `device_config.h` include with `__has_include` so a fresh checkout + fails the build with an actionable message (copy the `.example`) instead of a bare compiler + "file not found". + +## Architecture diagrams + +### Class diagram + +```mermaid +classDiagram + class AnimationHandler { + <> + -varSilo* silo + -bool* varSiloChanged + -long lastChange + -CRGBWW oldColor + -unsigned int fpsTimer + -unsigned int audioTimer + -float[] audioRingBuffer + -unsigned int ringBufferCounter + -float audioBias + -unsigned int UPDATE_TIME + +AnimationHandler(silo, varSiloChanged) + +fade2Color() CRGBWW + +blinkColor() CRGBWW + +strobe() CRGBWW + +sound2Light() CRGBWW + +breathe() CRGBWW + +ocean() CRGBWW + +forest() CRGBWW + +dusk() CRGBWW + +fireFlicker() CRGBWW + +bassReact() CRGBWW + +recordAudioSample() void + +rgb2hsv(rgb, hsv) void + +hsv2rgb(hsv, rgb) void + #cycleColors(colors, count) CRGBWW + } + + class AnimationHandlerBus { + <> + -CRGB* leds + -unsigned int stripLength + -PixelBytes* pixelTargets + +AnimationHandlerBus(type, stripLength, silo, varSiloChanged) + +handle() void + +setPixelData(data, length) void + -getNewColor() CRGBWW + -getNewStripBuffer() void + -CRGBWW2FastLedCRGB(color) CRGB + -FastLedCRGB2CRGBWW(color) CRGBWW + -wipe() void + -fire() void + -noise() void + -vuMeter() void + -vuMeterPeak() void + -strobeParts() void + -movingParts() void + -rainbowCycle() void + -theaterChase() void + -comet() void + -nightSky() void + -pixelArray() void + } + + class AnimationHandlerPWM { + <> + -StripControle* strip + +AnimationHandlerPWM(strip, silo, varSiloChanged) + +handle() void + } + + class StripControle { + <> + -stripType type + -CRGBWW channelValue + +StripControle(type) + +showColor(CRGBWW) void + +off() void + +getColor() CRGBWW + -RGBW2RGB() CRGBWW + -RGBWW2RGBW() CRGBWW + -RGBWW2RGB() CRGBWW + -recalculateColor() void + -setChannels() void + } + + class CRGBWW { + <> + +unsigned int R + +unsigned int G + +unsigned int B + +unsigned int WW + +unsigned int CW + +operator==(CRGBWW) bool + } + + class PixelBytes { + <<3-byte raw color for PIXEL_ARRAY>> + +uint8_t r + +uint8_t g + +uint8_t b + } + + class varSilo { + <> + +unsigned int mode + +CRGBWW colorValue + +unsigned int time + +unsigned int frequency + +unsigned char sensitivity + +unsigned int position + +unsigned int length + +unsigned int minBrightnes + +unsigned int maxBrightnes + +unsigned int timeVariance + +unsigned int maxBrightnesVariance + +unsigned int duration + } + + AnimationHandler <|-- AnimationHandlerBus : inherits + AnimationHandler <|-- AnimationHandlerPWM : inherits + AnimationHandlerPWM --> StripControle : drives + AnimationHandler ..> varSilo : reads/writes via pointer + varSilo *-- CRGBWW : colorValue + AnimationHandler *-- CRGBWW : oldColor + AnimationHandlerBus *-- PixelBytes : pixelTargets[] + StripControle ..> CRGBWW : showColor()/getColor() +``` + +Both handlers are constructed once in `src/main.cpp`'s `initStrip()` (whichever one matches the compile-time `STRIP_TYPE`) and driven every `loop()` iteration via `runAnimationHandler()` — `main.cpp` itself isn't a class, so it's omitted above; see the sequence diagrams below for how it fits in. + +### Sequence: addressable strip, WIPE (mode 100) + +```mermaid +sequenceDiagram + participant Broker as MQTT Broker + participant Main as main.cpp + participant Silo as varSilo (Silo) + participant Bus as AnimationHandlerBus + participant FastLED + + Broker->>Main: publish {"mode":100,"color":[...],"time":1500} on mainTopic + Main->>Main: callback() deserializes JSON + Main->>Silo: mode=WIPE, colorValue=..., time=1500 + Main->>Main: *varSiloChanged = true + + loop every loop() iteration + Main->>Bus: runAnimationHandler() -> busHandler->handle() + alt first tick after change + Bus->>Bus: *varSiloChanged true -> lastChange=now, oldColor=leds[0] + end + Bus->>Bus: recordAudioSample() (inherited, always runs) + alt UPDATE_TIME elapsed + Bus->>Bus: mode >= 100 -> getNewStripBuffer() + Bus->>Bus: case WIPE -> wipe() + Bus->>Bus: light leds[0..N) LED-by-LED based on elapsed time + Bus->>FastLED: FastLED.show() + FastLED->>FastLED: push leds[] out over the WS2812/APA102 data line + end + end +``` + +### Sequence: non-addressable strip, FADE_2_COLOR (mode 0) + +```mermaid +sequenceDiagram + participant Broker as MQTT Broker + participant Main as main.cpp + participant Silo as varSilo (Silo) + participant PWM as AnimationHandlerPWM + participant Strip as StripControle + + Broker->>Main: publish {"mode":0,"color":[...],"time":2000} on mainTopic + Main->>Main: callback() deserializes JSON + Main->>Silo: mode=FADE_2_COLOR, colorValue=..., time=2000 + Main->>Main: *varSiloChanged = true + + loop every loop() iteration + Main->>PWM: runAnimationHandler() -> pwmHandler->handle() + alt first tick after change + PWM->>Strip: getColor() + Strip-->>PWM: oldColor + end + PWM->>PWM: recordAudioSample() (inherited, always runs) + alt UPDATE_TIME elapsed + PWM->>PWM: switch(mode) -> case FADE_2_COLOR -> fade2Color() + PWM->>PWM: interpolate oldColor -> colorValue over time + PWM->>Strip: showColor(newColor) + Strip->>Strip: recalculateColor() (RGBWW -> RGBW/RGB per stripType) + Strip->>Strip: setChannels() -> analogWrite() per PWM pin + end + end +``` + +## Testing +* No hardware is required to run `pio test -e native` - it exercises the deterministic math each + mode leans on (guards, phase/index math, color interpolation), extracted into hardware-independent + helpers under `lib/ModeMath/`. It does not exercise FastLED rendering, MQTT, or the + inherently-randomized effects' actual appearance - those still need a real strip. diff --git a/software/README.md b/software/README.md index 9cf44a0..b9b4603 100644 --- a/software/README.md +++ b/software/README.md @@ -10,22 +10,25 @@ Although the ESPLED board is fully customizable, the default software should sat The configuration mode allows you to select your local WiFi network and the type of led strip you want to use. To enter the configuration mode, perform the following tasks: -- Press the __reset__ button -- Press and hold the __mode__ button after within 1 second after releasing the reset button -- Release the __mode__ button within 2 seconds +? +- Press and release the __reset__ button +- Press and hold the __mode__ button, the ESP Module should turn on its blue led permanently +- Hold the __mode__ button down until the led strip blinks blue once +- then the board is reset and should open up it's own wifi again After sucessfully entering the configuration mode, use your smartphone or computer to connect to the wireless network called "ESPLED config" and follow the screen. ## Control LEDs -There are three different interfaces to connect to the controller. -*MQTT -*REST API -*On-Board-Button +The README historically listed three interfaces (MQTT, REST API, On-Board-Button), but in the +current firmware only __MQTT__ is actually implemented for runtime control. The on-board "mode" +button (`pinM`) is only read once at boot to decide whether to enter configuration mode +(see [Configuration](#configuration)) — it is not wired up to change modes while running. No REST +API/webserver for LED control exists in the code. ### MQTT The different functions of the controller are available under the following MQTT topic: -*/ESPLED/FF:FF:FF:FF:FF:FF* +*/ESPLED/FF:FF:FF:FF:FF:FF* (replace with MAC of your device, visible in the setup routine) The payload contais json in the following format: @@ -44,12 +47,289 @@ The payload contais json in the following format: } ``` -| Function | Topic | Values | -|------------------------------------------|--------------------------|--------------------| -| Change Mode | mode | 0 to 200 | -| Color amplitudes | color[ R,G,B,(CW),(WW) ] | 10 bit integer | -| Timeframe in milliseconds(ex. fade time) | time | unsigned integer | -| Frequency in hertz | frequency | float | -| Relativ sensitivity | sensitivity | float (0.0 to 1.0) | -| Number of leds (mode specific) | length | unsigned integer | -| Select start led (mode specific) | position | unsigned integer | +Any subset of fields can be sent — only keys present in the JSON payload overwrite the +corresponding value; everything else keeps its last value. + +Addressable strips also accept a second, binary payload on a dedicated sub-topic +(`*/ESPLED/FF:FF:FF:FF:FF:FF/pixels*`) for the `PIXEL_ARRAY` mode — see mode 1000 in the +[Modes](#modes) table below. + +| Function | Topic | Values | Used by modes | +|--------------------------------------------|--------------------------|---------------------|---------------| +| Change Mode | mode | see [Modes](#modes) below | all | +| Color amplitudes | color[ R,G,B,WW,CW ] | 10 bit integer (0-1023) | 0, 1, 2, 7, 50, 51, 100, 102, 104, 105, 106, 108, 109, 110, 111, 113, 500, 501, 502, 1001 | +| Timeframe in milliseconds (speed, mode specific) | time | unsigned integer | 0 (fade), 2 (period), 3/4/5 (gradient cycle), 100 (traversal), 105 (traversal), 106 (round trip), 107 (hue cycle), 108 (step interval), 109 (traversal), 110 (round trip), 502 (windowing duration), 1001 (fade legs) | +| Frequency in hertz | frequency | unsigned integer, 1-60 (0 also valid on 113 - see note) | 1, 6, 7 (beat speed - S1/gap/S2 sequence takes 1000/frequency ms), 50, 51, 101, 102, 103, 104, 111, 112, 113 (blink rate, 0=no blink), 500, 501, 502 | +| Relative sensitivity / gain / trail fade | sensitivity | __unsigned char (0-255)__ — see note | 50, 51, 109 (trail fade-per-step amount, 0=none), 110 (trail fade-per-step amount, 0=none), 500, 501, 502 (gain on top of auto peak-normalization) | +| Number of LEDs / max count / repeat count / gap / percentage (mode specific) | length | unsigned integer | 104 (max concurrent stars), 105 (block size), 106 (block size), 108 (lit segment length, 0=default of 1), 109/110 (trail gap, 0-9, 0=no gaps), 111 (block size), 112 (section length), 113 (percentage lit, 0-100), 502 (number of bins), 1001 (repeat count, ≤1=single pulse) | +| Select side/anchor / spacing (mode specific) | position | unsigned integer | 101 (`0`=start of strip, non-zero=end of strip), 108 (spacing between repeats, 0=default of 3), 113 (`0`=left, else=right), 500/501 (`0`=left, `1`=right, else=center) | +| Lower brightness bound | minBrightnes | unsigned integer (0-1023) | 2, 7, 50, 104 | +| Upper brightness bound / overall dim (mode specific) | maxBrightnes | unsigned integer (0-1023) | 2, 3, 4, 5, 7, 50, 101, 102, 103, 104 (0=unset/full brightness on 3/4/5/101/102/103) | +| Random jitter on `time` / regime-switch period (mode specific) | timeVariance | unsigned integer (ms) | 2, 101 (0=disabled; cooling-regime switch period) | +| Hold time at each extreme / pause between beats (mode specific) | duration | unsigned integer (ms) | 7 (diastole pause between beats), 1001 (hold at full color / hold off) | +| Random jitter on `maxBrightnes` | maxBrightnesVariance | unsigned integer | 2 | + +> __Note on `sensitivity`:__ `varSilo.sensitivity` is declared as `unsigned char` +> ([TypeDefinitions.h](/lib/Definitions/TypeDefinitions.h)) and the MQTT callback assigns the JSON +> value to it directly, so send a whole number 0-255, not a fraction. + +> __Note on `length` / `position`:__ these are *not* generic strip-geometry controls that every mode +> understands — each mode that reads them assigns its own meaning (see the table above and the +> per-mode descriptions below). Modes that don't list `length`/`position` ignore them entirely. + +> __Note on `frequency`:__ every mode that reads it rejects `0` as invalid *except* `PROGRESS_BAR` +> (113), where `0` is a deliberate, meaningful value ("don't blink the leading pixel") rather than a +> rejected input. + +### Modes +Which modes are available depends on the strip type selected during [configuration](#configuration): + +- __Uniform strips__ (`RGB`, `RGBW`, `RGBWW`) are driven by `AnimationHandlerPWM` and only support the + *uniform color* modes (0-99, shared between both strip types). Sending an addressable-only mode + (100+) is rejected (logged as an error, outputs black). +- __Addressable strips__ (`WS2812`, `APA102`) are driven by `AnimationHandlerBus` and support both the + uniform modes (rendered by filling the whole strip with one calculated color) and the addressable + modes (100+, which control individual pixels). + +Mode numbers are grouped by band rather than assigned sequentially: `0-49` core shared modes, +`50-99` sound-reactive shared modes, `100-499` normal addressable-only modes, `500-599` +sound-reactive addressable-only modes, `1000+` special modes that bypass the normal +`color`/`time`/etc. parameter model entirely (`PIXEL_ARRAY`, `BLINK_COLOR`). + +The table below embeds the full JSON for every mode directly in its row (as an HTML table, so the +formatting survives) — one scrollable reference, nothing to jump to further down the page. The +trade-off: GitHub's one-click copy icon only attaches to fenced ``` code blocks, and those can't +live inside a table cell, so these examples aren't individually copy-buttoned — select-and-copy +still works fine. `PIXEL_ARRAY` (1000) is the one exception with a real fenced/copyable snippet, +right after the table, since its actual pixel data is a separate binary payload that doesn't fit +a single JSON example. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#ModeStrip supportParameters usedExampleDescription
0FADE_2_COLORuniform + addressablecolor, time
{
+  "mode": 0,
+  "color": [1023, 400, 60, 0, 0],
+  "time": 2000
+}
Linearly fades every channel from the previous color to color over time ms, then holds. A time of 0 gives an instant color jump.
1STROBEuniform + addressablecolor, frequency
{
+  "mode": 1,
+  "color": [1023, 0, 850, 0, 0],
+  "frequency": 10
+}
Square-wave toggles the strip between off and color at frequency Hz. frequency of 0 or >60 is rejected.
2BREATHEuniform + addressablecolor, time, minBrightnes, maxBrightnes, timeVariance, maxBrightnesVariance
{
+  "mode": 2,
+  "color": [550, 0, 1023, 0, 0],
+  "time": 4000,
+  "minBrightnes": 100,
+  "maxBrightnes": 900,
+  "timeVariance": 500,
+  "maxBrightnesVariance": 100
+}
Cosine "breathing" brightness oscillation of color between minBrightnes and maxBrightnes with period time, jittered each cycle by ±timeVariancemaxBrightnesVariance.
3OCEANuniform + addressabletime, maxBrightnes
{
+  "mode": 3,
+  "time": 6000,
+  "maxBrightnes": 700
+}
Fixed gradient loop, no color input: Deep Blue → Teal → Cyan → repeat, one full loop every time ms. maxBrightnes (0-1023, 0=unset/full) dims the whole gradient.
4FORESTuniform + addressabletime, maxBrightnes
{
+  "mode": 4,
+  "time": 6000,
+  "maxBrightnes": 700
+}
Fixed gradient loop, no color input: Emerald Green → Soft Yellow-Green → Deep Green → repeat. maxBrightnes (0-1023, 0=unset/full) dims the whole gradient.
5DUSKuniform + addressabletime, maxBrightnes
{
+  "mode": 5,
+  "time": 6000,
+  "maxBrightnes": 700
+}
Fixed gradient loop, no color input: Deep Purple → Magenta → Warm Orange → repeat. maxBrightnes (0-1023, 0=unset/full) dims the whole gradient.
6FIRE_FLICKERuniform + addressablefrequency
{
+  "mode": 6,
+  "frequency": 15
+}
Whole-strip flame flicker across a fixed red/orange/yellow palette, no color input. frequency of 0 or >60 is rejected.
7HEARTBEATuniform + addressablecolor, minBrightnes, maxBrightnes, frequency, duration
{
+  "mode": 7,
+  "color": [1023, 0, 60, 0, 0],
+  "minBrightnes": 50,
+  "maxBrightnes": 1000,
+  "frequency": 1,
+  "duration": 700
+}
Classic "lub-DUB" heartbeat brightness pulse: S1 ("lub" - louder/longer) rises to maxBrightnes and decays, a brief systole gap, then S2 ("dub" - sharper/shorter/quieter, peaks at ~60% of S1) rises and decays, followed by a diastole pause (duration ms) at minBrightnes before the next beat. frequency sets how fast the S1/gap/S2 sequence itself runs (1000/frequency ms total) - independent of duration, which only controls the quiet gap between beats.
50SOUND_2_LIGHTuniform + addressablecolor, minBrightnes, maxBrightnes, sensitivity, frequency
{
+  "mode": 50,
+  "color": [0, 900, 700, 0, 0],
+  "minBrightnes": 50,
+  "maxBrightnes": 1000,
+  "sensitivity": 40,
+  "frequency": 20
+}
Same rectified-amplitude envelope follower as VU_METER (500), but instead of a bar graph it scales the whole strip/color's brightness between minBrightnes (silence) and maxBrightnes (loudest). sensitivity is the gain, frequency the update rate.
51BASS_REACTuniform + addressablecolor, sensitivity, frequency
{
+  "mode": 51,
+  "color": [900, 50, 0, 0, 0],
+  "sensitivity": 180,
+  "frequency": 20
+}
Scales color's brightness by a low-frequency/bass envelope extracted from the mic (one-pole low-pass over the audio buffer, not a full FFT). sensitivity is the gain, frequency the update rate.
100WIPEaddressable onlycolor, time
{
+  "mode": 100,
+  "color": [50, 1023, 150, 0, 0],
+  "time": 12000
+}
Lights the strip LED-by-LED from index 0 to the end over time ms total, then holds color.
101FIREaddressable onlyfrequency, position, timeVariance, maxBrightnes
{
+  "mode": 101,
+  "frequency": 30,
+  "position": 0,
+  "timeVariance": 3000,
+  "maxBrightnes": 600
+}
Directional flame anchored at one end of the strip — position: 0 = start, non-zero = end. Occasional blue tint near the base and dim smoke near the tip on top of the usual heat-color ramp. No color input. timeVariance (ms, 0=off) is how often the fire switches between a tall/persistent-flame cooling regime and a short one - smaller values switch more often. maxBrightnes (0-1023, 0 treated as unset/full) dims the whole flame.
102NOISEaddressable onlycolor, frequency, maxBrightnes
{
+  "mode": 102,
+  "color": [200, 220, 1023, 0, 0],
+  "frequency": 20,
+  "maxBrightnes": 500
+}
Randomly (50/50 per LED, per refresh) sets each LED to color or off, refreshed at frequency Hz. maxBrightnes (0-1023, 0=unset/full) caps the overall brightness.
103NOISE_RANDOM_COLORaddressable onlyfrequency, maxBrightnes
{
+  "mode": 103,
+  "frequency": 20
+}
Same 50/50-per-LED sparkle as NOISE, but there's no color input - every lit LED independently picks its own random color each refresh. maxBrightnes caps the overall brightness, same as NOISE.
104NIGHT_SKYaddressable onlycolor, length, frequency, minBrightnes, maxBrightnes
{
+  "mode": 104,
+  "color": [0, 700, 1023, 0, 0],
+  "length": 6,
+  "frequency": 20,
+  "minBrightnes": 100,
+  "maxBrightnes": 800
+}
Up to length stars twinkle at once in color (each star's brightness scales that color, rather than a fixed tint), refreshed at frequency Hz, each spawned with a random brightness between minBrightnes and maxBrightnes.
105MOVING_PARTSaddressable onlycolor, length, time
{
+  "mode": 105,
+  "color": [0, 1023, 0, 0, 0],
+  "length": 8,
+  "time": 2000
+}
A solid length-LED block sweeps the strip once every time ms, wrapping around at the end.
106MOVING_BACK_AND_FORTHaddressable onlycolor, length, time
{
+  "mode": 106,
+  "color": [0, 1023, 0, 0, 0],
+  "length": 8,
+  "time": 2000
+}
Same solid length-LED block as MOVING_PARTS, but it reflects at each end instead of wrapping - a back-and-forth/ping-pong sweep. time is the ms for one full round trip (there and back).
107RAINBOW_CYCLEaddressable onlytime
{
+  "mode": 107,
+  "time": 4000
+}
Continuous hue-shift across the whole strip; one full rainbow cycle every time ms.
108THEATER_CHASEaddressable onlycolor, time, length, position
{
+  "mode": 108,
+  "color": [1023, 1023, 1023, 0, 0],
+  "time": 100,
+  "length": 2,
+  "position": 5
+}
Classic marquee/theater chase: a segment of length lit pixels (default 1) repeats every position pixels (spacing between repeats, default 3 - the original fixed look), shifting one step every time ms.
109COMETaddressable onlycolor, time, sensitivity, length
{
+  "mode": 109,
+  "color": [0, 600, 1023, 0, 0],
+  "time": 1500,
+  "sensitivity": 40
+}
Bright head pixel with a fading trail travels the strip once every time ms, wrapping at the end. sensitivity (0-255) is the trail's fade-per-step amount - 0 means no fade at all (a solid, fully continuous pack fills in behind the head), 255 is a very short trail. length (0-9, default 0) is the gap - how many pixels to skip between each trail step - 0 packs every pixel with no gaps, higher values space the trail into a sparser dotted look.
110COMET_BACK_AND_FORTHaddressable onlycolor, time, sensitivity, length
{
+  "mode": 110,
+  "color": [0, 600, 1023, 0, 0],
+  "time": 1500,
+  "sensitivity": 40
+}
Same head + fading trail as COMET, but it reflects at each end instead of wrapping (the trail flips to the other side of the head when it reverses). time is the ms for one full round trip; sensitivity and length are the same trail-fade and gap knobs as COMET.
111STROBE_PARTSaddressable onlycolor, length, frequency
{
+  "mode": 111,
+  "color": [1023, 0, 0, 0, 0],
+  "length": 5,
+  "frequency": 4
+}
Strip divided into length-LED blocks; all blocks invert together (checkerboard flip) at frequency Hz.
112POLICE_LIGHTSaddressable onlylength, frequency
{
+  "mode": 112,
+  "length": 8,
+  "frequency": 2
+}
Classic police-light-bar "wig-wag" flash: the strip is divided into alternating red/blue length-pixel sections separated by 4px gaps (leftover pixels get spread evenly across the gaps first, then any remainder split across a start/end margin, with a final odd pixel going to the start). No color input - red/blue are fixed. At any instant either all red sections are lit and all blue sections are dark, or the reverse, swapping at frequency Hz.
113PROGRESS_BARaddressable onlycolor, length, position, frequency
{
+  "mode": 113,
+  "color": [0, 1023, 300, 0, 0],
+  "length": 60,
+  "position": 0,
+  "frequency": 2
+}
Simple progress bar: length (0-100, a percentage) sets what fraction of the strip is lit in color. position picks which end it grows from: 0 = left/start, anything else = right/end. The leading pixel (the bar's current front edge) blinks white at frequency Hz to draw the eye to it; frequency of 0 disables the blink (leading pixel just shows the bar's normal color, solid) rather than being rejected.
500VU_METERaddressable onlycolor, sensitivity, frequency, position
{
+  "mode": 500,
+  "color": [1023, 250, 0, 0, 0],
+  "sensitivity": 40,
+  "frequency": 30,
+  "position": 2
+}
Audio-reactive bar-graph based on the mic's raw peak-to-peak amplitude (deliberately not relative to any tracked silence bias - see DEVNOTES.md). sensitivity is the only gain/amplification knob, frequency the smoothing/refresh rate. position picks the anchor: 0 = grows from the start (left), 1 = grows from the end (right), anything else = grows symmetrically outward from the center.
501VU_METER_PEAKaddressable onlycolor, sensitivity, frequency, position
{
+  "mode": 501,
+  "color": [0, 900, 100, 0, 0],
+  "sensitivity": 40,
+  "frequency": 30,
+  "position": 2
+}
Same bar-graph and position anchor as VU_METER, plus a white peak-hold marker that jumps to the leading edge of the highest recent level and slowly falls back down - classic VU-meter peak-hold behavior. For the center anchor (position anything other than 0/1) the bar grows both ways, so there are two symmetric peak markers instead of one.
502SPECTRUMaddressable onlycolor, time, frequency, sensitivity, length
{
+  "mode": 502,
+  "color": [700, 0, 1023, 0, 0],
+  "time": 1024,
+  "frequency": 20,
+  "sensitivity": 200,
+  "length": 20
+}
FFT spectrum visualizer: samples the mic, runs an FFT over a time-ms window, and splits the result into length bins spread evenly across the strip (each bin a contiguous run of LEDs with a 2px gap to the next, see binWidthFor()). Each bin's brightness reflects that frequency range's intensity (auto peak-normalized, then scaled by sensitivity as a gain); color sets the hue. frequency throttles how often the spectrum redraws, independent of the sampling rate.
1000PIXEL_ARRAYaddressable only— (see below)
{ "mode": 1000 }
Renders whatever per-LED RGB array was last delivered via the binary /pixels sub-topic — not controlled by color/time/etc. See below.
1001BLINK_COLORuniform + addressablecolor, time, duration, length
{
+  "mode": 1001,
+  "color": [1023, 0, 0, 0, 0],
+  "time": 800,
+  "duration": 600,
+  "length": 4
+}
Notification-blink overlay: pauses whatever mode/color was running, fades up to color over time ms, holds for duration ms, fades back down over time ms, holds off for duration ms, repeating length times (length ≤ 1 gives a single pulse - fade up, hold, done, skipping the fade-down/hold-off legs). Once finished, hands control straight back to whatever was running before, picking up exactly where it left off (not restarted, not jumped ahead by however long the blink ran) - see DEVNOTES.md for how. A second mode: 1001 message while one is already running is ignored rather than restarting the sequence.
+ +#### 1000 — PIXEL_ARRAY, sending the actual pixel data +Switch into the mode with the JSON message from the table above on the main topic, then publish +the actual per-LED colors as a raw binary payload — 3 bytes per LED (`R,G,B`, each 0-255, no +WW/CW), in strip order, on `/pixels`. The payload length must be exactly +`stripLength * 3` bytes or it's rejected outright (logged as an error, strip unchanged) rather than +partially applied. Example for a 60-LED strip, lighting only LEDs 10-14 red and leaving the rest +off, using Python to build the payload and `mosquitto_pub` to send it: +```python +strip_length = 60 +payload = bytearray(strip_length * 3) +for led in range(10, 15): + payload[led * 3 : led * 3 + 3] = bytes([255, 0, 0]) # R, G, B + +with open("pixels.bin", "wb") as f: + f.write(payload) +``` +```sh +mosquitto_pub -h -t "/ESPLED//pixels" -f pixels.bin +``` diff --git a/software/config/board_config.h b/software/config/board_config.h new file mode 100644 index 0000000..458aa10 --- /dev/null +++ b/software/config/board_config.h @@ -0,0 +1,18 @@ +#ifndef BOARD_CONFIG_H +#define BOARD_CONFIG_H + +// Hardware pin wiring for the ESPLED board itself (ESP12E/ESP8266 GPIOs). +// These are fixed by the PCB/wiring, not user-tunable - do not edit unless +// you're building different hardware. Secrets/network settings live in +// user_config.h instead. +#define pinR 5 +#define pinG 16 +#define pinB 14 +#define pinWW 12 +#define pinCW 13 +#define pinM 2 +#define pinData 5 // Addressable strip data line (WS2812/APA102), shares GPIO with pinR since only one strip type is active at a time +#define pinClock 3 // APA102 clock line only +#define pinTemp 4 // DS18B20 one-wire data (U4 pin2 -> U2 pin13 per hardware/ESPLED.net); 4.7K pull-up to 3.3V (R8) already on the PCB, normal (non-parasitic) power mode + +#endif diff --git a/software/config/device_config.h.example b/software/config/device_config.h.example new file mode 100644 index 0000000..013ad25 --- /dev/null +++ b/software/config/device_config.h.example @@ -0,0 +1,65 @@ +/* + * device_config.h.example — template for software/config/device_config.h + * + * This project splits build-time configuration into two files under config/: + * - board_config.h Fixed hardware pin wiring for the ESPLED board itself. + * Safe to commit; only change it if you're building + * different hardware. + * - device_config.h Your WiFi/MQTT credentials, network topic, TLS + * fingerprint, and which strip you've wired up. + * Git-ignored — never commit this file, it holds + * secrets. + * + * (Deliberately not named "user_config.h": the ESP8266 NONOS SDK's own + * osapi.h does `#include "user_config.h"` to pull in a framework-provided + * SDK-level config header, so a project file with that exact name would + * shadow it and break the build.) + * + * Setup: copy this file to device_config.h (same folder) and fill in your + * real values below. If device_config.h is missing, the build fails with + * an error pointing back here (see the __has_include check in src/main.cpp). + */ +#pragma once + +#include + +#define WIFI_SSID "YourNetwork" +#define WIFI_PASSWORD "YourPassword" + +#define OTA_PASSWORD "YourOTAPassword" + + +// Password required to push firmware over WiFi (see upload_protocol = espota +// in platformio.ini). Required by ArduinoOTA.setPassword() - choose your own. +#define OTA_PASSWORD "changeme" + +#define MQTT_SERVER "" +#define MQTT_PORT "8080" +#define MQTT_USERNAME "esp" +#define MQTT_PASSWORD "supersecurepassword" +#define MQTT_SSL "no" + +// Base MQTT topic this device publishes/subscribes under (its MAC address +// is appended at runtime, e.g. "/ESPLED/AA:BB:CC:DD:EE:FF"). +#define MQTT_TOPIC_PREFIX "/ESPLED/" + +// SHA-1 fingerprint of your MQTT broker's TLS certificate, only used when +// MQTT_SSL is "yes". WiFiClientSecure::setFingerprint() pins this exact +// certificate rather than validating a CA chain, so it must be regenerated +// whenever the broker's certificate is renewed/rotated. +// +// To generate it from a server.crt you already have: +// openssl x509 -in server.crt -noout -fingerprint -sha1 +// Or, to fetch the cert directly from a running broker first: +// openssl s_client -connect your.broker.example:8883 -showcerts /dev/null \ +// | openssl x509 -outform PEM > server.crt +// Either way this prints something like: +// SHA1 Fingerprint=A6:AE:85:65:63:DD:D8:7C:70:F7:92:73:DE:8F:18:2B:9F:DA:0A:76 +// Strip the "SHA1 Fingerprint=" prefix and replace the colons with spaces: +#define MQTT_SSL_FINGERPRINT "A6 AE 85 65 63 DD D8 7C 70 F7 92 73 DE 8F 18 2B 9F DA 0A 76" + +// One of: RGB_STRIP, RGBW_STRIP, RGBWW_STRIP, WS2812_STRIP, APA102_STRIP +#define STRIP_TYPE RGB_STRIP + +// Number of LEDs on the strip +#define STRIP_LENGTH 60 diff --git a/software/include/README b/software/include/README deleted file mode 100644 index 194dcd4..0000000 --- a/software/include/README +++ /dev/null @@ -1,39 +0,0 @@ - -This directory is intended for project header files. - -A header file is a file containing C declarations and macro definitions -to be shared between several project source files. You request the use of a -header file in your project source file (C, C++, etc) located in `src` folder -by including it, with the C preprocessing directive `#include'. - -```src/main.c - -#include "header.h" - -int main (void) -{ - ... -} -``` - -Including a header file produces the same results as copying the header file -into each source file that needs it. Such copying would be time-consuming -and error-prone. With a header file, the related declarations appear -in only one place. If they need to be changed, they can be changed in one -place, and programs that include the header file will automatically use the -new version when next recompiled. The header file eliminates the labor of -finding and changing all the copies as well as the risk that a failure to -find one copy will result in inconsistencies within a program. - -In C, the usual convention is to give header files names that end with `.h'. -It is most portable to use only letters, digits, dashes, and underscores in -header file names, and at most one dot. - -Read more about using header files in official GCC documentation: - -* Include Syntax -* Include Operation -* Once-Only Headers -* Computed Includes - -https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/software/lib/AnimationHandler/AnimationHandler.cpp b/software/lib/AnimationHandler/AnimationHandler.cpp index cb9cd47..e30ef07 100644 --- a/software/lib/AnimationHandler/AnimationHandler.cpp +++ b/software/lib/AnimationHandler/AnimationHandler.cpp @@ -1,4 +1,5 @@ #include +#include AnimationHandler::AnimationHandler(varSilo* silo, bool* varSiloChanged){ this->silo = silo; @@ -76,6 +77,52 @@ void AnimationHandler::hsv2rgb(unsigned int hsvContainer[], CRGBWW &rgbContainer } void AnimationHandler::recordAudioSample(){ - audioRingBuffer[ringBufferCounter++] = analogRead(A0)/1024.0; + float sample = analogRead(A0)/1024.0; + audioRingBuffer[ringBufferCounter++] = sample; if(ringBufferCounter == ringBufferSize) ringBufferCounter = 0; + + // Periodically recalibrates audioBias from the ring buffer instead of + // nudging it toward every single incoming sample - a plain per-sample + // moving average has no protection against a loud/sustained sound + // dragging the bias toward itself, which then makes every + // audio-reactive mode measure a large deviation-from-bias (i.e. read + // "loud") forever afterward, even in silence, until the bias slowly + // drifts back. ModeMath::recalibrateBias() only accepts a window if + // it actually looks quiet right now; a loud window is simply skipped + // and retried at the next interval, so audioBias just holds at its + // last known-good value for as long as it stays loud. + const unsigned long CALIBRATION_INTERVAL_MS = 1000; + const float CALIBRATION_MAX_SPREAD = 0.05f; + + static unsigned long lastCalibration = 0; + unsigned long now = millis(); + if(now - lastCalibration >= CALIBRATION_INTERVAL_MS){ + lastCalibration = now; + audioBias = recalibrateBias(audioRingBuffer, ringBufferSize, audioBias, CALIBRATION_MAX_SPREAD); + } + } + +void AnimationHandler::snapshotSilo(){ + savedSilo = *silo; + savedLastChange = lastChange; +} + +CRGBWW AnimationHandler::cycleColors(const CRGBWW colors[], unsigned int count){ + if(silo->time == 0){ + debugFkt("Time was set to 0, this is not possible", ERROR); + return CRGBWW{0,0,0,0,0}; } + + CRGBWW color = interpolateCycle(colors, count, millis() - lastChange, silo->time); + + float brightnessScale = brightnessScaleFor(silo->maxBrightnes); + if(brightnessScale < 1.0f){ + color.R = (unsigned int)(color.R * brightnessScale); + color.G = (unsigned int)(color.G * brightnessScale); + color.B = (unsigned int)(color.B * brightnessScale); + color.WW = (unsigned int)(color.WW * brightnessScale); + color.CW = (unsigned int)(color.CW * brightnessScale); + } + + return color; +} diff --git a/software/lib/AnimationHandler/AnimationHandler.h b/software/lib/AnimationHandler/AnimationHandler.h index b3f3e1a..22a3720 100644 --- a/software/lib/AnimationHandler/AnimationHandler.h +++ b/software/lib/AnimationHandler/AnimationHandler.h @@ -3,12 +3,20 @@ #include #include -#include +#include #define ringBufferSize 64 extern void debugFkt(String, LogLevel); +// Shared base class for the "uniform color" strip modes (Mode 0-99): each +// mode method below computes a single CRGBWW value per frame from the +// current silo settings. Both AnimationHandlerPWM (PWM-driven strips) and +// AnimationHandlerBus (addressable strips, which also add their own +// per-pixel modes on top) inherit from this. Holds the timing state shared +// across modes, the audio sample ring buffer for the audio-reactive modes, +// and RGB/HSV conversion helpers. Each mode's implementation lives in its +// own file under Modes/. class AnimationHandler{ public: AnimationHandler(varSilo* silo, bool* varSiloChanged); @@ -18,12 +26,26 @@ class AnimationHandler{ CRGBWW sound2Light(); CRGBWW strobe(); CRGBWW breathe(); + CRGBWW ocean(); + CRGBWW forest(); + CRGBWW dusk(); + CRGBWW fireFlicker(); + CRGBWW bassReact(); + CRGBWW heartBeat(); void rgb2hsv(CRGBWW &rgbContainer, unsigned int hsvContainer[]); void hsv2rgb(unsigned int hsvContainer[], CRGBWW &rgbContainer); void recordAudioSample(); + // Snapshots the live *silo (params) and lastChange (timing) into + // savedSilo/savedLastChange, so a later mode can restore both exactly. + // Used by BLINK_COLOR to remember what to resume once its notification + // sequence finishes. Must be called by whoever is about to overwrite + // *silo's fields (main.cpp's MQTT callback) - by the time a mode method + // runs, *silo already holds the new mode's own parameters. + void snapshotSilo(); + protected: varSilo* silo; bool* varSiloChanged; @@ -36,8 +58,25 @@ class AnimationHandler{ float audioRingBuffer[ringBufferSize]; unsigned int ringBufferCounter = 0; + // Slow-moving estimate of the mic's silence/DC bias point (a MAX4466 + // centers its output around VCC/2, i.e. ~0.5 on this 0-1 scale, but the + // exact point drifts with its onboard trimmer and component tolerance). + // Periodically recalibrated in recordAudioSample() (see + // ModeMath::recalibrateBias()); audio-reactive modes should measure + // deviation from this instead of a hardcoded 0.5. + float audioBias = 0.5f; + unsigned int UPDATE_TIME = 16; + // Shared helper for ocean()/forest()/dusk(): loops through the given + // keyframe colors over silo->time ms, linearly interpolating between + // them, then scales the result by silo->maxBrightnes (0=unset/full). + CRGBWW cycleColors(const CRGBWW colors[], unsigned int count); + + // Populated by snapshotSilo() - see its declaration above. + varSilo savedSilo{}; + long savedLastChange = 0; + }; #endif diff --git a/software/lib/AnimationHandler/Modes/fade2Color.cpp b/software/lib/AnimationHandler/Modes/0000_fade2Color.cpp similarity index 100% rename from software/lib/AnimationHandler/Modes/fade2Color.cpp rename to software/lib/AnimationHandler/Modes/0000_fade2Color.cpp diff --git a/software/lib/AnimationHandler/Modes/strobe.cpp b/software/lib/AnimationHandler/Modes/0001_strobe.cpp similarity index 94% rename from software/lib/AnimationHandler/Modes/strobe.cpp rename to software/lib/AnimationHandler/Modes/0001_strobe.cpp index 6765002..4d07137 100644 --- a/software/lib/AnimationHandler/Modes/strobe.cpp +++ b/software/lib/AnimationHandler/Modes/0001_strobe.cpp @@ -1,9 +1,10 @@ #include +#include CRGBWW AnimationHandler::strobe(){ debugFkt("Called strobe mode function", VERBOSE); - if(this->silo->frequency == 0 || this->silo->frequency > 60){ + if(!isValidRateHz(this->silo->frequency)){ debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); return CRGBWW{0,0,0,0,0}; } diff --git a/software/lib/AnimationHandler/Modes/breathe.cpp b/software/lib/AnimationHandler/Modes/0002_breathe.cpp similarity index 100% rename from software/lib/AnimationHandler/Modes/breathe.cpp rename to software/lib/AnimationHandler/Modes/0002_breathe.cpp diff --git a/software/lib/AnimationHandler/Modes/0003_ocean.cpp b/software/lib/AnimationHandler/Modes/0003_ocean.cpp new file mode 100644 index 0000000..c01a5db --- /dev/null +++ b/software/lib/AnimationHandler/Modes/0003_ocean.cpp @@ -0,0 +1,13 @@ +#include + +// Continuous gradient loop, no color input needed - silo->time (speed) and +// silo->maxBrightnes (overall dim, 0=unset/full) are tunable. Deep Blue -> +// Teal -> Cyan -> repeat. +CRGBWW AnimationHandler::ocean(){ + static const CRGBWW colors[3] = { + {0, 10, 500, 0, 0}, // Deep Blue + {0, 350, 350, 0, 0}, // Teal + {0, 700, 800, 0, 0} // Cyan + }; + return cycleColors(colors, 3); +} diff --git a/software/lib/AnimationHandler/Modes/0004_forest.cpp b/software/lib/AnimationHandler/Modes/0004_forest.cpp new file mode 100644 index 0000000..9382f10 --- /dev/null +++ b/software/lib/AnimationHandler/Modes/0004_forest.cpp @@ -0,0 +1,13 @@ +#include + +// Continuous gradient loop, no color input needed - silo->time (speed) and +// silo->maxBrightnes (overall dim, 0=unset/full) are tunable. Emerald Green +// -> Soft Yellow-Green -> Deep Green -> repeat. +CRGBWW AnimationHandler::forest(){ + static const CRGBWW colors[3] = { + {0, 600, 250, 0, 0}, // Emerald Green + {350, 650, 80, 0, 0}, // Soft Yellow-Green + {0, 250, 40, 0, 0} // Deep Green + }; + return cycleColors(colors, 3); +} diff --git a/software/lib/AnimationHandler/Modes/0005_dusk.cpp b/software/lib/AnimationHandler/Modes/0005_dusk.cpp new file mode 100644 index 0000000..5dbd481 --- /dev/null +++ b/software/lib/AnimationHandler/Modes/0005_dusk.cpp @@ -0,0 +1,13 @@ +#include + +// Continuous gradient loop, no color input needed - silo->time (speed) and +// silo->maxBrightnes (overall dim, 0=unset/full) are tunable. Deep Purple -> +// Magenta -> Warm Orange -> repeat. +CRGBWW AnimationHandler::dusk(){ + static const CRGBWW colors[3] = { + {250, 0, 450, 0, 0}, // Deep Purple + {650, 0, 500, 0, 0}, // Magenta + {800, 250, 0, 0, 0} // Warm Orange + }; + return cycleColors(colors, 3); +} diff --git a/software/lib/AnimationHandler/Modes/0006_fireFlicker.cpp b/software/lib/AnimationHandler/Modes/0006_fireFlicker.cpp new file mode 100644 index 0000000..dbc599e --- /dev/null +++ b/software/lib/AnimationHandler/Modes/0006_fireFlicker.cpp @@ -0,0 +1,42 @@ +#include +#include +#include + +// Whole-strip fire flicker (uniform color, shared by PWM and Bus). No color +// input needed - only silo->frequency (flicker rate) is tunable. Smoothly +// lerps toward a newly-picked random fire-hued target each pick, for a +// natural flicker instead of a hard strobe. lerpAmount is deliberately slow +// relative to how often a new target gets picked (at high frequency, only +// a handful of handle() ticks separate one pick from the next) - a faster +// lerp doesn't have time to reach each target before the next one is +// picked, so it visibly steps between palette entries instead of blending, +// which is what made this feel strobe-like before. +CRGBWW AnimationHandler::fireFlicker(){ + if(!isValidRateHz(silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return CRGBWW{0,0,0,0,0}; + } + + static const CRGBWW palette[4] = { + {700, 20, 0, 0, 0}, + {900, 300, 0, 0, 0}, + {950, 500, 50, 0, 0}, + {1000, 650, 100, 0, 0} + }; + static CRGBWW target = palette[0]; + static CRGBWW current = palette[0]; + static unsigned long lastPick = 0; + + unsigned long now = millis(); + long period = 1000 / silo->frequency; + if(now >= lastPick + period){ + lastPick = now; + target = palette[random8(0, 4)]; + } + + float lerpAmount = 0.04f; // smoothing factor per handle() tick, not per flicker-pick + current.R += (int)(target.R - current.R) * lerpAmount; + current.G += (int)(target.G - current.G) * lerpAmount; + current.B += (int)(target.B - current.B) * lerpAmount; + return current; +} diff --git a/software/lib/AnimationHandler/Modes/0007_heartBeat.cpp b/software/lib/AnimationHandler/Modes/0007_heartBeat.cpp new file mode 100644 index 0000000..5f866f0 --- /dev/null +++ b/software/lib/AnimationHandler/Modes/0007_heartBeat.cpp @@ -0,0 +1,103 @@ +#include +#include + +// Heartbeat brightness pulse: the classic "lub-DUB". S1 ("lub" - louder and +// slightly longer) rises to maxBrightnes and decays, a brief systole gap, +// then S2 ("dub" - sharper, shorter, quieter) rises to a fraction of +// maxBrightnes and decays, followed by a diastole pause (silo->duration ms) +// at minBrightnes before the next beat. silo->frequency (1-60Hz) sets how +// fast the S1/gap/S2 sequence itself runs (1000/frequency ms total) - same +// Hz-to-period convention used elsewhere (STROBE, SPECTRUM's redraw rate) - +// independent of silo->duration, which only controls the quiet gap between +// beats. +enum class HeartbeatState { S1_RISE, S1_FALL, GAP, S2_RISE, S2_FALL, PAUSE }; + +CRGBWW AnimationHandler::heartBeat(){ + const float S2_PEAK_RATIO = 0.6f; // S2 ("dub") peaks quieter than S1 ("lub") + + if(!isValidRateHz(silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60, this is not possible", ERROR); + return CRGBWW{0,0,0,0,0}; + } + if(silo->duration == 0){ + debugFkt("Duration was set to 0, this is not possible", ERROR); + return CRGBWW{0,0,0,0,0}; + } + if(silo->maxBrightnes <= silo->minBrightnes){ + debugFkt("maxBrightnes must be greater than minBrightnes", ERROR); + return CRGBWW{0,0,0,0,0}; + } + + static HeartbeatState state = HeartbeatState::S1_RISE; + static long stateTimer = 0; + + long now = millis(); + + if(now < lastChange + (long)UPDATE_TIME){ + state = HeartbeatState::S1_RISE; + stateTimer = now; + } + + unsigned int beatDuration = 1000 / silo->frequency; + unsigned int minB = silo->minBrightnes; + unsigned int maxB = silo->maxBrightnes; + unsigned int peak2 = minB + (unsigned int)((maxB - minB) * S2_PEAK_RATIO); + + // Proportions of beatDuration - S1 (rise+fall) runs slightly longer than + // S2 (rise+fall), with a systole gap between them. Sums to 1.0. + unsigned int riseTime1 = (unsigned int)(beatDuration * 0.10f); + unsigned int fallTime1 = (unsigned int)(beatDuration * 0.22f); + unsigned int gapTime = (unsigned int)(beatDuration * 0.40f); + unsigned int riseTime2 = (unsigned int)(beatDuration * 0.10f); + unsigned int fallTime2 = (unsigned int)(beatDuration * 0.18f); + + unsigned int brightness = minB; + + switch(state){ + case HeartbeatState::S1_RISE: { + float t = (riseTime1 == 0) ? 1.0f : (float)(now - stateTimer) / riseTime1; + if(t >= 1.0f){ t = 1.0f; state = HeartbeatState::S1_FALL; stateTimer = now; } + brightness = minB + (unsigned int)((maxB - minB) * t); + break; + } + case HeartbeatState::S1_FALL: { + float t = (fallTime1 == 0) ? 1.0f : (float)(now - stateTimer) / fallTime1; + if(t >= 1.0f){ t = 1.0f; state = HeartbeatState::GAP; stateTimer = now; } + brightness = maxB - (unsigned int)((maxB - minB) * t); + break; + } + case HeartbeatState::GAP: { + if((unsigned long)(now - stateTimer) >= gapTime){ state = HeartbeatState::S2_RISE; stateTimer = now; } + brightness = minB; + break; + } + case HeartbeatState::S2_RISE: { + float t = (riseTime2 == 0) ? 1.0f : (float)(now - stateTimer) / riseTime2; + if(t >= 1.0f){ t = 1.0f; state = HeartbeatState::S2_FALL; stateTimer = now; } + brightness = minB + (unsigned int)((peak2 - minB) * t); + break; + } + case HeartbeatState::S2_FALL: { + float t = (fallTime2 == 0) ? 1.0f : (float)(now - stateTimer) / fallTime2; + if(t >= 1.0f){ t = 1.0f; state = HeartbeatState::PAUSE; stateTimer = now; } + brightness = peak2 - (unsigned int)((peak2 - minB) * t); + break; + } + case HeartbeatState::PAUSE: { + if((unsigned long)(now - stateTimer) >= silo->duration){ state = HeartbeatState::S1_RISE; stateTimer = now; } + brightness = minB; + break; + } + } + + float scaleFactor = brightness / 1023.0f; + + CRGBWW newColor; + newColor.R = silo->colorValue.R * scaleFactor; + newColor.G = silo->colorValue.G * scaleFactor; + newColor.B = silo->colorValue.B * scaleFactor; + newColor.WW = silo->colorValue.WW * scaleFactor; + newColor.CW = silo->colorValue.CW * scaleFactor; + + return newColor; +} diff --git a/software/lib/AnimationHandler/Modes/0050_sound2Light.cpp b/software/lib/AnimationHandler/Modes/0050_sound2Light.cpp new file mode 100644 index 0000000..f7a7e36 --- /dev/null +++ b/software/lib/AnimationHandler/Modes/0050_sound2Light.cpp @@ -0,0 +1,58 @@ +#include +#include + +// Overall-brightness audio-reactive mode: the same rectified-amplitude +// envelope follower VU_METER uses for its bar graph, but instead of lighting +// a growing bar it scales the whole strip/color's brightness between +// minBrightnes (silence) and maxBrightnes (loudest) - a shared uniform mode +// (PWM + Bus, the latter via fill_solid), so no separate addressable variant +// is needed. sensitivity is the gain, frequency the update rate. +CRGBWW AnimationHandler::sound2Light(){ + if(!isValidRateHz(silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return CRGBWW{0,0,0,0,0}; + } + if(silo->maxBrightnes <= silo->minBrightnes){ + debugFkt("maxBrightnes must be greater than minBrightnes", ERROR); + return CRGBWW{0,0,0,0,0}; + } + + unsigned long now = millis(); + static unsigned long lastStep = 0; + static float smoothedAmplitude = 0.0f; + + if(now < lastChange + UPDATE_TIME){ + smoothedAmplitude = 0.0f; + lastStep = 0; + } + + long period = 1000 / silo->frequency; + if(now >= lastStep + period){ + lastStep = now; + + float sumAbs = 0.0f; + for(unsigned int i = 0; i < ringBufferSize; i++){ + sumAbs += fabsf(audioRingBuffer[i] - audioBias); + } + float rawAmplitude = sumAbs / ringBufferSize; + float gain = 1.0f + (silo->sensitivity / 255.0f) * 4.0f; + float target = rawAmplitude * gain * 2.0f; + if(target > 1.0f) target = 1.0f; + if(target < 0.0f) target = 0.0f; + + // one-pole smoothing so brightness doesn't flicker every tick + smoothedAmplitude = smoothedAmplitude * 0.7f + target * 0.3f; + } + + unsigned int brightness = silo->minBrightnes + + (unsigned int)((silo->maxBrightnes - silo->minBrightnes) * smoothedAmplitude); + float scaleFactor = brightness / 1023.0f; + + CRGBWW newColor; + newColor.R = silo->colorValue.R * scaleFactor; + newColor.G = silo->colorValue.G * scaleFactor; + newColor.B = silo->colorValue.B * scaleFactor; + newColor.WW = silo->colorValue.WW * scaleFactor; + newColor.CW = silo->colorValue.CW * scaleFactor; + return newColor; +} diff --git a/software/lib/AnimationHandler/Modes/0051_bassReact.cpp b/software/lib/AnimationHandler/Modes/0051_bassReact.cpp new file mode 100644 index 0000000..f23eab0 --- /dev/null +++ b/software/lib/AnimationHandler/Modes/0051_bassReact.cpp @@ -0,0 +1,55 @@ +#include +#include + +// Low-frequency/bass envelope reactive brightness scaling of colorValue. +// Shared uniform mode (PWM + Bus). Uses a one-pole low-pass filter over the +// raw audio samples (not a full FFT band-split) to approximate the bass +// envelope - same FFT-avoidance rationale as vuMeter(). silo->sensitivity is +// the gain, silo->frequency the update rate. +CRGBWW AnimationHandler::bassReact(){ + if(!isValidRateHz(silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return CRGBWW{0,0,0,0,0}; + } + + const float LOWPASS_ALPHA = 0.15f; + + unsigned long now = millis(); + static unsigned long lastStep = 0; + static float lowPassed = 0.0f; + static float smoothedEnvelope = 0.0f; + + if(now < lastChange + UPDATE_TIME){ + lowPassed = audioBias; + smoothedEnvelope = 0.0f; + lastStep = 0; + } + + long period = 1000 / silo->frequency; + if(now >= lastStep + period){ + lastStep = now; + + // low-pass the ring buffer (in chronological order) to isolate the + // slow/bass-frequency component, rejecting fast midrange/treble wiggle + for(unsigned int i = 0; i < ringBufferSize; i++){ + unsigned int idx = (ringBufferCounter + i) % ringBufferSize; + lowPassed += (audioRingBuffer[idx] - lowPassed) * LOWPASS_ALPHA; + } + + float rawEnvelope = fabsf(lowPassed - audioBias); + float gain = 1.0f + (silo->sensitivity / 255.0f) * 6.0f; + float target = rawEnvelope * gain * 3.0f; + if(target > 1.0f) target = 1.0f; + if(target < 0.0f) target = 0.0f; + + smoothedEnvelope = smoothedEnvelope * 0.6f + target * 0.4f; + } + + CRGBWW out; + out.R = silo->colorValue.R * smoothedEnvelope; + out.G = silo->colorValue.G * smoothedEnvelope; + out.B = silo->colorValue.B * smoothedEnvelope; + out.WW = silo->colorValue.WW * smoothedEnvelope; + out.CW = silo->colorValue.CW * smoothedEnvelope; + return out; +} diff --git a/software/lib/AnimationHandler/Modes/1001_blinkColor.cpp b/software/lib/AnimationHandler/Modes/1001_blinkColor.cpp new file mode 100644 index 0000000..3f9445f --- /dev/null +++ b/software/lib/AnimationHandler/Modes/1001_blinkColor.cpp @@ -0,0 +1,100 @@ +#include + +// Notification-blink overlay: pauses whatever mode/color was active, fades +// up to colorValue over time, holds for duration, fades back down over +// time, holds off for duration, repeating length times (length <= 1 skips +// the fade-down/hold-off legs entirely, giving a single pulse), then hands +// control straight back to whatever was running before - no crossfade back +// in, just an instant cut at whichever point the sequence naturally ends. +// +// The actual "pause and resume without jumping" trick lives in +// AnimationHandler::snapshotSilo() (called by main.cpp's MQTT callback +// before it overwrites *silo with this mode's own parameters) and the +// lastChange-shift in the DONE state below - see AnimationHandler.h. +enum class BlinkState { FADE_UP, HOLD_MAX, FADE_DOWN, HOLD_MIN, DONE }; + +CRGBWW AnimationHandler::blinkColor(){ + static BlinkState state = BlinkState::FADE_UP; + static unsigned int cycle = 0; + static long stateTimer = 0; + + if(silo->time == 0 || silo->duration == 0){ + debugFkt("time or duration was set to 0, this is not possible", ERROR); + return CRGBWW{0,0,0,0,0}; + } + + long now = millis(); + + // reset on fresh trigger (mirrors every other mode's reset-detection idiom) + if(now < lastChange + (long)UPDATE_TIME){ + state = BlinkState::FADE_UP; + cycle = 0; + stateTimer = now; + } + + CRGBWW newColor; + + switch(state){ + case BlinkState::FADE_UP: { + if(now < stateTimer + (long)silo->time){ + float t = (float)(now - stateTimer) / silo->time; + // same (int)-cast-before-multiply pattern fade2Color()/interpolateCycle() use + newColor.R = oldColor.R + (int)(silo->colorValue.R - oldColor.R) * t; + newColor.G = oldColor.G + (int)(silo->colorValue.G - oldColor.G) * t; + newColor.B = oldColor.B + (int)(silo->colorValue.B - oldColor.B) * t; + newColor.WW = oldColor.WW + (int)(silo->colorValue.WW - oldColor.WW) * t; + newColor.CW = oldColor.CW + (int)(silo->colorValue.CW - oldColor.CW) * t; + }else{ + newColor = silo->colorValue; + state = BlinkState::HOLD_MAX; + stateTimer = now; + } + break; + } + case BlinkState::HOLD_MAX: { + newColor = silo->colorValue; + if(now >= stateTimer + (long)silo->duration){ + stateTimer = now; + state = (silo->length <= 1) ? BlinkState::DONE : BlinkState::FADE_DOWN; + } + break; + } + case BlinkState::FADE_DOWN: { + if(now < stateTimer + (long)silo->time){ + float t = (float)(now - stateTimer) / silo->time; + newColor.R = silo->colorValue.R - (int)silo->colorValue.R * t; + newColor.G = silo->colorValue.G - (int)silo->colorValue.G * t; + newColor.B = silo->colorValue.B - (int)silo->colorValue.B * t; + newColor.WW = silo->colorValue.WW - (int)silo->colorValue.WW * t; + newColor.CW = silo->colorValue.CW - (int)silo->colorValue.CW * t; + }else{ + newColor = CRGBWW{0,0,0,0,0}; + state = BlinkState::HOLD_MIN; + stateTimer = now; + } + break; + } + case BlinkState::HOLD_MIN: { + newColor = CRGBWW{0,0,0,0,0}; + if(now >= stateTimer + (long)silo->duration){ + cycle++; + stateTimer = now; + state = (cycle >= silo->length) ? BlinkState::DONE : BlinkState::FADE_UP; + } + break; + } + case BlinkState::DONE: { + // instant handoff - no crossfade. Shift lastChange so every mode's + // own (now - lastChange)-based progress continues exactly where it + // was when the blink interrupted it, instead of jumping forward by + // the blink's real-world duration. + long blinkStart = lastChange; + *silo = savedSilo; + lastChange = now - (blinkStart - savedLastChange); + newColor = CRGBWW{0,0,0,0,0}; // overwritten by the resumed mode on the very next tick + break; + } + } + + return newColor; +} diff --git a/software/lib/AnimationHandler/Modes/blinkColor.cpp b/software/lib/AnimationHandler/Modes/blinkColor.cpp deleted file mode 100644 index 700d642..0000000 --- a/software/lib/AnimationHandler/Modes/blinkColor.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include - -CRGBWW AnimationHandler::blinkColor(){ - //Sichere Var Silo - //Sichere oldColor - - //if now+onTime < lastChange - //Fade2Color(BlinkColor) - - // - return CRGBWW{0,0,0,0,0}; -} diff --git a/software/lib/AnimationHandler/Modes/sound2Light.cpp b/software/lib/AnimationHandler/Modes/sound2Light.cpp deleted file mode 100644 index 906a3f3..0000000 --- a/software/lib/AnimationHandler/Modes/sound2Light.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include -#include - -#define SCL_INDEX 0x00 -#define SCL_TIME 0x01 -#define SCL_FREQUENCY 0x02 -#define SCL_PLOT 0x03 - -//Geschätzt -#define samplingFrequency 1000 - -void printVector(double *vData, uint16_t bufferSize) -{ - for (uint16_t i = 0; i < bufferSize; i++) - { - double abscissa = ((i * 1.0 * samplingFrequency) / ringBufferSize); - - String bar = ""; - for(unsigned int j = 0; j < vData[i]*10; j++) bar += "|"; - - debugFkt(String(abscissa, 2) + "Hz: " + bar, DEBUG); - } -} - - -CRGBWW AnimationHandler::sound2Light(){ - arduinoFFT FFT = arduinoFFT(); - - double fourierBufferReal[ringBufferSize]; - double fourierBufferImag[ringBufferSize]; - - for(int i = 0; i < ringBufferSize; i++){ - unsigned int correctIndex = ringBufferCounter+i; - if(correctIndex > ringBufferSize) correctIndex -= ringBufferSize; - - fourierBufferReal[i] = audioRingBuffer[correctIndex]; - fourierBufferImag[i] = 0.0; - } - - FFT.DCRemoval(fourierBufferReal, ringBufferSize); - debugFkt("FFT: DC removed", DEBUG); - - FFT.Windowing(fourierBufferReal, ringBufferSize, FFT_WIN_TYP_HAMMING, FFT_FORWARD); - debugFkt("FFT: Windowed", DEBUG); - - FFT.Compute(fourierBufferReal, fourierBufferImag, ringBufferSize, FFT_FORWARD); - debugFkt("FFT: Computed", DEBUG); - - FFT.ComplexToMagnitude(fourierBufferReal, fourierBufferImag, ringBufferSize); - debugFkt("FFT: Magnitude", DEBUG); - - printVector(fourierBufferReal, (ringBufferSize >> 1)); - unsigned int freqRangeValue[3] = {0,0,0}; - - //TODO: Fix reduction to 3 frequency bands - for(int i = 0; i < 3; i++){ - for(int j = 0; j < (ringBufferSize/2)/3; j++) - freqRangeValue[i] += fourierBufferReal[j] *10; - } - - return CRGBWW{0,0,0,0,0}; -} diff --git a/software/lib/AnimationHandlerBus/AnimationHandlerBus.cpp b/software/lib/AnimationHandlerBus/AnimationHandlerBus.cpp index 096b62f..0096c37 100644 --- a/software/lib/AnimationHandlerBus/AnimationHandlerBus.cpp +++ b/software/lib/AnimationHandlerBus/AnimationHandlerBus.cpp @@ -1,18 +1,197 @@ #include +extern void debugFkt(String, LogLevel); -//case FIRE: -// fire(); -// break; -// case NOISE: -// noise(); -// break; -// case VU_METER: -// vuMeter(): -// break; -// case STROBE_PARTS: -// strobeParts(); -// break; -// case MOVING_PARTS: -// movingParts(); -// break; +AnimationHandlerBus::AnimationHandlerBus(stripType* type, unsigned int stripLength, varSilo* silo, bool* varSiloChanged) + : AnimationHandler{silo, varSiloChanged} +{ + this->stripLength = stripLength; + + //Allocate memory for CRGB led Buffer according to number of leds + this->leds = (CRGB*)malloc(sizeof(CRGB) * stripLength); + memset(this->leds, 0, stripLength * sizeof(CRGB)); + + //Allocate memory for PIXEL_ARRAY's per-LED target buffer + this->pixelTargets = (PixelBytes*)malloc(sizeof(PixelBytes) * stripLength); + memset(this->pixelTargets, 0, stripLength * sizeof(PixelBytes)); + + //Setup fastled lib with memory area and length + switch(*type){ + case WS2812_STRIP: + FastLED.addLeds(leds, stripLength); + debugFkt("WS2812 Bus initialized", INFO); + break; + case APA102_STRIP: + FastLED.addLeds(leds, stripLength); + debugFkt("APA102 Bus initialized", INFO); + break; + default: + debugFkt("Unknown stripType in AnimationHandlerBus or not implemented yet", ERROR); + break; + } +} + +AnimationHandlerBus::~AnimationHandlerBus(){ + free(this->leds); + free(this->pixelTargets); +} + +void AnimationHandlerBus::snapshotSilo(){ + AnimationHandler::snapshotSilo(); +} + +void AnimationHandlerBus::setPixelData(const uint8_t* data, unsigned int length){ + unsigned int expectedLength = this->stripLength * 3; + if(length != expectedLength){ + debugFkt("Pixel array payload size mismatch, expected " + String(expectedLength) + " got " + String(length), ERROR); + return; + } + + for(unsigned int i = 0; i < this->stripLength; i++){ + unsigned int base = i * 3; + this->pixelTargets[i] = PixelBytes{data[base], data[base+1], data[base+2]}; + } +} + +void AnimationHandlerBus::handle(){ + unsigned int now = millis(); + + if(*varSiloChanged){ + lastChange = now; + oldColor = FastLedCRGB2CRGBWW(this->leds[0]); + *varSiloChanged = false; + } + + if(now > audioTimer){ + audioTimer = now; + recordAudioSample(); + } + + if(now > fpsTimer + UPDATE_TIME){ + fpsTimer = now; + + if(silo->mode < 100){ + //Uniform color mode -> fill the whole strip with one calculated color + fill_solid(this->leds, this->stripLength, CRGBWW2FastLedCRGB(this->getNewColor())); + }else{ + //Addressable mode -> individual pixels are set by the mode function itself + this->getNewStripBuffer(); + } + + FastLED.show(); + } +} + +CRGB AnimationHandlerBus::CRGBWW2FastLedCRGB(CRGBWW color){ + CRGB out; + out.r = color.R / 4; + out.g = color.G / 4; + out.b = color.B / 4; + //TODO: include CW/WW channels + return out; +} + +CRGBWW AnimationHandlerBus::FastLedCRGB2CRGBWW(CRGB color){ + CRGBWW out; + out.R = color.r * 4; + out.G = color.g * 4; + out.B = color.b * 4; + out.WW = 0; + out.CW = 0; + return out; +} + +CRGBWW AnimationHandlerBus::getNewColor(){ + switch(silo->mode){ + case FADE_2_COLOR: + return fade2Color(); + case BLINK_COLOR: + return blinkColor(); + case STROBE: + return strobe(); + case SOUND_2_LIGHT: + return sound2Light(); + case BREATHE: + return breathe(); + case OCEAN: + return ocean(); + case FOREST: + return forest(); + case DUSK: + return dusk(); + case FIRE_FLICKER: + return fireFlicker(); + case BASS_REACT: + return bassReact(); + case HEARTBEAT: + return heartBeat(); + default: + debugFkt("The Selected Mode is not a uniform color mode", ERROR); + return CRGBWW{0,0,0,0,0}; + } +} + +void AnimationHandlerBus::getNewStripBuffer(){ + switch(silo->mode){ + case WIPE: + this->wipe(); + break; + case NOISE: + this->noise(); + break; + case NOISE_RANDOM_COLOR: + this->noiseRandomColor(); + break; + case FIRE: + this->fire(); + break; + case VU_METER: + this->vuMeter(); + break; + case STROBE_PARTS: + this->strobeParts(); + break; + case MOVING_PARTS: + this->movingParts(); + break; + case MOVING_BACK_AND_FORTH: + this->movingBackAndForth(); + break; + case RAINBOW_CYCLE: + this->rainbowCycle(); + break; + case THEATER_CHASE: + this->theaterChase(); + break; + case COMET: + this->comet(); + break; + case COMET_BACK_AND_FORTH: + this->cometBackAndForth(); + break; + case NIGHT_SKY: + this->nightSky(); + break; + case PIXEL_ARRAY: + this->pixelArray(); + break; + case VU_METER_PEAK: + this->vuMeterPeak(); + break; + case SPECTRUM: + this->spectrum(); + break; + case POLICE_LIGHTS: + this->policeLights(); + break; + case PROGRESS_BAR: + this->progressBar(); + break; + default: + debugFkt("The Selected Mode is not implemented yet", ERROR); + break; + } +} + +// Per-mode render functions live under Modes/ (one file per mode), matching +// the convention already used by AnimationHandler/Modes/. diff --git a/software/lib/AnimationHandlerBus/AnimationHandlerBus.h b/software/lib/AnimationHandlerBus/AnimationHandlerBus.h index 7f1f6b1..bb9288e 100644 --- a/software/lib/AnimationHandlerBus/AnimationHandlerBus.h +++ b/software/lib/AnimationHandlerBus/AnimationHandlerBus.h @@ -1,12 +1,60 @@ #include -#include +#include #include +#include +// Raw per-LED target color received via the /pixels binary MQTT topic. +// 3 bytes/LED (0-255 each) - WS2812/APA102 have no WW/CW channels, so +// unlike CRGBWW there's nothing to carry for those here. +struct PixelBytes { uint8_t r, g, b; }; -class AnimationHandlerBus{ -public: - void fade(CRGBWW value,double transitionTime); -private: +// Drives addressable strips (WS2812/APA102) via FastLED. Owns the per-pixel +// "leds" buffer and dispatches to the per-pixel modes (Mode >= 100, e.g. +// wipe(), fire(), rainbowCycle()) in addition to inheriting the shared +// uniform-color modes from AnimationHandler. Also owns the pixelTargets +// buffer and setPixelData() for PIXEL_ARRAY mode, which pushes raw per-LED +// RGB over a separate MQTT topic, bypassing the normal JSON control channel. +class AnimationHandlerBus : AnimationHandler{ + public: + AnimationHandlerBus(stripType* type, unsigned int stripLength, varSilo* silo, bool* varSiloChanged); + ~AnimationHandlerBus(); + void handle(); + void setPixelData(const uint8_t* data, unsigned int length); + // Forwards to the inherited (privately, so not otherwise reachable from + // outside) AnimationHandler::snapshotSilo() - see its declaration there. + void snapshotSilo(); + + private: + CRGB* leds; //buffer on Heap for Adressable Strips + unsigned int stripLength; + PixelBytes* pixelTargets; //buffer on Heap for PIXEL_ARRAY mode + + CRGBWW getNewColor(); + void getNewStripBuffer(); + + //Helpers + CRGB CRGBWW2FastLedCRGB(CRGBWW color); + CRGBWW FastLedCRGB2CRGBWW(CRGB color); + + //Addressable Modes + void wipe(); + void noise(); + void noiseRandomColor(); + void fire(); + void vuMeter(); + void strobeParts(); + void movingParts(); + void movingBackAndForth(); + void rainbowCycle(); + void theaterChase(); + void comet(); + void cometBackAndForth(); + void nightSky(); + void pixelArray(); + void vuMeterPeak(); + void spectrum(); + void policeLights(); + void progressBar(); }; diff --git a/software/lib/AnimationHandlerBus/Modes/0100_wipe.cpp b/software/lib/AnimationHandlerBus/Modes/0100_wipe.cpp new file mode 100644 index 0000000..80e26da --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0100_wipe.cpp @@ -0,0 +1,29 @@ +#include + +void AnimationHandlerBus::wipe(){ + unsigned long now = millis(); + bool wipeEndReached = now > (this->lastChange + this->silo->time + UPDATE_TIME); + static CRGB color = CRGB::Black; + static unsigned long timePerLed = 0; + + // handle reset of static vars through varSiloChanged + if(now < this->lastChange + UPDATE_TIME){ + timePerLed = this->silo->time / this->stripLength; + if(timePerLed < 1) timePerLed = 1; + + color = CRGBWW2FastLedCRGB(silo->colorValue); + } + + if(!wipeEndReached){ + unsigned int numberOfLedsToTurnOn = (now - this->lastChange) / timePerLed; + if(numberOfLedsToTurnOn > this->stripLength) numberOfLedsToTurnOn = this->stripLength; + + for(unsigned int i = 0; i < numberOfLedsToTurnOn; i++){ + this->leds[i] = color; + } + }else{ + for(unsigned int i = 0; i < this->stripLength; i++){ + this->leds[i] = color; + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0101_fire.cpp b/software/lib/AnimationHandlerBus/Modes/0101_fire.cpp new file mode 100644 index 0000000..a243802 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0101_fire.cpp @@ -0,0 +1,100 @@ +#include +#include + +// Directional single-end flame: logical index 0 is always "the base" (closest +// to the fire's origin); silo->position selects which physical end that maps +// to. Heat simulation follows the classic FastLED "Fire2012" reference +// (cool -> diffuse -> spark), with an occasional blue tint at the base and +// occasional dim smoke near the tip layered on top of the usual HeatColor(). +// +// silo->timeVariance (ms, 0 = disabled) controls how often the fire switches +// between a "tall" regime (low cooling, flames reach further up the strip) +// and a "short" one (high cooling, flames stay near the base): every +// timeVariance ms, a new cooling value is randomly picked - smaller +// timeVariance means more frequent switches, larger means the fire commits +// to one regime for longer. silo->maxBrightnes (0-1023, 0 treated as +// "unset"/full brightness) scales the whole flame's output down for an +// overall dimmer fire. +void AnimationHandlerBus::fire(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + + const uint8_t DEFAULT_COOLING = 55; + const uint8_t SPARKING = 120; + const CRGB SMOKE_COLOR = CRGB(20, 20, 25); + const unsigned int BASE_ZONE = 3; + + unsigned long now = millis(); + static uint8_t* heat = nullptr; + static unsigned long lastStep = 0; + static unsigned long lastCoolingChange = 0; + static uint8_t currentCooling = DEFAULT_COOLING; + + // handle reset of static vars through varSiloChanged + if(now < this->lastChange + UPDATE_TIME){ + if(heat == nullptr) heat = (uint8_t*)malloc(this->stripLength); + memset(heat, 0, this->stripLength); + lastStep = 0; + lastCoolingChange = 0; + currentCooling = DEFAULT_COOLING; + } + + bool reversed = (this->silo->position != 0); + long period = 1000 / this->silo->frequency; + + unsigned long coolingCyclePeriod = this->silo->timeVariance; + if(coolingCyclePeriod > 0 && now >= lastCoolingChange + coolingCyclePeriod){ + lastCoolingChange = now; + currentCooling = random8(20, 100); // low = tall/persistent flame, high = short flame + } + + float brightnessScale = brightnessScaleFor(this->silo->maxBrightnes); + + if(now >= lastStep + period){ + lastStep = now; + + // Step 1: cool down every cell a little + for(unsigned int i = 0; i < this->stripLength; i++){ + heat[i] = qsub8(heat[i], random8(0, ((currentCooling * 10) / this->stripLength) + 2)); + } + + // Step 2: heat drifts away from the base and diffuses + for(int k = this->stripLength - 1; k >= 2; k--){ + heat[k] = (heat[k-1] + heat[k-2] + heat[k-2]) / 3; + } + + // Step 3: randomly ignite new sparks near the base + if(random8() < SPARKING){ + unsigned int sparkRange = this->stripLength < 7 ? this->stripLength : 7; + unsigned int y = random8(sparkRange); + heat[y] = qadd8(heat[y], random8(160, 255)); + } + + // Step 4: map heat to color and write into the physical buffer according + // to which side the fire is anchored on + unsigned int outerZoneStart = (this->stripLength * 6) / 10; + for(unsigned int logicalIndex = 0; logicalIndex < this->stripLength; logicalIndex++){ + uint8_t h = heat[logicalIndex]; + CRGB color; + + if(logicalIndex < BASE_ZONE && h > 150 && random8() < 40){ + color = blend(HeatColor(h), CRGB::Blue, random8(60, 140)); + }else if(h < 20 && logicalIndex >= outerZoneStart && random8() < 15){ + color = SMOKE_COLOR; + }else{ + color = HeatColor(h); + } + + if(brightnessScale < 1.0f){ + color.r = (uint8_t)(color.r * brightnessScale); + color.g = (uint8_t)(color.g * brightnessScale); + color.b = (uint8_t)(color.b * brightnessScale); + } + + unsigned int physicalIndex = mapLogicalToPhysical(logicalIndex, this->stripLength, reversed); + this->leds[physicalIndex] = color; + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0102_noise.cpp b/software/lib/AnimationHandlerBus/Modes/0102_noise.cpp new file mode 100644 index 0000000..5ef5153 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0102_noise.cpp @@ -0,0 +1,35 @@ +#include +#include + +// silo->maxBrightnes (0-1023, 0 treated as unset/full brightness) scales +// colorValue down for an overall dimmer sparkle, same convention as fire(). +void AnimationHandlerBus::noise(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + + unsigned long now = millis(); + static unsigned long lastStateChange = 0; + long period = (1000 / this->silo->frequency); + + if(now >= lastStateChange + period){ + lastStateChange = now; + + float brightnessScale = brightnessScaleFor(this->silo->maxBrightnes); + CRGB color = CRGBWW2FastLedCRGB(silo->colorValue); + if(brightnessScale < 1.0f){ + color.r = (uint8_t)(color.r * brightnessScale); + color.g = (uint8_t)(color.g * brightnessScale); + color.b = (uint8_t)(color.b * brightnessScale); + } + + for(unsigned int i = 0; i < this->stripLength; i++){ + if(random16(0, 1000) > 500){ + this->leds[i] = color; + }else{ + this->leds[i] = CRGB::Black; + } + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0103_noiseRandomColor.cpp b/software/lib/AnimationHandlerBus/Modes/0103_noiseRandomColor.cpp new file mode 100644 index 0000000..707ba29 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0103_noiseRandomColor.cpp @@ -0,0 +1,38 @@ +#include +#include + +// Variant of noise(): same 50/50 per-LED sparkle, but there's no color +// input - every lit LED independently picks its own random color each +// refresh, rather than all lit LEDs sharing silo->colorValue. silo-> +// maxBrightnes (0-1023, 0 treated as unset/full brightness) caps the +// overall brightness, same convention as noise()/fire(). +void AnimationHandlerBus::noiseRandomColor(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + + unsigned long now = millis(); + static unsigned long lastStateChange = 0; + long period = (1000 / this->silo->frequency); + + if(now >= lastStateChange + period){ + lastStateChange = now; + + float brightnessScale = brightnessScaleFor(this->silo->maxBrightnes); + + for(unsigned int i = 0; i < this->stripLength; i++){ + if(random16(0, 1000) > 500){ + CRGB color = CRGB(random8(), random8(), random8()); + if(brightnessScale < 1.0f){ + color.r = (uint8_t)(color.r * brightnessScale); + color.g = (uint8_t)(color.g * brightnessScale); + color.b = (uint8_t)(color.b * brightnessScale); + } + this->leds[i] = color; + }else{ + this->leds[i] = CRGB::Black; + } + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0104_nightSky.cpp b/software/lib/AnimationHandlerBus/Modes/0104_nightSky.cpp new file mode 100644 index 0000000..6980484 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0104_nightSky.cpp @@ -0,0 +1,64 @@ +#include +#include + +// Stars sparkle at tunable positions, count, brightness, frequency and +// color. silo->length caps the number of concurrently active stars; silo-> +// minBrightnes/maxBrightnes bound the brightness newly-spawned stars are +// randomly assigned within, matching how breathe() already uses that pair; +// silo->colorValue is the star color itself (each star's per-tick +// brightness scales that color, rather than a hardcoded near-white). +void AnimationHandlerBus::nightSky(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + if(!isValidBlockLength(this->silo->length, this->stripLength)){ + debugFkt("Length was set to 0 or larger than the strip, this is not possible", ERROR); + return; + } + + const uint8_t FADE_AMOUNT = 10; + const uint8_t SPAWN_CHANCE = 60; // out of 255, per tick while below the max star count + + unsigned long now = millis(); + static uint8_t* starBrightness = nullptr; + static unsigned long lastStep = 0; + + if(now < this->lastChange + UPDATE_TIME){ + if(starBrightness == nullptr) starBrightness = (uint8_t*)malloc(this->stripLength); + memset(starBrightness, 0, this->stripLength); + lastStep = 0; + } + + long period = 1000 / this->silo->frequency; + if(now >= lastStep + period){ + lastStep = now; + + unsigned int activeCount = 0; + for(unsigned int i = 0; i < this->stripLength; i++){ + starBrightness[i] = qsub8(starBrightness[i], FADE_AMOUNT); + if(starBrightness[i] > 0) activeCount++; + } + + if(activeCount < this->silo->length && random8() < SPAWN_CHANCE){ + unsigned int index = random16(0, this->stripLength); + if(starBrightness[index] == 0){ + unsigned int lo = this->silo->minBrightnes; + unsigned int hi = this->silo->maxBrightnes; + if(hi < lo) hi = lo; + unsigned int brightness1023 = random16(lo, hi + 1); + starBrightness[index] = (uint8_t)(brightness1023 / 4); + } + } + + CRGB baseColor = CRGBWW2FastLedCRGB(silo->colorValue); + for(unsigned int i = 0; i < this->stripLength; i++){ + uint8_t b = starBrightness[i]; + this->leds[i] = CRGB( + (uint8_t)((unsigned int)baseColor.r * b / 255), + (uint8_t)((unsigned int)baseColor.g * b / 255), + (uint8_t)((unsigned int)baseColor.b * b / 255) + ); + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0105_movingParts.cpp b/software/lib/AnimationHandlerBus/Modes/0105_movingParts.cpp new file mode 100644 index 0000000..bb73cfb --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0105_movingParts.cpp @@ -0,0 +1,35 @@ +#include +#include + +// A single hard-edged solid block of silo->length LEDs translates along the +// strip and wraps around at the end. silo->time is the ms for one full-strip +// traversal, same unit/semantic as wipe()'s time. Position is derived every +// frame from (now - lastChange) rather than stored, so it self-restarts +// whenever the mode/params change. +void AnimationHandlerBus::movingParts(){ + if(!isValidBlockLength(this->silo->length, this->stripLength)){ + debugFkt("Length was set to 0 or larger than the strip, this is not possible", ERROR); + return; + } + if(this->silo->time == 0){ + debugFkt("Time was set to 0, this is not possible", ERROR); + return; + } + + unsigned long now = millis(); + static CRGB color = CRGB::Black; + + if(now < this->lastChange + UPDATE_TIME){ + color = CRGBWW2FastLedCRGB(silo->colorValue); + } + + unsigned int headPos = headPositionFor(now - this->lastChange, this->silo->time, this->stripLength); + + for(unsigned int i = 0; i < this->stripLength; i++){ + this->leds[i] = CRGB::Black; + } + for(unsigned int offset = 0; offset < this->silo->length; offset++){ + unsigned int pixel = (headPos + offset) % this->stripLength; + this->leds[pixel] = color; + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0106_movingBackAndForth.cpp b/software/lib/AnimationHandlerBus/Modes/0106_movingBackAndForth.cpp new file mode 100644 index 0000000..5ed5c9c --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0106_movingBackAndForth.cpp @@ -0,0 +1,36 @@ +#include +#include + +// Variant of movingParts(): the same solid length-LED block, but instead of +// wrapping around at the end it reflects and travels back the other way - +// classic ping-pong/back-and-forth motion. silo->time is the ms for one +// full round trip (there and back), same "speed" field as movingParts(). +// The block's position is bounded to [0, stripLength-length] so it never +// runs off either edge - no wraparound math needed, unlike movingParts(). +void AnimationHandlerBus::movingBackAndForth(){ + if(!isValidBlockLength(this->silo->length, this->stripLength)){ + debugFkt("Length was set to 0 or larger than the strip, this is not possible", ERROR); + return; + } + if(this->silo->time == 0){ + debugFkt("Time was set to 0, this is not possible", ERROR); + return; + } + + unsigned long now = millis(); + static CRGB color = CRGB::Black; + + if(now < this->lastChange + UPDATE_TIME){ + color = CRGBWW2FastLedCRGB(silo->colorValue); + } + + unsigned int range = this->stripLength - this->silo->length; + unsigned int headPos = bouncePositionFor(now - this->lastChange, this->silo->time, range); + + for(unsigned int i = 0; i < this->stripLength; i++){ + this->leds[i] = CRGB::Black; + } + for(unsigned int offset = 0; offset < this->silo->length; offset++){ + this->leds[headPos + offset] = color; + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0107_rainbowCycle.cpp b/software/lib/AnimationHandlerBus/Modes/0107_rainbowCycle.cpp new file mode 100644 index 0000000..b684d01 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0107_rainbowCycle.cpp @@ -0,0 +1,27 @@ +#include + +// Continuous hue-shift across the strip. startHue advances proportionally to +// elapsed time so the cycle period equals silo->time regardless of frame +// rate; it wraps naturally on uint8_t overflow. +void AnimationHandlerBus::rainbowCycle(){ + if(this->silo->time == 0){ + debugFkt("Time was set to 0, this is not possible", ERROR); + return; + } + + unsigned long now = millis(); + static uint8_t startHue = 0; + static unsigned long lastAdvance = 0; + + if(now < this->lastChange + UPDATE_TIME){ + startHue = 0; + lastAdvance = now; + } + + unsigned long elapsed = now - lastAdvance; + lastAdvance = now; + startHue += (uint8_t)((255UL * elapsed) / this->silo->time); + + uint8_t deltaHue = this->stripLength > 0 ? (uint8_t)(255 / this->stripLength) : 0; + fill_rainbow(this->leds, this->stripLength, startHue, deltaHue); +} diff --git a/software/lib/AnimationHandlerBus/Modes/0108_theaterChase.cpp b/software/lib/AnimationHandlerBus/Modes/0108_theaterChase.cpp new file mode 100644 index 0000000..5fe7785 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0108_theaterChase.cpp @@ -0,0 +1,40 @@ +#include + +// Classic marquee/theater-chase: a segment of `length` lit pixels (default 1, +// the original single-pixel-per-repeat look) repeats every `position` pixels +// (spacing between repeats, default 3 - the original hardcoded constant), +// with the lit set shifting by one pixel every silo->time ms. +void AnimationHandlerBus::theaterChase(){ + if(this->silo->time == 0){ + debugFkt("Time was set to 0, this is not possible", ERROR); + return; + } + + unsigned int segLength = (this->silo->length == 0) ? 1 : this->silo->length; + unsigned int spacing = (this->silo->position == 0) ? 3 : this->silo->position; + + if(segLength >= spacing){ + debugFkt("length must be smaller than position (spacing) to leave a gap", ERROR); + return; + } + + unsigned long now = millis(); + static unsigned long lastStep = 0; + static unsigned int phase = 0; + + if(now < this->lastChange + UPDATE_TIME){ + lastStep = 0; + phase = 0; + } + + if(now >= lastStep + this->silo->time){ + lastStep = now; + phase = (phase + 1) % spacing; + + CRGB color = CRGBWW2FastLedCRGB(silo->colorValue); + for(unsigned int i = 0; i < this->stripLength; i++){ + unsigned int posInPeriod = (i + phase) % spacing; + this->leds[i] = (posInPeriod < segLength) ? color : CRGB::Black; + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0109_comet.cpp b/software/lib/AnimationHandlerBus/Modes/0109_comet.cpp new file mode 100644 index 0000000..c918a56 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0109_comet.cpp @@ -0,0 +1,52 @@ +#include +#include + +// Bright head pixel with a fading trail, traveling along the strip and +// wrapping at the end. Position is derived from (now - lastChange), same +// idiom as movingParts(). The trail is rendered explicitly every tick as a +// sequence of steps walking backward from the head (wrapping past index 0 +// as needed) rather than relying on fadeToBlackBy()'s per-tick decay of +// whatever was already lit - that approach left gaps whenever the head +// moved more than one pixel between ticks (which it does routinely, +// depending on time/stripLength), since skipped pixels only ever got +// darker, never relit. Rendering the whole trail explicitly every tick +// means it's always a fully continuous, correctly-shaped pack regardless +// of how far the head moves per tick. +// +// silo->sensitivity (0-255) is the per-step fade amount - 0 means the +// trail never fades (fills the entire rest of the strip solid behind the +// head), 255 fades almost immediately (a very short trail). silo->length +// (0-9, the gap) is how many pixels to skip between each successive trail +// step - 0 packs every pixel solid (degrading brightness with no gaps), +// higher values space the trail out into a sparser dotted look. +void AnimationHandlerBus::comet(){ + if(this->silo->time == 0){ + debugFkt("Time was set to 0, this is not possible", ERROR); + return; + } + + uint8_t fadeAmount = (uint8_t)this->silo->sensitivity; + unsigned int gap = this->silo->length; + if(gap > 9) gap = 9; + unsigned int step = gap + 1; + + unsigned long now = millis(); + static CRGB color = CRGB::Black; + + if(now < this->lastChange + UPDATE_TIME){ + color = CRGBWW2FastLedCRGB(silo->colorValue); + } + + unsigned int headPos = headPositionFor(now - this->lastChange, this->silo->time, this->stripLength); + + fill_solid(this->leds, this->stripLength, CRGB::Black); + + uint8_t brightness = 255; + for(unsigned int i = 0; i < this->stripLength; i += step){ + unsigned int pixel = (headPos >= i) ? (headPos - i) : (this->stripLength - (i - headPos)); + this->leds[pixel] = blend(CRGB::Black, color, brightness); + if(fadeAmount == 0) continue; // never decays - solid trail all the way around + if(brightness == 0) break; + brightness = scale8(brightness, 255 - fadeAmount); + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0110_cometBackAndForth.cpp b/software/lib/AnimationHandlerBus/Modes/0110_cometBackAndForth.cpp new file mode 100644 index 0000000..4518f62 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0110_cometBackAndForth.cpp @@ -0,0 +1,52 @@ +#include +#include + +// Variant of comet(): the same explicit-trail rendering, but the head +// reflects at each end instead of wrapping - same relationship +// movingBackAndForth() has to movingParts(). Unlike comet() (which always +// travels in one direction, so its trail always extends the same way), +// the trail here has to flip sides whenever the head reverses - it always +// trails behind the current direction of travel, so isBounceMovingForward() +// picks which way to walk. silo->sensitivity is the per-step fade amount, +// silo->length (0-9) is the gap between trail steps - same as comet(). +void AnimationHandlerBus::cometBackAndForth(){ + if(this->silo->time == 0){ + debugFkt("Time was set to 0, this is not possible", ERROR); + return; + } + + uint8_t fadeAmount = (uint8_t)this->silo->sensitivity; + unsigned int gap = this->silo->length; + if(gap > 9) gap = 9; + unsigned int step = gap + 1; + + unsigned long now = millis(); + static CRGB color = CRGB::Black; + + if(now < this->lastChange + UPDATE_TIME){ + color = CRGBWW2FastLedCRGB(silo->colorValue); + } + + unsigned long elapsed = now - this->lastChange; + unsigned int headPos = bouncePositionFor(elapsed, this->silo->time, this->stripLength - 1); + bool movingForward = isBounceMovingForward(elapsed, this->silo->time); + + fill_solid(this->leds, this->stripLength, CRGB::Black); + + uint8_t brightness = 255; + for(unsigned int i = 0; ; i += step){ + unsigned int pixel; + if(movingForward){ + if(i > headPos) break; + pixel = headPos - i; + }else{ + if(headPos + i >= this->stripLength) break; + pixel = headPos + i; + } + + this->leds[pixel] = blend(CRGB::Black, color, brightness); + if(fadeAmount == 0) continue; // never decays - solid trail all the way to the edge + if(brightness == 0) break; + brightness = scale8(brightness, 255 - fadeAmount); + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0111_strobeParts.cpp b/software/lib/AnimationHandlerBus/Modes/0111_strobeParts.cpp new file mode 100644 index 0000000..8a1d1cd --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0111_strobeParts.cpp @@ -0,0 +1,40 @@ +#include +#include + +// Alternating-block checkerboard strobe: the strip is divided into blocks of +// silo->length LEDs; on each tick every block inverts together (even blocks +// lit / odd blocks off, then swapped). Distinct from the uniform strobe() +// (mode < 100), which flashes the whole strip at once. +void AnimationHandlerBus::strobeParts(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + if(!isValidBlockLength(this->silo->length, this->stripLength)){ + debugFkt("Length was set to 0 or larger than the strip, this is not possible", ERROR); + return; + } + + unsigned long now = millis(); + static unsigned long lastToggle = 0; + static bool phaseOn = true; + + if(now < this->lastChange + UPDATE_TIME){ + lastToggle = 0; + phaseOn = true; + } + + long period = 1000 / this->silo->frequency; + if(now >= lastToggle + period){ + lastToggle = now; + phaseOn = !phaseOn; + + CRGB color = CRGBWW2FastLedCRGB(silo->colorValue); + for(unsigned int i = 0; i < this->stripLength; i++){ + unsigned int blockIndex = i / this->silo->length; + bool blockIsEven = (blockIndex % 2 == 0); + bool lit = (blockIsEven == phaseOn); + this->leds[i] = lit ? color : CRGB::Black; + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0112_policeLights.cpp b/software/lib/AnimationHandlerBus/Modes/0112_policeLights.cpp new file mode 100644 index 0000000..055a7e0 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0112_policeLights.cpp @@ -0,0 +1,55 @@ +#include +#include + +// Classic police-light-bar "wig-wag" flash: the strip is divided into +// alternating red/blue sections of silo->length pixels each, separated by +// 4px gaps (leftover pixels get spread across the gaps, then any remainder +// across start/end margins - see policeLightsLayoutFor()). At any instant +// either all red sections are lit and all blue sections are dark, or the +// other way around, swapping at silo->frequency Hz. No color input - the +// red/blue palette is fixed, same rationale as fire()/noise() having none. +#define POLICE_LIGHTS_BASE_GAP 4 + +void AnimationHandlerBus::policeLights(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + + PoliceLightsLayout layout = policeLightsLayoutFor(this->stripLength, this->silo->length, POLICE_LIGHTS_BASE_GAP); + if(layout.numSections == 0){ + debugFkt("length doesn't leave room for even a single section", ERROR); + return; + } + + unsigned long now = millis(); + static unsigned long lastStep = 0; + static bool redPhase = true; + + if(now < this->lastChange + UPDATE_TIME){ + lastStep = 0; + redPhase = true; + } + + long period = 1000 / this->silo->frequency; + if(now >= lastStep + period){ + lastStep = now; + redPhase = !redPhase; + } + + fill_solid(this->leds, this->stripLength, CRGB::Black); + + unsigned int pixel = layout.startMargin; + for(unsigned int i = 0; i < layout.numSections; i++){ + bool isRed = (i % 2 == 0); + if(isRed == redPhase){ + CRGB color = isRed ? CRGB(255, 0, 0) : CRGB(0, 0, 255); + for(unsigned int p = 0; p < this->silo->length; p++){ + unsigned int idx = pixel + p; + if(idx < this->stripLength) this->leds[idx] = color; + } + } + pixel += this->silo->length; + if(i < layout.numSections - 1) pixel += layout.gapWidth; + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0113_progressBar.cpp b/software/lib/AnimationHandlerBus/Modes/0113_progressBar.cpp new file mode 100644 index 0000000..cf5d053 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0113_progressBar.cpp @@ -0,0 +1,41 @@ +#include +#include + +// Simple progress bar: silo->length (0-100, a percentage - clamped if sent +// higher) sets what fraction of the strip is lit. silo->position picks +// which end it grows from: 0 = left/start, 1 (or anything else) = right/end +// - reusing mapLogicalToPhysical()'s reversal (same idiom fire() uses for +// its base anchor), just with only two options since there's no "center" +// concept for a progress bar. silo->colorValue sets the bar's color. The +// leading pixel (the bar's current front edge) blinks white at +// silo->frequency Hz to draw the eye to it - frequency=0 disables the +// blink entirely (leading pixel just shows the bar's normal color, solid), +// which is why this uses isValidOptionalRateHz() rather than the usual +// isValidRateHz() that rejects 0. +void AnimationHandlerBus::progressBar(){ + if(!isValidOptionalRateHz(this->silo->frequency)){ + debugFkt("Frequency over 60 is not possible", ERROR); + return; + } + + unsigned int percent = this->silo->length; + if(percent > 100) percent = 100; + + unsigned int litCount = (this->stripLength * percent) / 100; + + bool leadingIsWhite = false; + if(this->silo->frequency > 0 && litCount > 0){ + long period = 1000 / this->silo->frequency; + leadingIsWhite = (long)((millis() - this->lastChange) % period) < (period / 2); + } + + CRGB color = CRGBWW2FastLedCRGB(silo->colorValue); + bool reversed = (this->silo->position != 0); + + fill_solid(this->leds, this->stripLength, CRGB::Black); + for(unsigned int i = 0; i < litCount; i++){ + unsigned int physicalIndex = mapLogicalToPhysical(i, this->stripLength, reversed); + bool isLeading = (i == litCount - 1); + this->leds[physicalIndex] = (isLeading && leadingIsWhite) ? CRGB::White : color; + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0500_vuMeter.cpp b/software/lib/AnimationHandlerBus/Modes/0500_vuMeter.cpp new file mode 100644 index 0000000..3531f39 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0500_vuMeter.cpp @@ -0,0 +1,67 @@ +#include +#include + +// Simple peak-to-peak amplitude envelope bar-graph (not FFT/spectrum - +// cheaper on the ESP8266, and a mono mic doesn't justify a per-band +// spectrum on a short strip). Depends on AnimationHandlerBus::handle() +// sampling audio into audioRingBuffer every tick. Deliberately does NOT +// measure deviation from a tracked silence bias (audioBias) - an earlier +// version did, plus automatic bias recalibration, and in practice that +// combination made the reading flat-line at/near peak most of the time +// (see DEVNOTES.md). silo->sensitivity is now the only amplification +// knob, applied as a plain gain on top of the ring buffer's raw +// peak-to-peak swing. silo->position picks the bar's anchor: 0 = grows +// from the start (left), 1 = grows from the end (right), anything else = +// grows symmetrically outward from the center in both directions. +void AnimationHandlerBus::vuMeter(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + + unsigned long now = millis(); + static unsigned long lastStep = 0; + static float smoothedAmplitude = 0.0f; + + if(now < this->lastChange + UPDATE_TIME){ + smoothedAmplitude = 0.0f; + lastStep = 0; + } + + long period = 1000 / this->silo->frequency; + if(now >= lastStep + period){ + lastStep = now; + + float rawAmplitude = peakToPeakAmplitude(audioRingBuffer, ringBufferSize); + float gain = 1.0f + (this->silo->sensitivity / 255.0f) * 4.0f; + float target = rawAmplitude * gain * 2.0f; + if(target > 1.0f) target = 1.0f; + if(target < 0.0f) target = 0.0f; + + // one-pole smoothing so the bar doesn't flicker every tick + smoothedAmplitude = smoothedAmplitude * 0.7f + target * 0.3f; + + unsigned int litCount = (unsigned int)(smoothedAmplitude * this->stripLength); + if(litCount > this->stripLength) litCount = this->stripLength; + + unsigned int startIdx, endIdx; + if(this->silo->position == 0){ + startIdx = 0; + endIdx = litCount; + }else if(this->silo->position == 1){ + startIdx = this->stripLength - litCount; + endIdx = this->stripLength; + }else{ + unsigned int center = this->stripLength / 2; + unsigned int half = litCount / 2; + startIdx = (half <= center) ? (center - half) : 0; + endIdx = startIdx + litCount; + if(endIdx > this->stripLength) endIdx = this->stripLength; + } + + CRGB color = CRGBWW2FastLedCRGB(silo->colorValue); + for(unsigned int i = 0; i < this->stripLength; i++){ + this->leds[i] = (i >= startIdx && i < endIdx) ? color : CRGB::Black; + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0501_vuMeterPeak.cpp b/software/lib/AnimationHandlerBus/Modes/0501_vuMeterPeak.cpp new file mode 100644 index 0000000..8963496 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0501_vuMeterPeak.cpp @@ -0,0 +1,93 @@ +#include +#include + +// Same peak-to-peak-amplitude bar-graph body as vuMeter() (see that file, +// including why it's not bias-relative, and its silo->position anchor +// semantics: 0 = grows from the start (left), 1 = grows from the end +// (right), anything else = grows symmetrically outward from the center), +// plus a white peak-hold marker that jumps to the leading edge of the +// highest recent level and slowly falls back down - the classic VU-meter +// "peak hold" behavior. For the center anchor the bar grows both ways, so +// there are two symmetric marker pixels (one per side) instead of one. +// The fall rate is a fixed constant (no varSilo field obviously maps to +// it, matching the precedent set by comet()'s fixed FADE_AMOUNT). +void AnimationHandlerBus::vuMeterPeak(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + + const float PEAK_FALL_PER_TICK = 0.3f; + + unsigned long now = millis(); + static unsigned long lastStep = 0; + static float smoothedAmplitude = 0.0f; + static float peakLevel = 0.0f; + + if(now < this->lastChange + UPDATE_TIME){ + smoothedAmplitude = 0.0f; + peakLevel = 0.0f; + lastStep = 0; + } + + long period = 1000 / this->silo->frequency; + if(now >= lastStep + period){ + lastStep = now; + + float rawAmplitude = peakToPeakAmplitude(audioRingBuffer, ringBufferSize); + float gain = 1.0f + (this->silo->sensitivity / 255.0f) * 4.0f; + float target = rawAmplitude * gain * 2.0f; + if(target > 1.0f) target = 1.0f; + if(target < 0.0f) target = 0.0f; + + smoothedAmplitude = smoothedAmplitude * 0.7f + target * 0.3f; + + float currentLevel = smoothedAmplitude * this->stripLength; + if(currentLevel > peakLevel){ + peakLevel = currentLevel; + }else{ + peakLevel -= PEAK_FALL_PER_TICK; + if(peakLevel < 0.0f) peakLevel = 0.0f; + } + + unsigned int litCount = (unsigned int)currentLevel; + if(litCount > this->stripLength) litCount = this->stripLength; + unsigned int peakCount = (unsigned int)peakLevel; + if(peakCount > this->stripLength) peakCount = this->stripLength; + + unsigned int startIdx, endIdx; + if(this->silo->position == 0){ + startIdx = 0; + endIdx = litCount; + }else if(this->silo->position == 1){ + startIdx = this->stripLength - litCount; + endIdx = this->stripLength; + }else{ + unsigned int center = this->stripLength / 2; + unsigned int half = litCount / 2; + startIdx = (half <= center) ? (center - half) : 0; + endIdx = startIdx + litCount; + if(endIdx > this->stripLength) endIdx = this->stripLength; + } + + CRGB color = CRGBWW2FastLedCRGB(silo->colorValue); + for(unsigned int i = 0; i < this->stripLength; i++){ + this->leds[i] = (i >= startIdx && i < endIdx) ? color : CRGB::Black; + } + + if(this->silo->position == 0){ + unsigned int peakIndex = (peakCount < this->stripLength) ? peakCount : (this->stripLength - 1); + this->leds[peakIndex] = CRGB::White; + }else if(this->silo->position == 1){ + unsigned int peakIndex = (peakCount < this->stripLength) ? (this->stripLength - 1 - peakCount) : 0; + this->leds[peakIndex] = CRGB::White; + }else{ + unsigned int center = this->stripLength / 2; + unsigned int peakHalf = peakCount / 2; + unsigned int leftIndex = (peakHalf < center) ? (center - 1 - peakHalf) : 0; + unsigned int rightIndex = (center + peakHalf < this->stripLength) ? (center + peakHalf) : (this->stripLength - 1); + this->leds[leftIndex] = CRGB::White; + this->leds[rightIndex] = CRGB::White; + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/0502_spectrum.cpp b/software/lib/AnimationHandlerBus/Modes/0502_spectrum.cpp new file mode 100644 index 0000000..369015d --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/0502_spectrum.cpp @@ -0,0 +1,130 @@ +#include +#include +#include + +// Audio spectrum visualizer. Samples the mic into its own private 64-sample +// ring buffer (separate from AnimationHandler::audioRingBuffer, which +// VU_METER/BASS_REACT/VU_METER_PEAK already use at whatever ad-hoc rate +// handle() drives them at - this mode needs its own tunable rate) at an +// interval derived from silo->time ("windowing duration": the ms span the +// FFT analyzes - sample interval = time/64, floored at UPDATE_TIME since +// nothing can sample faster than this mode itself gets called). Runs the +// same DCRemoval -> Hamming window -> FFT -> magnitude pipeline +// sound2Light() already stubs out, groups the resulting spectrum into +// silo->length visual bins (each a contiguous run of LEDs with a 2px gap +// between bins - see binWidthFor()), and renders each bin's intensity as +// brightness of silo->colorValue. silo->frequency (1-60Hz) throttles how +// often the FFT actually recomputes/redraws, independent of the sampling +// rate, so the window keeps sliding even between redraws. silo->sensitivity +// is a gain on top of automatic peak-normalization, since raw FFT +// magnitudes have no fixed scale to compare against. +#define SPECTRUM_SAMPLES 64 +#define SPECTRUM_GAP_PIXELS 2 + +void AnimationHandlerBus::spectrum(){ + if(!isValidRateHz(this->silo->frequency)){ + debugFkt("Frequency was set to 0 or over 60 this is not possible", ERROR); + return; + } + if(this->silo->time == 0){ + debugFkt("Time (windowing duration) was set to 0, this is not possible", ERROR); + return; + } + + unsigned int binWidth = binWidthFor(this->stripLength, this->silo->length, SPECTRUM_GAP_PIXELS); + if(binWidth == 0){ + debugFkt("length doesn't leave room for at least 1 pixel per bin after gaps", ERROR); + return; + } + + unsigned long now = millis(); + static float sampleBuffer[SPECTRUM_SAMPLES]; + static unsigned int sampleIndex = 0; + static unsigned long lastSample = 0; + static unsigned long lastRedraw = 0; + static float runningMaxMagnitude = 1.0f; + + if(now < this->lastChange + UPDATE_TIME){ + memset(sampleBuffer, 0, sizeof(sampleBuffer)); + sampleIndex = 0; + lastSample = 0; + lastRedraw = 0; + runningMaxMagnitude = 1.0f; + } + + unsigned long sampleInterval = this->silo->time / SPECTRUM_SAMPLES; + if(sampleInterval < UPDATE_TIME) sampleInterval = UPDATE_TIME; + + if(now >= lastSample + sampleInterval){ + lastSample = now; + sampleBuffer[sampleIndex] = analogRead(A0) / 1024.0f; + sampleIndex = (sampleIndex + 1) % SPECTRUM_SAMPLES; + } + + long redrawPeriod = 1000 / this->silo->frequency; + if(now < lastRedraw + redrawPeriod) return; + lastRedraw = now; + + double real[SPECTRUM_SAMPLES]; + double imag[SPECTRUM_SAMPLES]; + for(unsigned int i = 0; i < SPECTRUM_SAMPLES; i++){ + unsigned int idx = (sampleIndex + i) % SPECTRUM_SAMPLES; + real[i] = sampleBuffer[idx]; + imag[i] = 0.0; + } + + arduinoFFT FFT = arduinoFFT(); + FFT.DCRemoval(real, SPECTRUM_SAMPLES); + FFT.Windowing(real, SPECTRUM_SAMPLES, FFT_WIN_TYP_HAMMING, FFT_FORWARD); + FFT.Compute(real, imag, SPECTRUM_SAMPLES, FFT_FORWARD); + FFT.ComplexToMagnitude(real, imag, SPECTRUM_SAMPLES); + + // A real FFT of N samples only has N/2 usable magnitude bins; index 0 is + // the DC component, not a frequency, so skip it. + const unsigned int USABLE_BINS = SPECTRUM_SAMPLES / 2; + const unsigned int AC_BINS = USABLE_BINS - 1; + + float frameMax = 0.0f; + for(unsigned int i = 1; i < USABLE_BINS; i++){ + if(real[i] > frameMax) frameMax = (float)real[i]; + } + if(frameMax > runningMaxMagnitude) runningMaxMagnitude = frameMax; + runningMaxMagnitude *= 0.999f; // slow decay, same idiom as AnimationHandler::audioBias + if(runningMaxMagnitude < 1.0f) runningMaxMagnitude = 1.0f; + + float gain = 1.0f + (this->silo->sensitivity / 255.0f) * 4.0f; + CRGB baseColor = CRGBWW2FastLedCRGB(silo->colorValue); + + fill_solid(this->leds, this->stripLength, CRGB::Black); + + for(unsigned int bin = 0; bin < this->silo->length; bin++){ + unsigned int rawStart = 1 + (bin * AC_BINS) / this->silo->length; + unsigned int rawEnd = 1 + ((bin + 1) * AC_BINS) / this->silo->length; + if(rawEnd <= rawStart) rawEnd = rawStart + 1; + if(rawEnd > USABLE_BINS) rawEnd = USABLE_BINS; + + float sum = 0.0f; + unsigned int count = 0; + for(unsigned int i = rawStart; i < rawEnd; i++){ + sum += (float)real[i]; + count++; + } + float avgMagnitude = (count > 0) ? (sum / count) : 0.0f; + + float intensity = (avgMagnitude / runningMaxMagnitude) * gain; + if(intensity > 1.0f) intensity = 1.0f; + if(intensity < 0.0f) intensity = 0.0f; + + CRGB binColor = CRGB( + (uint8_t)(baseColor.r * intensity), + (uint8_t)(baseColor.g * intensity), + (uint8_t)(baseColor.b * intensity) + ); + + unsigned int pixelStart = bin * (binWidth + SPECTRUM_GAP_PIXELS); + for(unsigned int p = 0; p < binWidth; p++){ + unsigned int pixel = pixelStart + p; + if(pixel < this->stripLength) this->leds[pixel] = binColor; + } + } +} diff --git a/software/lib/AnimationHandlerBus/Modes/1000_pixelArray.cpp b/software/lib/AnimationHandlerBus/Modes/1000_pixelArray.cpp new file mode 100644 index 0000000..9c7cd59 --- /dev/null +++ b/software/lib/AnimationHandlerBus/Modes/1000_pixelArray.cpp @@ -0,0 +1,10 @@ +#include + +// Renders whatever was last delivered via setPixelData() (see the /pixels +// binary MQTT topic in main.cpp). No animation/timing of its own - just a +// direct copy each tick. +void AnimationHandlerBus::pixelArray(){ + for(unsigned int i = 0; i < this->stripLength; i++){ + this->leds[i] = CRGB(pixelTargets[i].r, pixelTargets[i].g, pixelTargets[i].b); + } +} diff --git a/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.cpp b/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.cpp index d6045ff..1f74337 100644 --- a/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.cpp +++ b/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.cpp @@ -8,6 +8,10 @@ AnimationHandlerPWM::AnimationHandlerPWM(StripControle* strip, varSilo* silo, bo this->strip = strip; } +void AnimationHandlerPWM::snapshotSilo(){ + AnimationHandler::snapshotSilo(); +} + void AnimationHandlerPWM::handle(){ //Switch Case that calls the apropriate Function for Handeling the currently selected Mode //All Modes are Implemented as one Function in the folder Modes @@ -35,7 +39,7 @@ void AnimationHandlerPWM::handle(){ strip->showColor(fade2Color()); break; case BLINK_COLOR: - blinkColor(); + strip->showColor(blinkColor()); break; case STROBE: strip->showColor(strobe()); @@ -46,6 +50,24 @@ void AnimationHandlerPWM::handle(){ case BREATHE: strip->showColor(breathe()); break; + case OCEAN: + strip->showColor(ocean()); + break; + case FOREST: + strip->showColor(forest()); + break; + case DUSK: + strip->showColor(dusk()); + break; + case FIRE_FLICKER: + strip->showColor(fireFlicker()); + break; + case BASS_REACT: + strip->showColor(bassReact()); + break; + case HEARTBEAT: + strip->showColor(heartBeat()); + break; default: debugFkt("The Selected Mode is not possible with RGB/RGBW/RGBWW Strips",ERROR); break; diff --git a/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.h b/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.h index f8092c0..13ffb03 100644 --- a/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.h +++ b/software/lib/AnimationHandlerPWM/AnimationHandlerPWM.h @@ -2,10 +2,18 @@ #include #include +// Drives a uniform (non-addressable) RGB/RGBW/RGBWW strip: each call to +// handle() computes one CRGBWW via the inherited uniform-color modes and +// pushes it through the attached StripControle to the PWM output channels. +// Only supports Mode < 100 - selecting an addressable-only mode is rejected +// and logged as an error. class AnimationHandlerPWM : AnimationHandler{ public: AnimationHandlerPWM(StripControle* strip, varSilo* silo, bool* varSiloChanged); void handle(); + // Forwards to the inherited (privately, so not otherwise reachable from + // outside) AnimationHandler::snapshotSilo() - see its declaration there. + void snapshotSilo(); private: StripControle* strip; diff --git a/software/lib/Definitions/PinDefinitions.h b/software/lib/Definitions/PinDefinitions.h deleted file mode 100644 index 05265e3..0000000 --- a/software/lib/Definitions/PinDefinitions.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef PINDEFINITIONS_H -#define PINDEFINITIONS_H - -//--PinDefines------------------------------------------------------------------ -#define pinR 5 -#define pinG 16 -#define pinB 14 -#define pinWW 12 -#define pinCW 13 -#define pinM 2 -//------------------------------------------------------------------------------ -#endif diff --git a/software/lib/Definitions/TypeDefinitions.h b/software/lib/Definitions/TypeDefinitions.h index 9c10fce..4150155 100644 --- a/software/lib/Definitions/TypeDefinitions.h +++ b/software/lib/Definitions/TypeDefinitions.h @@ -1,7 +1,10 @@ #ifndef TYPEDEFINITIONS_H #define TYPEDEFINITIONS_H -//Struct for Storing Color Values +//Struct for storing color values as this project's canonical 5-channel +//R/G/B/warm-white/cool-white color, with each channel in the 10-bit range +//0-1023. This is distinct from FastLED's own CRGB, which only has 3 +//channels at 8-bit (0-255) each. class CRGBWW{ public: unsigned int R, G, B, WW, CW; @@ -21,7 +24,7 @@ class CRGBWW{ //Variable Silo -> Datastructure that gets recived via MQTT and defines what the //Annimation Handlers/Modes do typedef struct{ - unsigned char mode; + unsigned int mode; CRGBWW colorValue; unsigned int time; unsigned int frequency; @@ -32,6 +35,7 @@ typedef struct{ unsigned int maxBrightnes; unsigned int timeVariance; unsigned int maxBrightnesVariance; + unsigned int duration; } varSilo; //Supported LED Strip Types @@ -39,7 +43,21 @@ enum stripType {RGB_STRIP = 3, RGBW_STRIP, RGBWW_STRIP, WS2812_STRIP, APA102_STR //LED Strip Modes -enum Mode {FADE_2_COLOR, BLINK_COLOR, STROBE, SOUND_2_LIGHT, BREATHE, FIRE = 101, NOISE, VU_METER, STROBE_PARTS, MOVING_PARTS}; +// +// Bands: 0-49 core shared (uniform+addressable) modes, 50-99 sound-based +// shared modes, 100-499 normal addressable-only modes, 500-599 sound-based +// addressable-only modes, 1000+ special/meta modes (bypass the normal +// varSilo-parameter model). Mode 1 is currently unused/available. +enum Mode {FADE_2_COLOR = 0, STROBE = 1, BREATHE = 2, OCEAN = 3, FOREST = 4, + DUSK = 5, FIRE_FLICKER = 6, HEARTBEAT = 7, + SOUND_2_LIGHT = 50, BASS_REACT = 51, + WIPE = 100, FIRE = 101, NOISE = 102, NOISE_RANDOM_COLOR = 103, + NIGHT_SKY = 104, MOVING_PARTS = 105, MOVING_BACK_AND_FORTH = 106, + RAINBOW_CYCLE = 107, THEATER_CHASE = 108, COMET = 109, + COMET_BACK_AND_FORTH = 110, STROBE_PARTS = 111, POLICE_LIGHTS = 112, + PROGRESS_BAR = 113, + VU_METER = 500, VU_METER_PEAK = 501, SPECTRUM = 502, + PIXEL_ARRAY = 1000, BLINK_COLOR = 1001}; //Log LEVELS enum LogLevel {VERBOSE, DEBUG, INFO, WARNING, ERROR}; diff --git a/software/lib/ESP8266_new_pwm b/software/lib/ESP8266_new_pwm deleted file mode 160000 index 5feee3a..0000000 --- a/software/lib/ESP8266_new_pwm +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5feee3a907224d12070bcf972d16390f5d3be542 diff --git a/software/lib/ModeMath/ModeMath.cpp b/software/lib/ModeMath/ModeMath.cpp new file mode 100644 index 0000000..e42012c --- /dev/null +++ b/software/lib/ModeMath/ModeMath.cpp @@ -0,0 +1,143 @@ +#include +#include + +bool isValidRateHz(unsigned int frequency){ + return frequency != 0 && frequency <= 60; +} + +bool isValidOptionalRateHz(unsigned int frequency){ + return frequency <= 60; +} + +bool isValidBlockLength(unsigned int length, unsigned int stripLength){ + return length != 0 && length <= stripLength; +} + +unsigned int mapLogicalToPhysical(unsigned int logicalIndex, unsigned int stripLength, bool reversed){ + if(!reversed) return logicalIndex; + return stripLength - 1 - logicalIndex; +} + +unsigned int headPositionFor(unsigned long elapsedMs, unsigned long periodMs, unsigned int stripLength){ + if(periodMs == 0) return 0; + unsigned long phase = elapsedMs % periodMs; + return (unsigned int)(phase * stripLength / periodMs); +} + +float brightnessScaleFor(unsigned int maxBrightnes){ + unsigned int cap = maxBrightnes; + if(cap == 0 || cap > 1023) cap = 1023; + return cap / 1023.0f; +} + +unsigned int bouncePositionFor(unsigned long elapsedMs, unsigned long periodMs, unsigned int range){ + unsigned long halfPeriod = periodMs / 2; + if(halfPeriod == 0 || range == 0) return 0; + + unsigned long phase = elapsedMs % periodMs; + if(phase < halfPeriod){ + return (unsigned int)(phase * range / halfPeriod); + }else{ + unsigned long down = phase - halfPeriod; + return (unsigned int)(range - (down * range / halfPeriod)); + } +} + +bool isBounceMovingForward(unsigned long elapsedMs, unsigned long periodMs){ + unsigned long halfPeriod = periodMs / 2; + if(halfPeriod == 0) return true; + return (elapsedMs % periodMs) < halfPeriod; +} + +unsigned int binWidthFor(unsigned int stripLength, unsigned int numBins, unsigned int gapPixels){ + if(numBins == 0) return 0; + unsigned int totalGap = (numBins - 1) * gapPixels; + if(totalGap >= stripLength) return 0; + unsigned int available = stripLength - totalGap; + return available / numBins; +} + +CRGBWW interpolateCycle(const CRGBWW colors[], unsigned int count, unsigned long elapsedMs, unsigned long periodMs){ + if(periodMs == 0 || count == 0) return CRGBWW{0,0,0,0,0}; + + unsigned long cyclePos = elapsedMs % periodMs; + float segmentFloat = (float)cyclePos / periodMs * count; + unsigned int segment = (unsigned int)segmentFloat; + float t = segmentFloat - segment; + + const CRGBWW &from = colors[segment % count]; + const CRGBWW &to = colors[(segment + 1) % count]; + + CRGBWW out; + out.R = from.R + (int)(to.R - from.R) * t; + out.G = from.G + (int)(to.G - from.G) * t; + out.B = from.B + (int)(to.B - from.B) * t; + out.WW = from.WW + (int)(to.WW - from.WW) * t; + out.CW = from.CW + (int)(to.CW - from.CW) * t; + return out; +} + +PoliceLightsLayout policeLightsLayoutFor(unsigned int stripLength, unsigned int sectionLength, unsigned int baseGap){ + PoliceLightsLayout layout{0, 0, 0, 0}; + if(sectionLength == 0) return layout; + + unsigned int numSections = 0; + while(true){ + unsigned int candidate = numSections + 1; + unsigned int numGaps = candidate - 1; + unsigned int used = candidate * sectionLength + numGaps * baseGap; + if(used > stripLength) break; + numSections = candidate; + } + if(numSections == 0) return layout; + + unsigned int numGaps = (numSections > 1) ? (numSections - 1) : 0; + unsigned int used = numSections * sectionLength + numGaps * baseGap; + unsigned int leftover = stripLength - used; + + unsigned int perGapExtra = (numGaps > 0) ? (leftover / numGaps) : 0; + unsigned int remainder = (numGaps > 0) ? (leftover % numGaps) : leftover; + + unsigned int half = remainder / 2; + unsigned int oddExtra = remainder % 2; + + layout.numSections = numSections; + layout.gapWidth = baseGap + perGapExtra; + layout.startMargin = half + oddExtra; + layout.endMargin = half; + return layout; +} + +float peakToPeakAmplitude(const float values[], unsigned int count){ + if(count == 0) return 0.0f; + + float minVal = values[0]; + float maxVal = values[0]; + for(unsigned int i = 1; i < count; i++){ + float v = values[i]; + if(v < minVal) minVal = v; + if(v > maxVal) maxVal = v; + } + + return (maxVal - minVal) / 2.0f; +} + +float recalibrateBias(const float values[], unsigned int count, float currentBias, float maxSpread){ + if(count == 0) return currentBias; + + float minVal = values[0]; + float maxVal = values[0]; + float sum = 0.0f; + for(unsigned int i = 0; i < count; i++){ + float v = values[i]; + if(v < minVal) minVal = v; + if(v > maxVal) maxVal = v; + sum += v; + } + + if(maxVal - minVal > maxSpread) return currentBias; + + float mean = sum / count; + if(fabsf(mean - currentBias) > maxSpread) return currentBias; + return mean; +} diff --git a/software/lib/ModeMath/ModeMath.h b/software/lib/ModeMath/ModeMath.h new file mode 100644 index 0000000..6ba5191 --- /dev/null +++ b/software/lib/ModeMath/ModeMath.h @@ -0,0 +1,117 @@ +#ifndef MODEMATH_H +#define MODEMATH_H + +#include + +// Pure, hardware-independent helpers shared by multiple AnimationHandler/ +// AnimationHandlerBus modes. No Arduino/FastLED dependency - safe to build +// and unit-test on the host (see software/test/test_modemath). + +// The "0 or >60 Hz" guard used by every rate-based mode (noise, strobe, +// strobeParts, fire, nightSky, fireFlicker). +bool isValidRateHz(unsigned int frequency); + +// Same upper bound as isValidRateHz(), but 0 is valid too - used by modes +// where 0 has its own meaning ("disable this rate-based feature entirely") +// rather than being a nonsensical input, e.g. progressBar()'s leading-pixel +// blink: frequency=0 means "don't blink," not "reject this message." +bool isValidOptionalRateHz(unsigned int frequency); + +// The "0 or larger than the strip" guard used by block-sized modes +// (strobeParts, movingParts, nightSky). +bool isValidBlockLength(unsigned int length, unsigned int stripLength); + +// Maps a logical index (0 = the "base"/anchor point) to a physical LED +// index, optionally reversing direction. Used by fire()'s directional +// heat-to-pixel mapping. +unsigned int mapLogicalToPhysical(unsigned int logicalIndex, unsigned int stripLength, bool reversed); + +// Position of a traveling head/block within [0, stripLength), given how far +// into the current periodMs-long cycle elapsedMs is. Used by movingParts()/ +// comet() to derive position from elapsed time instead of storing it. +unsigned int headPositionFor(unsigned long elapsedMs, unsigned long periodMs, unsigned int stripLength); + +// Converts a varSilo maxBrightnes value (0-1023) into a 0.0-1.0 brightness +// scale factor for dimming a rendered color: 0 is treated as "unset" (full +// brightness, scale 1.0) rather than "off", and anything above 1023 is +// clamped to 1.0. Used by fire()/noise()/noiseRandomColor() to apply an +// overall brightness cap. +float brightnessScaleFor(unsigned int maxBrightnes); + +// Triangle-wave position within [0, range] that bounces back and forth once +// per periodMs (there in the first half, back in the second half), rather +// than wrapping. Used by movingBackAndForth() to reflect at each end instead +// of jumping back to the start. +unsigned int bouncePositionFor(unsigned long elapsedMs, unsigned long periodMs, unsigned int range); + +// Whether bouncePositionFor() is currently moving in the increasing +// direction (true, the first half of the period) or the decreasing/return +// direction (false, the second half) for the same elapsedMs/periodMs +// inputs. Used by cometBackAndForth() to know which way to extend its +// trail behind the head - the trail always trails the direction of +// travel, so it needs to flip sides when the head reverses. +bool isBounceMovingForward(unsigned long elapsedMs, unsigned long periodMs); + +// Per-bin pixel width for numBins bins laid out along stripLength pixels +// with gapPixels of blank space between adjacent bins (none before the +// first or after the last). Returns 0 if numBins is 0 or there isn't room +// for at least 1 pixel per bin once the gaps are accounted for - callers +// should treat 0 as "reject this length" the same way the other guards do. +// Used by spectrum() to lay out its bins. +unsigned int binWidthFor(unsigned int stripLength, unsigned int numBins, unsigned int gapPixels); + +// Loops through the given keyframe colors over periodMs, linearly +// interpolating between them based on how far into the cycle elapsedMs is. +// Used by AnimationHandler::cycleColors() (ocean/forest/dusk). +CRGBWW interpolateCycle(const CRGBWW colors[], unsigned int count, unsigned long elapsedMs, unsigned long periodMs); + +// Layout for policeLights(): numSections back-to-back sectionLength-pixel +// sections separated by gapWidth-pixel gaps (gapWidth >= baseGap), with any +// left-over pixels that don't divide evenly split into startMargin/endMargin +// before the first and after the last section. numSections is 0 if +// sectionLength doesn't leave room for even one section. +struct PoliceLightsLayout { + unsigned int numSections; + unsigned int gapWidth; + unsigned int startMargin; + unsigned int endMargin; +}; + +// Fits as many sectionLength-pixel sections as possible along stripLength +// pixels, each pair of adjacent sections separated by at least baseGap +// pixels. Left-over pixels (stripLength minus what the sections+base gaps +// use) are distributed: first spread evenly across the internal gaps +// (widening each equally), then whatever doesn't divide evenly is split +// between a start and end margin, and if a single pixel is left after that +// it goes to the start margin. Used by policeLights() to lay out its +// alternating red/blue sections. +PoliceLightsLayout policeLightsLayoutFor(unsigned int stripLength, unsigned int sectionLength, unsigned int baseGap); + +// Half the peak-to-peak spread (max-min) of the given values - a loudness +// measure that doesn't need to know where the signal's DC center sits, +// unlike measuring deviation from a tracked bias (see recalibrateBias()). +// Used by vuMeter()/vuMeterPeak() instead of bias-relative deviation, since +// that bias-tracking approach turned out to be fragile in practice (see +// DEVNOTES.md) - sensitivity is the only knob now, applied as a plain gain +// on top of this raw value. count == 0 returns 0. +float peakToPeakAmplitude(const float values[], unsigned int count); + +// Attempts to recalibrate a running "silence bias" estimate (see +// AnimationHandler::audioBias) from a window of recent audio samples. +// Accepts the window's mean as the new bias only if BOTH: +// 1. the window's own peak-to-peak spread is at or below maxSpread (rules +// out a loud/varying signal), and +// 2. the window's mean is within maxSpread of currentBias (rules out a +// hard-clipped/rail-pinned signal, which can have near-zero spread of +// its own - flat at the rail - while still sitting far from the true +// silence point). +// Otherwise returns currentBias unchanged, so a loud sound (clipped or +// not) can never drag the bias toward itself; recalibration simply waits +// for the next window that's both quiet AND close to where the bias +// already is. count == 0 also returns currentBias unchanged. This also +// naturally bounds how far the bias can move per call, so genuine slow DC +// drift (temperature, trimmer settling) still gets admitted incrementally +// over successive calls. Used by AnimationHandler::recordAudioSample(). +float recalibrateBias(const float values[], unsigned int count, float currentBias, float maxSpread); + +#endif diff --git a/software/lib/README b/software/lib/README deleted file mode 100644 index 6debab1..0000000 --- a/software/lib/README +++ /dev/null @@ -1,46 +0,0 @@ - -This directory is intended for project specific (private) libraries. -PlatformIO will compile them to static libraries and link into executable file. - -The source code of each library should be placed in a an own separate directory -("lib/your_library_name/[here are source files]"). - -For example, see a structure of the following two libraries `Foo` and `Bar`: - -|--lib -| | -| |--Bar -| | |--docs -| | |--examples -| | |--src -| | |- Bar.c -| | |- Bar.h -| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html -| | -| |--Foo -| | |- Foo.c -| | |- Foo.h -| | -| |- README --> THIS FILE -| -|- platformio.ini -|--src - |- main.c - -and a contents of `src/main.c`: -``` -#include -#include - -int main (void) -{ - ... -} - -``` - -PlatformIO Library Dependency Finder will find automatically dependent -libraries scanning project source files. - -More information about PlatformIO Library Dependency Finder -- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/software/lib/StripControle/StripControle.h b/software/lib/StripControle/StripControle.h index cbafaaa..3e57616 100644 --- a/software/lib/StripControle/StripControle.h +++ b/software/lib/StripControle/StripControle.h @@ -3,9 +3,15 @@ #include #include -#include +#include +// Low-level driver for a uniform (non-addressable) strip. Holds the current +// CRGBWW channel values, converts them down to the strip's actual channel +// count (RGB/RGBW/RGBWW) depending on stripType, and writes the result to +// the PWM output pins. Used exclusively by AnimationHandlerPWM - addressable +// strips bypass this entirely and talk to FastLED directly via +// AnimationHandlerBus. class StripControle{ public: diff --git a/software/platformio.ini b/software/platformio.ini index 7deac77..2822742 100644 --- a/software/platformio.ini +++ b/software/platformio.ini @@ -8,25 +8,47 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html +[platformio] +include_dir = config + [env:esp12e] platform = espressif8266 board = esp12e framework = arduino +; include_dir alone only covers src/-level compiles; lib/* files build with +; their own LDF-scoped include paths, so config/ needs to be added explicitly +; here for board_config.h/user_config.h to resolve from every lib too. +build_flags = -Iconfig + lib_deps = # Using a library name - WifiManager PubSubClient FastLED arduinoFFT + ArduinoJson + OneWire + DallasTemperature -#upload_port = 192.168.178.67 -#upload_protocol = espota +; To upload over WiFi instead of serial: comment out the two upload_ lines +; below, and uncomment these two instead (fill in the device's current IP, +; e.g. from the serial monitor's "IP address:" boot log, or the router's +; DHCP lease list). Needs ArduinoOTA.setPassword() below to match +; OTA_PASSWORD in config/device_config.h. +;upload_port = 192.168.1.xxx +;upload_protocol = espota +;upload_flags = --auth=OTA_PASSWORD_HERE -upload_port = /dev/ttyUSB3 -upload_speed = 921600 +upload_port = /dev/cu.usbserial-1110 +upload_speed = 115200 monitor_speed = 115200 -monitor_port = /dev/ttyUSB3 +monitor_port = /dev/cu.usbserial-1110 + +; Native (host-machine) environment for pio test - runs the pure ModeMath +; helpers' unit tests without any ESP8266 hardware or toolchain. Does not +; build src/main.cpp (Arduino-only) or the FastLED-dependent libs. +[env:native] +platform = native diff --git a/software/src/main.cpp b/software/src/main.cpp index 99f2afa..5eb3f05 100644 --- a/software/src/main.cpp +++ b/software/src/main.cpp @@ -1,21 +1,24 @@ //--Includes-------------------------------------------------------------------- -#include #include #include -#include -#include #include -#include -#include -#include +#include #include #include -#include #include #include #include #include #include + +#if __has_include() + #include +#else + #error "config/device_config.h not found! Copy config/device_config.h.example to config/device_config.h and fill in your WiFi/MQTT settings before building. See config/device_config.h.example for details." +#endif +#include +#include +#include //--Includes-------------------------------------------------------------------- @@ -26,30 +29,29 @@ bool* varSiloChanged = new bool(false); const unsigned int pwmFreq = 1000; -char mqtt_server[40]; -char mqtt_port[6] = "8080"; -char strip_type[8] = "RGB"; -char mqtt_ssl[3] = "no"; -char mqtt_username[40] = "esp"; -char mqtt_password[40] = "supersecurepassword"; +char mqtt_server[40] = MQTT_SERVER; +char mqtt_port[6] = MQTT_PORT; +char mqtt_ssl[4] = MQTT_SSL; +char mqtt_username[40] = MQTT_USERNAME; +char mqtt_password[40] = MQTT_PASSWORD; -char mainTopic[80] = "/ESPLED/"; -char debugTopic[80]; +const char* wifi_default_ssid = WIFI_SSID; +const char* wifi_default_password = WIFI_PASSWORD; -//Fingerprint of odroid -const char* fingerprint = "A6 AE 85 65 63 DD D8 7C 70 F7 92 73 DE 8F 18 2B 9F DA 0A 76"; +char mainTopic[80] = MQTT_TOPIC_PREFIX; +char debugTopic[80]; +char pixelTopic[90]; +char tempTopic[90]; +const char* fingerprint = MQTT_SSL_FINGERPRINT; -stripType type; -bool shouldSaveConfig = false; +stripType type = STRIP_TYPE; const char* mqtt_device_id = "/rgbController/"; const LogLevel LOGLEVEL = INFO; -WiFiManager wifiManager; - WiFiClient espClient; WiFiClientSecure espClientSecure; @@ -58,7 +60,9 @@ PubSubClient client; StripControle* simpleStrip; AnimationHandlerPWM* pwmHandler; AnimationHandlerBus* busHandler; -//TODO: Adressable Strip + +OneWire oneWire(pinTemp); +DallasTemperature tempSensor(&oneWire); void debugFkt(String message, LogLevel LevelOfMessage){ @@ -96,24 +100,17 @@ void debugFkt(String message, LogLevel LevelOfMessage){ } } -//callback notifying us of the need to save config -void saveConfigCallback () { - debugFkt("Should save config", INFO); - shouldSaveConfig = true; -} - -void firmmareReset(){ - if(digitalRead(pinM) == LOW){ - wifiManager.resetSettings(); - SPIFFS.format(); - digitalWrite(pinB, HIGH); - delay(500); - digitalWrite(pinB, LOW); - } -} - //WARNING: If received json incomplete, increase MQTT_MAX_PACKET_SIZE in PubSunClient Library void callback(char* topic, byte* payload, unsigned int length) { + if(strcmp(topic, pixelTopic) == 0){ + if(busHandler != nullptr){ + busHandler->setPixelData(payload, length); + }else{ + debugFkt("Received pixel data but no addressable strip handler is active", ERROR); + } + return; + } + for (unsigned int i = 0; i < length; i++) { Serial.print((char)payload[i]); } @@ -124,7 +121,20 @@ void callback(char* topic, byte* payload, unsigned int length) { //DynamicJsonDocument doc(2048); deserializeJson(doc, payload, length); - if(doc.containsKey("mode")) Silo->mode = doc["mode"]; + + if(doc.containsKey("mode")){ + unsigned int newMode = doc["mode"]; + if(newMode == BLINK_COLOR){ + if(Silo->mode == BLINK_COLOR){ + debugFkt("Already blinking - ignoring duplicate mode:BLINK_COLOR message", INFO); + return; // already blinking - don't restart the sequence + } + // snapshot the mode we're about to interrupt, before its fields get + // overwritten below, so blinkColor() can resume it without a jump + if(type < 6) pwmHandler->snapshotSilo(); else busHandler->snapshotSilo(); + } + Silo->mode = newMode; + } if(doc.containsKey("color")){ Silo->colorValue.R = doc["color"][0]; Silo->colorValue.G = doc["color"][1]; @@ -141,6 +151,7 @@ void callback(char* topic, byte* payload, unsigned int length) { if(doc.containsKey("maxBrightnes")) Silo->maxBrightnes = doc["maxBrightnes"]; if(doc.containsKey("timeVariance")) Silo->timeVariance = doc["timeVariance"]; if(doc.containsKey("maxBrightnesVariance")) Silo->maxBrightnesVariance = doc["maxBrightnesVariance"]; + if(doc.containsKey("duration")) Silo->duration = doc["duration"]; debugFkt("Message arrived, Length: " + String(length), INFO); @@ -157,6 +168,7 @@ void callback(char* topic, byte* payload, unsigned int length) { debugFkt("minBrightnes: " + String(Silo->minBrightnes), DEBUG); debugFkt("timeVariance: " + String(Silo->timeVariance), DEBUG); debugFkt("maxBrightnesVariance: " + String(Silo->maxBrightnesVariance), DEBUG); + debugFkt("duration: " + String(Silo->duration), DEBUG); *varSiloChanged = true; } @@ -165,14 +177,17 @@ void callback(char* topic, byte* payload, unsigned int length) { void initMQTT() { if(strcmp(mqtt_ssl, "yes") == 0){ - client = PubSubClient(espClientSecure); + client.setClient(espClientSecure); debugFkt("Will compare SSL-Fingerprint and use WifiClientSecure", INFO); espClientSecure.setFingerprint(fingerprint); }else{ - client = PubSubClient(espClient); + client.setClient(espClient); debugFkt("Will not use SSL -> Standard WifiClient", INFO); } + //grow the receive buffer so a full-strip PIXEL_ARRAY payload (3 bytes/LED) fits + client.setBufferSize(STRIP_LENGTH * 3 + 128); + //set the server and callback function client.setServer(mqtt_server, atoi(mqtt_port)); client.setCallback(callback); @@ -200,30 +215,35 @@ void initMQTT() { connected = client.connect(clientId.c_str(), mqtt_username, mqtt_password); }else{ debugFkt("connecting to mqtt server without username and password", INFO); - connected = client.connect(clientId.c_str()); + connected = client.connect(clientId.c_str()); } //react to outcome of connect try if (connected) { - //setup main Topic and debug topic path in mqtt + //setup main Topic, debug topic and pixel topic path in mqtt strcat(mainTopic, WiFi.macAddress().c_str()); strcat(debugTopic, mainTopic); strcat(debugTopic, "/debug"); + strcpy(pixelTopic, mainTopic); + strcat(pixelTopic, "/pixels"); + strcpy(tempTopic, mainTopic); + strcat(tempTopic, "/temperature"); debugFkt("Now Connected - Main Topic of this device: ", INFO); debugFkt(mainTopic, INFO); debugFkt("-------------", INFO); - //subscribe this device's topic + //subscribe this device's topics client.subscribe(mainTopic); - + client.subscribe(pixelTopic); + debugFkt("subscribed main Topic ", INFO); - + //Publish Info that Board Connected //client.publish("/ESPLED/",WiFi.macAddress().c_str()); String HelloMessage = "espled-board "+ WiFi.macAddress() + " connected"; - client.publish("/ESPLED/", HelloMessage.c_str()); - + client.publish("/ESPLED/", HelloMessage.c_str()); + } else { @@ -231,7 +251,7 @@ void initMQTT() { debugFkt("failed, rc=", ERROR); debugFkt(String(client.state()), ERROR); debugFkt("Try again in 5 seconds", ERROR); - + // Wait 5 seconds before retrying delay(5000); } @@ -240,147 +260,39 @@ void initMQTT() { void initWifi() { - WiFiManagerParameter custom_mqtt_server("server", "mqtt server", mqtt_server, 40); - WiFiManagerParameter custom_mqtt_port("port", "mqtt port", mqtt_port, 6); - WiFiManagerParameter custom_strip_type("strip_type", "strip type", strip_type, 8); - WiFiManagerParameter custom_mqtt_ssl("ssl", "no", mqtt_ssl, 3); - WiFiManagerParameter custom_mqtt_username("mqtt_username", "username", mqtt_username, 40); - WiFiManagerParameter custom_mqtt_password("mqtt_password", "password", mqtt_password, 40); - - wifiManager.setSaveConfigCallback(saveConfigCallback); - - wifiManager.addParameter(&custom_mqtt_server); - wifiManager.addParameter(&custom_mqtt_port); - wifiManager.addParameter(&custom_strip_type); - wifiManager.addParameter(&custom_mqtt_ssl); - wifiManager.addParameter(&custom_mqtt_username); - wifiManager.addParameter(&custom_mqtt_password); - - wifiManager.autoConnect("RGB Controller Setup"); - wifiManager.setConfigPortalTimeout(180); - - //After the WiFi Manger is done, we are most probably connected - debugFkt("local ip", INFO); - debugFkt(WiFi.localIP().toString(), INFO); - - - strcpy(mqtt_server, custom_mqtt_server.getValue()); - strcpy(mqtt_port, custom_mqtt_port.getValue()); - strcpy(strip_type, custom_strip_type.getValue()); - strcpy(mqtt_ssl, custom_mqtt_ssl.getValue()); - strcpy(mqtt_username, custom_mqtt_username.getValue()); - strcpy(mqtt_password, custom_mqtt_password.getValue()); - + WiFi.mode(WIFI_STA); + WiFi.begin(wifi_default_ssid, wifi_default_password); - //save the custom parameters to FS - if (shouldSaveConfig) { - debugFkt("saving config", INFO); + debugFkt("Connecting to WiFi: " + String(wifi_default_ssid), INFO); - DynamicJsonDocument doc(4000); - JsonObject json = doc.to(); - - json["mqtt_server"] = mqtt_server; - json["mqtt_port"] = mqtt_port; - json["strip_type"] = strip_type; - json["mqtt_ssl"] = mqtt_ssl; - json["mqtt_username"] = mqtt_username; - json["mqtt_password"] = mqtt_password; - - - File configFile = SPIFFS.open("/config.json", "w"); - if (!configFile) { - debugFkt("failed to open config file for writing", ERROR); - } - - serializeJson(json, Serial); - serializeJson(json, configFile); - configFile.close(); - //end save + while (WiFi.status() != WL_CONNECTED) { + delay(500); } + + debugFkt("local ip", INFO); + debugFkt(WiFi.localIP().toString(), INFO); } -void initOta(){ - //TODO: Password - //Init OTA update routine +void initOTA() +{ + String otaHostname = "espled-" + WiFi.macAddress(); + otaHostname.replace(":", ""); + ArduinoOTA.setHostname(otaHostname.c_str()); + ArduinoOTA.setPassword(OTA_PASSWORD); + ArduinoOTA.onStart([]() { - String type; - if (ArduinoOTA.getCommand() == U_FLASH) { - type = "sketch"; - } else { // U_SPIFFS - type = "filesystem"; - } - debugFkt("Start updating " + type, INFO); + debugFkt("OTA update starting", INFO); }); ArduinoOTA.onEnd([]() { - debugFkt("End", INFO); - }); - ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { - Serial.printf("Progress: %u%%\r", (progress / (total / 100))); + debugFkt("OTA update complete", INFO); }); ArduinoOTA.onError([](ota_error_t error) { - Serial.printf("Error[%u]: ", error); - if (error == OTA_AUTH_ERROR) { - debugFkt("Auth Failed", ERROR); - } else if (error == OTA_BEGIN_ERROR) { - debugFkt("Begin Failed", ERROR); - } else if (error == OTA_CONNECT_ERROR) { - debugFkt("Connect Failed", ERROR); - } else if (error == OTA_RECEIVE_ERROR) { - debugFkt("Receive Failed", ERROR); - } else if (error == OTA_END_ERROR) { - debugFkt("End Failed", ERROR); - } + debugFkt("OTA update failed, error " + String((int)error), ERROR); }); - ArduinoOTA.begin(); -} -void initFS(){ - debugFkt("mounting FS...", INFO); - - if (SPIFFS.begin()) { - debugFkt("mounted file system", INFO); - if (SPIFFS.exists("/config.json")) { - //file exists, reading and loading - debugFkt("reading config file", INFO); - File configFile = SPIFFS.open("/config.json", "r"); - if (configFile) { - debugFkt("opened config file", INFO); - size_t size = configFile.size(); - // Allocate a buffer to store contents of the file. - std::unique_ptr buf(new char[size]); - - configFile.readBytes(buf.get(), size); - - DynamicJsonDocument doc(2000); - DeserializationError error = deserializeJson(doc, buf.get()); - if (error) { - } - JsonObject json = doc.as(); - - - - serializeJson(json, Serial); - if (!json.isNull()) { - debugFkt("parsed json", INFO); - - strcpy(mqtt_server, json["mqtt_server"]); - strcpy(mqtt_port, json["mqtt_port"]); - strcpy(strip_type, json["strip_type"]); - strcpy(mqtt_ssl, json["mqtt_ssl"]); - strcpy(mqtt_username, json["mqtt_username"]); - strcpy(mqtt_password, json["mqtt_password"]); - - } else { - debugFkt("failed to load json config", ERROR); - } - configFile.close(); - } - } - } else { - debugFkt("failed to mount FS", ERROR); - } - debugFkt("Finished spiffs", INFO); + ArduinoOTA.begin(); + debugFkt("OTA ready", INFO); } void initPins(){ @@ -402,21 +314,17 @@ void initPins(){ } void initStrip(){ - if(strcmp(strip_type, "RGB") == 0) type = stripType::RGB_STRIP; - else if(strcmp(strip_type, "RGBW") == 0) type = stripType::RGBW_STRIP; - else if(strcmp(strip_type, "RGBWW") == 0) type = stripType::RGBWW_STRIP; - else if(strcmp(strip_type, "WS2812") == 0) type = stripType::WS2812_STRIP; - else if(strcmp(strip_type, "APA102") == 0) type = stripType::APA102_STRIP; + Silo = new varSilo(); + Silo->length = STRIP_LENGTH; if(type < 6){ - debugFkt("Animation handler, strip and Silo initialized", INFO); - Silo = new varSilo(); + debugFkt("Animation handler, strip and Silo initialized [uniform strip]", INFO); simpleStrip = new StripControle(type); pwmHandler = new AnimationHandlerPWM(simpleStrip, Silo, varSiloChanged); } else{ - //TODO - //busHandler = new AnimationHandlerBus() + debugFkt("Animation handler, strip and Silo initialized [adressable strip]", INFO); + busHandler = new AnimationHandlerBus(&type, STRIP_LENGTH, Silo, varSiloChanged); } } @@ -425,8 +333,28 @@ void runAnimationHandler(){ pwmHandler->handle(); } else{ - //TODO - //busHandler->handle(); + busHandler->handle(); + } +} + +unsigned long tempLastRequest = 0; +bool tempConversionPending = false; +const unsigned long TEMP_READ_INTERVAL = 10000; // ms between readings +const unsigned long TEMP_CONVERSION_TIME = 750; // ms, worst case at 12-bit resolution + +void handleTempSensor(){ + unsigned long now = millis(); + if(!tempConversionPending && now - tempLastRequest > TEMP_READ_INTERVAL){ + tempSensor.requestTemperatures(); + tempLastRequest = now; + tempConversionPending = true; + } + if(tempConversionPending && now - tempLastRequest > TEMP_CONVERSION_TIME){ + tempConversionPending = false; + float celsius = tempSensor.getTempCByIndex(0); + char buf[8]; + dtostrf(celsius, 4, 1, buf); // one decimal place, e.g. "20.3" + client.publish(tempTopic, buf); } } @@ -434,24 +362,27 @@ void runAnimationHandler(){ void setup(){ initPins(); - firmmareReset(); Serial.begin(115200); delay(50); debugFkt("Booting", INFO); delay(500); - initFS(); initWifi(); + initOTA(); initMQTT(); - initOta(); initStrip(); + tempSensor.begin(); + tempSensor.setWaitForConversion(false); + debugFkt("Ready", INFO); debugFkt("IP address: " + WiFi.localIP().toString(), INFO); - simpleStrip->showColor(CRGBWW{0,1023,0,1023,0}); - delay(500); - simpleStrip->showColor(CRGBWW{0,0,0,0,0}); + if(type < 6){ + simpleStrip->showColor(CRGBWW{0,1023,0,1023,0}); + delay(500); + simpleStrip->showColor(CRGBWW{0,0,0,0,0}); + } } unsigned int cycleCount = 0; @@ -459,12 +390,14 @@ unsigned int freeHeap = 0; void loop() { cycleCount = ESP.getCycleCount(); - //OTA Handler + ArduinoOTA.handle(); //Run PWM Handler handle runAnimationHandler(); + handleTempSensor(); + //if(str) //TODO: Reconnect client.loop(); diff --git a/software/test/test_modemath/test_modemath.cpp b/software/test/test_modemath/test_modemath.cpp new file mode 100644 index 0000000..f4bfebe --- /dev/null +++ b/software/test/test_modemath/test_modemath.cpp @@ -0,0 +1,440 @@ +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// ---- isValidRateHz ---- + +void test_isValidRateHz_zero_is_invalid(void){ + TEST_ASSERT_FALSE(isValidRateHz(0)); +} + +void test_isValidRateHz_one_is_valid(void){ + TEST_ASSERT_TRUE(isValidRateHz(1)); +} + +void test_isValidRateHz_sixty_is_valid(void){ + TEST_ASSERT_TRUE(isValidRateHz(60)); +} + +void test_isValidRateHz_sixtyone_is_invalid(void){ + TEST_ASSERT_FALSE(isValidRateHz(61)); +} + +// ---- isValidOptionalRateHz ---- + +void test_isValidOptionalRateHz_zero_is_valid(void){ + TEST_ASSERT_TRUE(isValidOptionalRateHz(0)); +} + +void test_isValidOptionalRateHz_sixty_is_valid(void){ + TEST_ASSERT_TRUE(isValidOptionalRateHz(60)); +} + +void test_isValidOptionalRateHz_sixtyone_is_invalid(void){ + TEST_ASSERT_FALSE(isValidOptionalRateHz(61)); +} + +// ---- isValidBlockLength ---- + +void test_isValidBlockLength_zero_is_invalid(void){ + TEST_ASSERT_FALSE(isValidBlockLength(0, 60)); +} + +void test_isValidBlockLength_one_is_valid(void){ + TEST_ASSERT_TRUE(isValidBlockLength(1, 60)); +} + +void test_isValidBlockLength_exactly_stripLength_is_valid(void){ + TEST_ASSERT_TRUE(isValidBlockLength(60, 60)); +} + +void test_isValidBlockLength_stripLength_plus_one_is_invalid(void){ + TEST_ASSERT_FALSE(isValidBlockLength(61, 60)); +} + +// ---- mapLogicalToPhysical ---- + +void test_mapLogicalToPhysical_not_reversed_is_identity(void){ + TEST_ASSERT_EQUAL_UINT(0, mapLogicalToPhysical(0, 60, false)); + TEST_ASSERT_EQUAL_UINT(59, mapLogicalToPhysical(59, 60, false)); + TEST_ASSERT_EQUAL_UINT(30, mapLogicalToPhysical(30, 60, false)); +} + +void test_mapLogicalToPhysical_reversed_even_stripLength(void){ + TEST_ASSERT_EQUAL_UINT(59, mapLogicalToPhysical(0, 60, true)); + TEST_ASSERT_EQUAL_UINT(0, mapLogicalToPhysical(59, 60, true)); + TEST_ASSERT_EQUAL_UINT(29, mapLogicalToPhysical(30, 60, true)); +} + +void test_mapLogicalToPhysical_reversed_odd_stripLength(void){ + TEST_ASSERT_EQUAL_UINT(60, mapLogicalToPhysical(0, 61, true)); + TEST_ASSERT_EQUAL_UINT(0, mapLogicalToPhysical(60, 61, true)); + TEST_ASSERT_EQUAL_UINT(30, mapLogicalToPhysical(30, 61, true)); +} + +void test_mapLogicalToPhysical_reversed_500_leds(void){ + TEST_ASSERT_EQUAL_UINT(499, mapLogicalToPhysical(0, 500, true)); + TEST_ASSERT_EQUAL_UINT(0, mapLogicalToPhysical(499, 500, true)); + TEST_ASSERT_EQUAL_UINT(250, mapLogicalToPhysical(250, 500, false)); +} + +// ---- headPositionFor ---- + +void test_headPositionFor_start_of_cycle(void){ + TEST_ASSERT_EQUAL_UINT(0, headPositionFor(0, 1000, 100)); +} + +void test_headPositionFor_mid_cycle(void){ + TEST_ASSERT_EQUAL_UINT(50, headPositionFor(500, 1000, 100)); +} + +void test_headPositionFor_near_end_of_cycle(void){ + TEST_ASSERT_EQUAL_UINT(99, headPositionFor(999, 1000, 100)); +} + +void test_headPositionFor_wraps_exactly_at_period(void){ + // elapsed == period must wrap back to phase 0, not run off the end + TEST_ASSERT_EQUAL_UINT(0, headPositionFor(1000, 1000, 100)); + TEST_ASSERT_EQUAL_UINT(50, headPositionFor(1500, 1000, 100)); +} + +void test_headPositionFor_stripLength_not_a_multiple_of_period(void){ + TEST_ASSERT_EQUAL_UINT(30, headPositionFor(500, 1000, 60)); + TEST_ASSERT_EQUAL_UINT(59, headPositionFor(999, 1000, 60)); + TEST_ASSERT_EQUAL_UINT(0, headPositionFor(16, 1000, 60)); +} + +// ---- bouncePositionFor ---- + +void test_bouncePositionFor_start_of_cycle(void){ + TEST_ASSERT_EQUAL_UINT(0, bouncePositionFor(0, 1000, 50)); +} + +void test_bouncePositionFor_quarter_cycle_going_up(void){ + TEST_ASSERT_EQUAL_UINT(25, bouncePositionFor(250, 1000, 50)); +} + +void test_bouncePositionFor_hits_range_at_half_cycle(void){ + TEST_ASSERT_EQUAL_UINT(50, bouncePositionFor(500, 1000, 50)); +} + +void test_bouncePositionFor_three_quarter_cycle_going_down(void){ + TEST_ASSERT_EQUAL_UINT(25, bouncePositionFor(750, 1000, 50)); +} + +void test_bouncePositionFor_wraps_exactly_at_period(void){ + // elapsed == period must restart the bounce (back at the start, not + // stuck at the far end) + TEST_ASSERT_EQUAL_UINT(0, bouncePositionFor(1000, 1000, 50)); + TEST_ASSERT_EQUAL_UINT(25, bouncePositionFor(1250, 1000, 50)); +} + +void test_bouncePositionFor_zero_range_is_always_zero(void){ + TEST_ASSERT_EQUAL_UINT(0, bouncePositionFor(500, 1000, 0)); +} + +// ---- isBounceMovingForward ---- + +void test_isBounceMovingForward_first_half_is_forward(void){ + TEST_ASSERT_TRUE(isBounceMovingForward(200, 1000)); +} + +void test_isBounceMovingForward_second_half_is_backward(void){ + TEST_ASSERT_FALSE(isBounceMovingForward(700, 1000)); +} + +void test_isBounceMovingForward_wraps_across_multiple_periods(void){ + TEST_ASSERT_TRUE(isBounceMovingForward(2200, 1000)); // phase 200 -> forward + TEST_ASSERT_FALSE(isBounceMovingForward(2700, 1000)); // phase 700 -> backward +} + +void test_isBounceMovingForward_zero_period_defaults_to_forward(void){ + TEST_ASSERT_TRUE(isBounceMovingForward(500, 0)); +} + +// ---- brightnessScaleFor ---- + +void test_brightnessScaleFor_zero_is_treated_as_unset_full_brightness(void){ + TEST_ASSERT_EQUAL_FLOAT(1.0f, brightnessScaleFor(0)); +} + +void test_brightnessScaleFor_full_scale_is_one(void){ + TEST_ASSERT_EQUAL_FLOAT(1.0f, brightnessScaleFor(1023)); +} + +void test_brightnessScaleFor_half_scale(void){ + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.5004888f, brightnessScaleFor(512)); +} + +void test_brightnessScaleFor_above_1023_is_clamped_to_one(void){ + TEST_ASSERT_EQUAL_FLOAT(1.0f, brightnessScaleFor(2000)); +} + +// ---- binWidthFor ---- + +void test_binWidthFor_typical_case(void){ + // 10 bins, 9 gaps of 2px = 18px overhead, 42px left over 10 bins = 4px/bin + TEST_ASSERT_EQUAL_UINT(4, binWidthFor(60, 10, 2)); +} + +void test_binWidthFor_single_bin_uses_full_width_no_gaps(void){ + TEST_ASSERT_EQUAL_UINT(10, binWidthFor(10, 1, 2)); +} + +void test_binWidthFor_zero_bins_is_invalid(void){ + TEST_ASSERT_EQUAL_UINT(0, binWidthFor(60, 0, 2)); +} + +void test_binWidthFor_too_many_bins_for_the_gaps_alone_is_invalid(void){ + // 30 bins leaves only 2px after 58px of gaps - not enough for even 1px/bin + TEST_ASSERT_EQUAL_UINT(0, binWidthFor(60, 30, 2)); +} + +void test_binWidthFor_gaps_alone_exceeding_stripLength_is_invalid(void){ + TEST_ASSERT_EQUAL_UINT(0, binWidthFor(5, 5, 2)); +} + +// ---- interpolateCycle ---- +// Uses the exact same keyframes as AnimationHandler::ocean() (Deep Blue -> +// Teal -> Cyan), period split into 3 equal 1000ms segments (period=3000). + +static const CRGBWW oceanColors[3] = { + {0, 10, 500, 0, 0}, // Deep Blue + {0, 350, 350, 0, 0}, // Teal + {0, 700, 800, 0, 0} // Cyan +}; + +void test_interpolateCycle_phase_zero_is_first_keyframe_exactly(void){ + CRGBWW out = interpolateCycle(oceanColors, 3, 0, 3000); + TEST_ASSERT_EQUAL_UINT(0, out.R); + TEST_ASSERT_EQUAL_UINT(10, out.G); + TEST_ASSERT_EQUAL_UINT(500, out.B); +} + +void test_interpolateCycle_wraps_exactly_at_period(void){ + CRGBWW out = interpolateCycle(oceanColors, 3, 3000, 3000); + TEST_ASSERT_EQUAL_UINT(0, out.R); + TEST_ASSERT_EQUAL_UINT(10, out.G); + TEST_ASSERT_EQUAL_UINT(500, out.B); +} + +void test_interpolateCycle_midpoint_segment0_deepblue_to_teal(void){ + CRGBWW out = interpolateCycle(oceanColors, 3, 500, 3000); + TEST_ASSERT_EQUAL_UINT(0, out.R); + TEST_ASSERT_EQUAL_UINT(180, out.G); + TEST_ASSERT_EQUAL_UINT(425, out.B); +} + +void test_interpolateCycle_midpoint_segment1_teal_to_cyan(void){ + CRGBWW out = interpolateCycle(oceanColors, 3, 1500, 3000); + TEST_ASSERT_EQUAL_UINT(0, out.R); + TEST_ASSERT_EQUAL_UINT(525, out.G); + TEST_ASSERT_EQUAL_UINT(575, out.B); +} + +void test_interpolateCycle_midpoint_segment2_cyan_to_deepblue_wraparound(void){ + CRGBWW out = interpolateCycle(oceanColors, 3, 2500, 3000); + TEST_ASSERT_EQUAL_UINT(0, out.R); + TEST_ASSERT_EQUAL_UINT(355, out.G); + TEST_ASSERT_EQUAL_UINT(650, out.B); +} + +// ---- policeLightsLayoutFor ---- + +void test_policeLightsLayoutFor_leftover_spread_across_gaps_with_odd_remainder_at_start(void){ + PoliceLightsLayout layout = policeLightsLayoutFor(100, 8, 4); + TEST_ASSERT_EQUAL_UINT(8, layout.numSections); + TEST_ASSERT_EQUAL_UINT(5, layout.gapWidth); + TEST_ASSERT_EQUAL_UINT(1, layout.startMargin); + TEST_ASSERT_EQUAL_UINT(0, layout.endMargin); +} + +void test_policeLightsLayoutFor_exact_fit_no_leftover(void){ + PoliceLightsLayout layout = policeLightsLayoutFor(100, 10, 4); + TEST_ASSERT_EQUAL_UINT(7, layout.numSections); + TEST_ASSERT_EQUAL_UINT(5, layout.gapWidth); + TEST_ASSERT_EQUAL_UINT(0, layout.startMargin); + TEST_ASSERT_EQUAL_UINT(0, layout.endMargin); +} + +void test_policeLightsLayoutFor_single_section_splits_leftover_evenly_across_margins(void){ + PoliceLightsLayout layout = policeLightsLayoutFor(10, 8, 4); + TEST_ASSERT_EQUAL_UINT(1, layout.numSections); + TEST_ASSERT_EQUAL_UINT(1, layout.startMargin); + TEST_ASSERT_EQUAL_UINT(1, layout.endMargin); +} + +void test_policeLightsLayoutFor_single_leftover_pixel_with_no_gaps_goes_to_start(void){ + PoliceLightsLayout layout = policeLightsLayoutFor(9, 8, 4); + TEST_ASSERT_EQUAL_UINT(1, layout.numSections); + TEST_ASSERT_EQUAL_UINT(1, layout.startMargin); + TEST_ASSERT_EQUAL_UINT(0, layout.endMargin); +} + +void test_policeLightsLayoutFor_section_too_long_for_strip_is_zero_sections(void){ + PoliceLightsLayout layout = policeLightsLayoutFor(5, 8, 4); + TEST_ASSERT_EQUAL_UINT(0, layout.numSections); +} + +void test_policeLightsLayoutFor_zero_sectionLength_is_zero_sections(void){ + PoliceLightsLayout layout = policeLightsLayoutFor(100, 0, 4); + TEST_ASSERT_EQUAL_UINT(0, layout.numSections); +} + +// ---- peakToPeakAmplitude ---- + +void test_peakToPeakAmplitude_quiet_signal_is_small(void){ + float values[5] = {0.49f, 0.51f, 0.50f, 0.48f, 0.52f}; + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.02f, peakToPeakAmplitude(values, 5)); +} + +void test_peakToPeakAmplitude_loud_signal_is_large(void){ + float values[4] = {0.0f, 1.0f, 0.1f, 0.9f}; + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.5f, peakToPeakAmplitude(values, 4)); +} + +void test_peakToPeakAmplitude_is_independent_of_dc_offset(void){ + // Same swing (0.2 peak-to-peak), centered at very different DC points - + // the whole point is this doesn't need to know/track where the center + // is, unlike a bias-relative deviation measurement would. + float centeredLow[2] = {0.1f, 0.3f}; + float centeredHigh[2] = {0.7f, 0.9f}; + TEST_ASSERT_FLOAT_WITHIN(0.001f, peakToPeakAmplitude(centeredLow, 2), peakToPeakAmplitude(centeredHigh, 2)); +} + +void test_peakToPeakAmplitude_zero_count_is_zero(void){ + float values[1] = {0.9f}; + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, peakToPeakAmplitude(values, 0)); +} + +void test_peakToPeakAmplitude_single_sample_is_zero(void){ + float values[1] = {0.37f}; + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, peakToPeakAmplitude(values, 1)); +} + +// ---- recalibrateBias ---- + +void test_recalibrateBias_quiet_window_accepts_the_mean(void){ + float values[5] = {0.48f, 0.50f, 0.49f, 0.51f, 0.50f}; + float result = recalibrateBias(values, 5, 0.5f, 0.05f); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.496f, result); +} + +void test_recalibrateBias_loud_varying_window_keeps_currentBias_unchanged(void){ + float values[5] = {0.0f, 1.0f, 0.0f, 1.0f, 0.0f}; + float result = recalibrateBias(values, 5, 0.5f, 0.05f); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.5f, result); +} + +void test_recalibrateBias_hard_clipped_flat_window_keeps_currentBias_unchanged(void){ + // Pinned at the ADC rail: zero internal spread (it's perfectly flat), but + // far from currentBias - must still be rejected, not just windows that + // are internally noisy. + float values[5] = {1.0f, 1.0f, 1.0f, 1.0f, 1.0f}; + float result = recalibrateBias(values, 5, 0.5f, 0.05f); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.5f, result); +} + +void test_recalibrateBias_spread_exactly_at_threshold_is_accepted(void){ + // 0.5 and 0.0625 are both exactly representable in binary floating point, + // so this spread hits the maxSpread boundary with no rounding ambiguity. + // currentBias (0.5) stays within maxSpread of the window's own mean too, + // so only the spread-vs-threshold boundary is under test here. + float values[2] = {0.5f, 0.5625f}; + float result = recalibrateBias(values, 2, 0.5f, 0.0625f); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.53125f, result); +} + +void test_recalibrateBias_zero_count_keeps_currentBias_unchanged(void){ + float values[1] = {0.9f}; + float result = recalibrateBias(values, 0, 0.42f, 0.05f); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.42f, result); +} + +void test_recalibrateBias_single_sample_is_trivially_quiet(void){ + float values[1] = {0.37f}; + float result = recalibrateBias(values, 1, 0.35f, 0.05f); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.37f, result); +} + +int main(int argc, char **argv){ + UNITY_BEGIN(); + + RUN_TEST(test_isValidRateHz_zero_is_invalid); + RUN_TEST(test_isValidRateHz_one_is_valid); + RUN_TEST(test_isValidRateHz_sixty_is_valid); + RUN_TEST(test_isValidRateHz_sixtyone_is_invalid); + + RUN_TEST(test_isValidOptionalRateHz_zero_is_valid); + RUN_TEST(test_isValidOptionalRateHz_sixty_is_valid); + RUN_TEST(test_isValidOptionalRateHz_sixtyone_is_invalid); + + RUN_TEST(test_isValidBlockLength_zero_is_invalid); + RUN_TEST(test_isValidBlockLength_one_is_valid); + RUN_TEST(test_isValidBlockLength_exactly_stripLength_is_valid); + RUN_TEST(test_isValidBlockLength_stripLength_plus_one_is_invalid); + + RUN_TEST(test_mapLogicalToPhysical_not_reversed_is_identity); + RUN_TEST(test_mapLogicalToPhysical_reversed_even_stripLength); + RUN_TEST(test_mapLogicalToPhysical_reversed_odd_stripLength); + RUN_TEST(test_mapLogicalToPhysical_reversed_500_leds); + + RUN_TEST(test_headPositionFor_start_of_cycle); + RUN_TEST(test_headPositionFor_mid_cycle); + RUN_TEST(test_headPositionFor_near_end_of_cycle); + RUN_TEST(test_headPositionFor_wraps_exactly_at_period); + RUN_TEST(test_headPositionFor_stripLength_not_a_multiple_of_period); + + RUN_TEST(test_bouncePositionFor_start_of_cycle); + RUN_TEST(test_bouncePositionFor_quarter_cycle_going_up); + RUN_TEST(test_bouncePositionFor_hits_range_at_half_cycle); + RUN_TEST(test_bouncePositionFor_three_quarter_cycle_going_down); + RUN_TEST(test_bouncePositionFor_wraps_exactly_at_period); + RUN_TEST(test_bouncePositionFor_zero_range_is_always_zero); + + RUN_TEST(test_isBounceMovingForward_first_half_is_forward); + RUN_TEST(test_isBounceMovingForward_second_half_is_backward); + RUN_TEST(test_isBounceMovingForward_wraps_across_multiple_periods); + RUN_TEST(test_isBounceMovingForward_zero_period_defaults_to_forward); + + RUN_TEST(test_brightnessScaleFor_zero_is_treated_as_unset_full_brightness); + RUN_TEST(test_brightnessScaleFor_full_scale_is_one); + RUN_TEST(test_brightnessScaleFor_half_scale); + RUN_TEST(test_brightnessScaleFor_above_1023_is_clamped_to_one); + + RUN_TEST(test_binWidthFor_typical_case); + RUN_TEST(test_binWidthFor_single_bin_uses_full_width_no_gaps); + RUN_TEST(test_binWidthFor_zero_bins_is_invalid); + RUN_TEST(test_binWidthFor_too_many_bins_for_the_gaps_alone_is_invalid); + RUN_TEST(test_binWidthFor_gaps_alone_exceeding_stripLength_is_invalid); + + RUN_TEST(test_interpolateCycle_phase_zero_is_first_keyframe_exactly); + RUN_TEST(test_interpolateCycle_wraps_exactly_at_period); + RUN_TEST(test_interpolateCycle_midpoint_segment0_deepblue_to_teal); + RUN_TEST(test_interpolateCycle_midpoint_segment1_teal_to_cyan); + RUN_TEST(test_interpolateCycle_midpoint_segment2_cyan_to_deepblue_wraparound); + + RUN_TEST(test_policeLightsLayoutFor_leftover_spread_across_gaps_with_odd_remainder_at_start); + RUN_TEST(test_policeLightsLayoutFor_exact_fit_no_leftover); + RUN_TEST(test_policeLightsLayoutFor_single_section_splits_leftover_evenly_across_margins); + RUN_TEST(test_policeLightsLayoutFor_single_leftover_pixel_with_no_gaps_goes_to_start); + RUN_TEST(test_policeLightsLayoutFor_section_too_long_for_strip_is_zero_sections); + RUN_TEST(test_policeLightsLayoutFor_zero_sectionLength_is_zero_sections); + + RUN_TEST(test_peakToPeakAmplitude_quiet_signal_is_small); + RUN_TEST(test_peakToPeakAmplitude_loud_signal_is_large); + RUN_TEST(test_peakToPeakAmplitude_is_independent_of_dc_offset); + RUN_TEST(test_peakToPeakAmplitude_zero_count_is_zero); + RUN_TEST(test_peakToPeakAmplitude_single_sample_is_zero); + + RUN_TEST(test_recalibrateBias_quiet_window_accepts_the_mean); + RUN_TEST(test_recalibrateBias_loud_varying_window_keeps_currentBias_unchanged); + RUN_TEST(test_recalibrateBias_hard_clipped_flat_window_keeps_currentBias_unchanged); + RUN_TEST(test_recalibrateBias_spread_exactly_at_threshold_is_accepted); + RUN_TEST(test_recalibrateBias_zero_count_keeps_currentBias_unchanged); + RUN_TEST(test_recalibrateBias_single_sample_is_trivially_quiet); + + return UNITY_END(); +} diff --git a/tools/gifgen/.gitignore b/tools/gifgen/.gitignore new file mode 100644 index 0000000..d5ca8fa --- /dev/null +++ b/tools/gifgen/.gitignore @@ -0,0 +1,6 @@ +# Compiled host shared library - platform-specific build output of +# build.sh, not something to commit (generate.py rebuilds it automatically +# if missing or stale). +libmodes.so +libmodes.dylib +__pycache__/ diff --git a/tools/gifgen/build.sh b/tools/gifgen/build.sh new file mode 100755 index 0000000..413dfb6 --- /dev/null +++ b/tools/gifgen/build.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Compiles the REAL AnimationHandler/AnimationHandlerBus mode source (plus +# ModeMath and this directory's harness) into a host shared library, +# libmodes.so/.dylib, that generate.py loads via ctypes to render doc GIFs. +# +# Include order matters: tools/gifgen/shim/ must resolve `#include +# ` / `#include ` to OUR stand-ins, not any real +# Arduino core/FastLED - but since we never add a path to those, there's +# nothing to shadow, just something to provide. +# +# SPECTRUM uses arduinoFFT for real, so it DOES need the library compiled in +# here - unlike FastLED/Arduino.h, +# arduinoFFT's own header guards its Arduino-specific includes behind +# `#ifdef ARDUINO` (undefined on a plain host build), falling back to +# portable stdlib/math.h includes - so the real vendored source builds on +# the host as-is, no shim needed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SOFTWARE_LIB="$REPO_ROOT/software/lib" +SOFTWARE_CONFIG="$REPO_ROOT/software/config" +ARDUINO_FFT_DIR="$REPO_ROOT/software/.pio/libdeps/esp12e/arduinoFFT/src" + +OUT="$SCRIPT_DIR/libmodes.so" + +SOURCES=( + "$SCRIPT_DIR/harness.cpp" + "$SOFTWARE_LIB/ModeMath/ModeMath.cpp" + "$SOFTWARE_LIB/AnimationHandler/AnimationHandler.cpp" + "$SOFTWARE_LIB"/AnimationHandler/Modes/*.cpp + "$SOFTWARE_LIB/AnimationHandlerBus/AnimationHandlerBus.cpp" + "$SOFTWARE_LIB"/AnimationHandlerBus/Modes/*.cpp + "$ARDUINO_FFT_DIR/arduinoFFT.cpp" +) + +if [ ! -f "$ARDUINO_FFT_DIR/arduinoFFT.cpp" ]; then + echo "arduinoFFT not found at $ARDUINO_FFT_DIR - run 'pio run -e esp12e' at least once first" >&2 + exit 1 +fi + +g++ -shared -fPIC -std=gnu++17 -Wall \ + -I "$SCRIPT_DIR/shim" \ + -I "$SOFTWARE_LIB/Definitions" \ + -I "$SOFTWARE_CONFIG" \ + -I "$SOFTWARE_LIB/ModeMath" \ + -I "$SOFTWARE_LIB/AnimationHandler" \ + -I "$SOFTWARE_LIB/AnimationHandlerBus" \ + -I "$ARDUINO_FFT_DIR" \ + "${SOURCES[@]}" \ + -o "$OUT" + +echo "Built $OUT" diff --git a/tools/gifgen/generate.py b/tools/gifgen/generate.py new file mode 100644 index 0000000..f3761ff --- /dev/null +++ b/tools/gifgen/generate.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Generates a real animated GIF preview per LED-strip mode, driven by the +ACTUAL C++ mode-rendering code in software/lib/AnimationHandler(Bus)/ - not +a stand-in. See tools/gifgen/shim/ (host stand-ins for Arduino.h/FastLED.h) +and tools/gifgen/harness.cpp (the ctypes entry point, render_mode()) for how +that real code gets compiled and driven on a plain host. + +Usage: python3 tools/gifgen/generate.py +(also wired up as `make doc-gifs` at the repo root) + +For each mode this script: + 1. Builds tools/gifgen/libmodes.so if it's missing/stale (via build.sh). + 2. Calls render_mode() via ctypes with the SAME example `silo` field values + already documented in software/README.md's per-mode JSON examples, for + a 100-LED strip, 15 simulated seconds at 20fps (300 frames). + 3. Lays out each frame's 100 RGB triples as 100 8x8 boxes side by side + (left to right) in an 800x8 image (one box per LED) and saves the + animation to doc/gifs/_.gif. + +""" +import ctypes +import os +import subprocess +import sys + +from PIL import Image, ImageDraw + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..")) +LIB_PATH = os.path.join(SCRIPT_DIR, "libmodes.so") +BUILD_SCRIPT = os.path.join(SCRIPT_DIR, "build.sh") +OUT_DIR = os.path.join(REPO_ROOT, "doc", "gifs") + +# Preview render geometry: one LED == one 8x8 box, laid out side by side +# left to right (800x8 overall), matching an LED strip's actual shape. +STRIP_LENGTH = 100 +BOX_SIZE = 8 +NUM_BOXES = STRIP_LENGTH +WIDTH = BOX_SIZE * NUM_BOXES +HEIGHT = BOX_SIZE + +# 15 simulated seconds at 20fps. +FRAME_INTERVAL_MS = 50 +NUM_FRAMES = 300 +GIF_DURATION_MS = 50 + +# Per-mode example silo values. Every entry's kwargs are copied verbatim +# from the JSON example already documented for that mode in +# software/README.md's big HTML modes table, so the generated GIF matches +# the documented example. A few entries (marked below) predate their +# README row and were hand-picked to showcase the mode instead. +# +# `filename` matches the mode's own Modes/_.cpp convention. +MODES = [ + {"id": 0, "filename": "0000_fade2Color", "color": (1023, 400, 60, 0, 0), "time": 2000}, + {"id": 1, "filename": "0001_strobe", "color": (1023, 0, 850, 0, 0), "frequency": 10}, + {"id": 2, "filename": "0002_breathe", "color": (550, 0, 1023, 0, 0), "time": 4000, + "minBrightnes": 100, "maxBrightnes": 900, "timeVariance": 500, "maxBrightnesVariance": 100}, + # maxBrightnes dims the gradient to ~70% - included to prove it actually + # does something now, same rationale as fire()/noise()'s examples. + {"id": 3, "filename": "0003_ocean", "time": 6000, "maxBrightnes": 700}, + {"id": 4, "filename": "0004_forest", "time": 6000, "maxBrightnes": 700}, + {"id": 5, "filename": "0005_dusk", "time": 6000, "maxBrightnes": 700}, + {"id": 6, "filename": "0006_fireFlicker", "frequency": 15}, + # frequency=1 -> the S1/gap/S2 sequence itself takes 1000ms; duration=700 + # is the quiet diastole gap after it - about 1.7s/beat, ~9 beats in 15s. + {"id": 7, "filename": "0007_heartBeat", "color": (1023, 0, 60, 0, 0), "frequency": 1, + "duration": 700, "minBrightnes": 50, "maxBrightnes": 1000}, + {"id": 50, "filename": "0050_sound2Light", "color": (0, 900, 700, 0, 0), + "minBrightnes": 50, "maxBrightnes": 1000, "sensitivity": 40, "frequency": 20}, + # Not in software/README.md's table (see note above) - hand-chosen. + {"id": 51, "filename": "0051_bassReact", "color": (1023, 80, 900, 0, 0), "frequency": 20, "sensitivity": 200}, + # time=12000 (of the GIF's 15s) so the wipe itself is visible for most + # of the animation, leaving only 3s of solid hold at the end - a short + # time here mostly shows a static held color, not a wipe. + {"id": 100, "filename": "0100_wipe", "color": (50, 1023, 150, 0, 0), "time": 12000}, + # timeVariance cycles the cooling regime (tall vs short flame) every 3s; + # maxBrightnes dims the whole flame to ~60% - both new/tunable, chosen + # here (rather than left at their old implicit defaults) to prove they + # actually do something. + {"id": 101, "filename": "0101_fire", "frequency": 30, "position": 0, "timeVariance": 3000, "maxBrightnes": 600}, + # maxBrightnes dims the sparkle to ~50% - included to prove it actually + # does something, same as fire()'s example above. + {"id": 102, "filename": "0102_noise", "color": (200, 220, 1023, 0, 0), "frequency": 20, "maxBrightnes": 500}, + # No color field - noiseRandomColor() ignores it, each lit LED picks its + # own random color independently every refresh. + {"id": 103, "filename": "0103_noiseRandomColor", "frequency": 20}, + # color is a cyan-blue here specifically to prove nightSky()'s stars now + # follow silo->colorValue instead of a hardcoded near-white tint. + {"id": 104, "filename": "0104_nightSky", "color": (0, 700, 1023, 0, 0), "length": 6, "frequency": 20, "minBrightnes": 100, "maxBrightnes": 800}, + {"id": 105, "filename": "0105_movingParts", "color": (0, 1023, 0, 0, 0), "length": 8, "time": 2000}, + # Ping-pong variant of movingParts() - same params, reflects instead of + # wrapping. Not yet in software/README.md at generation time; mirrors + # movingParts()'s own example. + {"id": 106, "filename": "0106_movingBackAndForth", "color": (0, 1023, 0, 0, 0), "length": 8, "time": 2000}, + {"id": 107, "filename": "0107_rainbowCycle", "time": 4000}, + {"id": 108, "filename": "0108_theaterChase", "color": (1023, 1023, 1023, 0, 0), "time": 100, + "length": 2, "position": 5}, + # sensitivity is comet()'s trail fade-per-step amount: 0 = no fade at + # all (solid continuous pack, degrading brightness only via length's + # gap), 255 = very tight/short trail. length=0 (default, unset) here to + # show the continuous-pack look. + {"id": 109, "filename": "0109_comet", "color": (0, 600, 1023, 0, 0), "time": 1500, "sensitivity": 40}, + # Ping-pong variant of comet() - same params, reflects instead of + # wrapping. Not yet in software/README.md at generation time; mirrors + # comet()'s own example. + {"id": 110, "filename": "0110_cometBackAndForth", "color": (0, 600, 1023, 0, 0), "time": 1500, "sensitivity": 40}, + {"id": 111, "filename": "0111_strobeParts", "color": (1023, 0, 0, 0, 0), "length": 5, "frequency": 4}, + # No color field - policeLights() ignores it, red/blue are fixed. length=8 + # -> policeLightsLayoutFor(100,8,4) fits 8 sections with a 5px gap and a + # 1px start margin (see its own ModeMath tests for the exact layout math). + {"id": 112, "filename": "0112_policeLights", "length": 8, "frequency": 2}, + {"id": 113, "filename": "0113_progressBar", "color": (0, 1023, 300, 0, 0), + "length": 60, "position": 0, "frequency": 2}, + # position=2 (anything other than 0/1) grows the bar symmetrically + # outward from the center - chosen here specifically to prove the new + # position-anchor behavior, rather than the old always-from-the-left + # default. sensitivity=40 (not the harness synthetic tone's max gain) + # so the bar visibly rises/falls with the envelope instead of + # saturating almost immediately and staying there. + {"id": 500, "filename": "0500_vuMeter", "color": (1023, 250, 0, 0, 0), "sensitivity": 40, "frequency": 30, "position": 2}, + # position=2 (center anchor) to showcase the two symmetric peak markers - + # mirrors VU_METER's own example above for the same reason, including + # the lower sensitivity. + {"id": 501, "filename": "0501_vuMeterPeak", "color": (1023, 250, 0, 0, 0), "sensitivity": 40, "frequency": 30, "position": 2}, + # length=20 bins, 2px gaps -> 3px/bin on a 100-LED strip (20*3+19*2=98). + # time=1024 gives a sample interval of exactly 1024/64=16ms (UPDATE_TIME, + # the fastest achievable rate) - the "windowing duration" knob. + {"id": 502, "filename": "0502_spectrum", "color": (700, 0, 1023, 0, 0), "length": 20, "time": 1024, + "frequency": 20, "sensitivity": 200}, + # PIXEL_ARRAY isn't driven by silo fields at all - see harness.cpp's + # special-case, which feeds it a fixed every-5th-LED-lit pattern via + # setPixelData() instead. + {"id": 1000, "filename": "1000_pixelArray"}, + # 4 cycles of fade-up(800)+hold(600)+fade-down(800)+hold(600) = 11200ms, + # leaving ~3.8s of "resumed" tail within the 15s preview - see harness.cpp's + # BLINK_COLOR note for why that tail shows black (zeroed savedSilo) rather + # than a real previous mode in this isolated render. + {"id": 1001, "filename": "1001_blinkColor", "color": (1023, 0, 0, 0, 0), "time": 800, "duration": 600, "length": 4}, +] + + +def ensure_lib_built(): + need_build = True + if os.path.exists(LIB_PATH): + lib_mtime = os.path.getmtime(LIB_PATH) + src_mtimes = [os.path.getmtime(os.path.join(SCRIPT_DIR, "harness.cpp"))] + for root, _, files in os.walk(os.path.join(SCRIPT_DIR, "shim")): + for f in files: + src_mtimes.append(os.path.getmtime(os.path.join(root, f))) + need_build = lib_mtime < max(src_mtimes) + + if need_build: + print("Building tools/gifgen/libmodes.so ...") + subprocess.run(["bash", BUILD_SCRIPT], check=True) + + +def load_lib(): + lib = ctypes.CDLL(LIB_PATH) + lib.render_mode.restype = ctypes.c_int + lib.render_mode.argtypes = [ + ctypes.c_int, ctypes.c_uint, # modeId, stripLength + ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, # color r,g,b,ww,cw + ctypes.c_uint, ctypes.c_uint, ctypes.c_ubyte, # time, frequency, sensitivity + ctypes.c_uint, ctypes.c_uint, # position, length + ctypes.c_uint, ctypes.c_uint, # minBrightnes, maxBrightnes + ctypes.c_uint, ctypes.c_uint, # timeVariance, maxBrightnesVariance + ctypes.c_uint, # duration + ctypes.c_ulong, ctypes.c_uint, # frameIntervalMs, numFrames + ctypes.POINTER(ctypes.c_uint8), # outFrames + ] + return lib + + +def render_mode_frames(lib, mode): + color = mode.get("color", (0, 0, 0, 0, 0)) + buf_type = ctypes.c_uint8 * (STRIP_LENGTH * 3 * NUM_FRAMES) + buf = buf_type() + + ret = lib.render_mode( + mode["id"], STRIP_LENGTH, + color[0], color[1], color[2], color[3], color[4], + mode.get("time", 0), mode.get("frequency", 0), mode.get("sensitivity", 0), + mode.get("position", 0), mode.get("length", 0), + mode.get("minBrightnes", 0), mode.get("maxBrightnes", 0), + mode.get("timeVariance", 0), mode.get("maxBrightnesVariance", 0), + mode.get("duration", 0), + FRAME_INTERVAL_MS, NUM_FRAMES, + buf, + ) + if ret != 0: + raise RuntimeError(f"render_mode() failed for mode {mode['id']} ({mode['filename']}), ret={ret}") + return bytes(buf) + + +def frame_to_image(frame_bytes): + img = Image.new("RGB", (WIDTH, HEIGHT)) + draw = ImageDraw.Draw(img) + for i in range(NUM_BOXES): + r, g, b = frame_bytes[i*3], frame_bytes[i*3+1], frame_bytes[i*3+2] + x0 = i * BOX_SIZE + draw.rectangle([x0, 0, x0 + BOX_SIZE - 1, HEIGHT - 1], fill=(r, g, b)) + return img + + +def main(): + ensure_lib_built() + lib = load_lib() + os.makedirs(OUT_DIR, exist_ok=True) + + for mode in MODES: + print(f"Rendering mode {mode['id']} ({mode['filename']}) ...") + raw = render_mode_frames(lib, mode) + + frames = [] + for f in range(NUM_FRAMES): + off = f * STRIP_LENGTH * 3 + frames.append(frame_to_image(raw[off:off + STRIP_LENGTH * 3])) + + out_path = os.path.join(OUT_DIR, f"{mode['filename']}.gif") + frames[0].save( + out_path, + save_all=True, + append_images=frames[1:], + duration=GIF_DURATION_MS, + loop=0, + optimize=False, + ) + size_kb = os.path.getsize(out_path) / 1024.0 + print(f" -> {os.path.relpath(out_path, REPO_ROOT)} ({len(frames)} frames, {size_kb:.1f} KiB)") + + print("Done.") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gifgen/harness.cpp b/tools/gifgen/harness.cpp new file mode 100644 index 0000000..6d199ef --- /dev/null +++ b/tools/gifgen/harness.cpp @@ -0,0 +1,172 @@ +// Host-side harness that drives the REAL AnimationHandlerBus dispatch code +// (software/lib/AnimationHandlerBus/AnimationHandlerBus.cpp, which routes +// both uniform modes [<100, via getNewColor()+fill_solid] and addressable +// modes [>=100, via getNewStripBuffer()] through one real handle() call) to +// render each LED-strip mode's actual output, frame by frame, for +// tools/gifgen/generate.py to turn into a GIF via ctypes. +// +// Compiled together with the real mode source under +// software/lib/AnimationHandler(Bus)/{.,/Modes/*.cpp} and ModeMath against +// the host shim headers in tools/gifgen/shim/ (Arduino.h/FastLED.h), which +// stand in for the ESP8266 Arduino core / FastLED so this all builds with +// plain g++ - see tools/gifgen/build.sh. +#include +#include + +#include +#include +#include +#include +#include +#include + +// Defined here (not in the shim headers) since the harness owns the fake +// clock/ADC state that render_mode() drives per frame. +unsigned long g_fakeMillis = 0; +int g_fakeAnalogValue = 512; + +// The real debugFkt() lives in software/src/main.cpp, which isn't part of +// this build; AnimationHandler.h/AnimationHandlerBus.cpp both just +// `extern void debugFkt(String, LogLevel);`, so provide a definition here +// to satisfy the linker. Logs to stderr rather than being a pure no-op, so +// mode ERROR/WARNING logs (e.g. "frequency 0 rejected") are visible while +// iterating on the harness - doesn't affect the rendered frames either way. +void debugFkt(String message, LogLevel level){ + static const char* names[] = {"VERBOSE", "DEBUG", "INFO", "WARNING", "ERROR"}; + if(level >= WARNING){ + fprintf(stderr, "[%s] %s\n", names[level], message.c_str()); + } +} + +extern "C" { + +// Renders `numFrames` frames of `modeId` at `frameIntervalMs` simulated-time +// steps for a `stripLength`-LED WS2812 strip, writing stripLength*3 bytes +// (R,G,B per LED, 0-255) per frame into outFrames (caller-allocated, sized +// numFrames*stripLength*3). silo fields map 1:1 onto varSilo (see +// software/lib/Definitions/TypeDefinitions.h) so Python can pass the exact +// example values already documented per-mode in software/README.md. +// +// Every mode - uniform (<100) and addressable (>=100) alike - is routed +// through one real AnimationHandlerBus::handle() call per frame; that's the +// same dispatch the real firmware uses, so there's no special-casing needed +// beyond PIXEL_ARRAY (1000), which needs an explicit setPixelData() payload +// since it isn't driven by the other silo fields at all. +int render_mode(int modeId, unsigned int stripLength, + unsigned int color_r, unsigned int color_g, unsigned int color_b, + unsigned int color_ww, unsigned int color_cw, + unsigned int time, unsigned int frequency, unsigned char sensitivity, + unsigned int position, unsigned int length, + unsigned int minBrightnes, unsigned int maxBrightnes, + unsigned int timeVariance, unsigned int maxBrightnesVariance, + unsigned int duration, + unsigned long frameIntervalMs, unsigned int numFrames, + uint8_t* outFrames){ + if(stripLength == 0 || numFrames == 0 || outFrames == nullptr || frameIntervalMs == 0){ + return -1; + } + + varSilo silo; + memset(&silo, 0, sizeof(silo)); + silo.mode = (unsigned int)modeId; + silo.colorValue = CRGBWW{color_r, color_g, color_b, color_ww, color_cw}; + silo.time = time; + silo.frequency = frequency; + silo.sensitivity = sensitivity; + silo.position = position; + silo.length = length; + silo.minBrightnes = minBrightnes; + silo.maxBrightnes = maxBrightnes; + silo.timeVariance = timeVariance; + silo.maxBrightnesVariance = maxBrightnesVariance; + silo.duration = duration; + + bool varSiloChanged = true; // fires each mode's internal "just switched" reset logic + stripType st = WS2812_STRIP; + + g_fakeMillis = 0; + g_fakeAnalogValue = 512; + srand(1234); // fixed seed so re-running generate.py reproduces the same GIF bytes + + // Construct AnimationHandlerBus over explicitly-zeroed storage rather than + // a plain stack allocation. This matters because AnimationHandler (its + // private base) declares `float audioRingBuffer[ringBufferSize];` with NO + // default member initializer, and its constructor never touches it - on + // the real device that class is a static/global-duration object, so the + // C++ runtime zero-initializes its storage before the constructor runs + // (audioBias, by contrast, does have an in-class initializer, so it's + // fine either way). A raw stack object here would leave audioRingBuffer + // as indeterminate stack garbage, making the audio-reactive modes + // (BASS_REACT/VU_METER/VU_METER_PEAK) noticeably non-deterministic + // depending on what a *previous* render_mode() call happened to leave on + // the stack. calloc + placement-new reproduces the real zero-init + // semantics instead. + void* busStorage = calloc(1, sizeof(AnimationHandlerBus)); + AnimationHandlerBus* bus = new (busStorage) AnimationHandlerBus(&st, stripLength, &silo, &varSiloChanged); + + // PIXEL_ARRAY (1000) doesn't animate from silo fields at all - it just + // replays whatever raw per-LED payload was last pushed via + // setPixelData() (normally delivered over the /pixels MQTT topic). Feed + // it a simple deterministic pattern - every 5th LED lit amber - purely so + // the preview isn't a blank frame; this is the one mode where the + // "silo example values" input model doesn't apply. + if(modeId == PIXEL_ARRAY){ + std::vector pattern(stripLength * 3, 0); + for(unsigned int i = 0; i < stripLength; i++){ + if(i % 5 == 0){ + pattern[i*3 + 0] = 255; + pattern[i*3 + 1] = 170; + pattern[i*3 + 2] = 0; + } + } + bus->setPixelData(pattern.data(), stripLength * 3); + } + + // BLINK_COLOR (1001) is designed to interrupt and later resume some *other* + // mode (see AnimationHandler::snapshotSilo()/savedSilo) - there's no such + // previous mode in this isolated single-mode render, so savedSilo is just + // zero-initialized. If the example params let the blink sequence finish + // within the preview's frame budget, the tail of the GIF will show it + // "resuming" into that zeroed state (mode 0/FADE_2_COLOR, black) rather + // than anything meaningful - expected here, not a bug; the resume-to-a- + // real-previous-mode behavior can only be verified on a live device or a + // dedicated harness that simulates two mode transitions, not this one. + + for(unsigned int f = 0; f < numFrames; f++){ + g_fakeMillis += frameIntervalMs; + + // Synthetic "simulated microphone" signal for the audio-reactive modes + // (BASS_REACT, VU_METER, VU_METER_PEAK): there's no real mic on a host + // machine, so drive analogRead() with a couple of summed sine waves + // plus jitter, scaled into the ESP8266 ADC's 0-1023 range. Modes that + // don't read analogRead() are unaffected. + double t = g_fakeMillis / 1000.0; + // Slow "loudness" envelope (6s period) on top of the two summed tones, + // so the audio-reactive modes visibly rise and fall over the 15s + // preview instead of saturating the gain in ~1s and holding flat. + double envelope = 0.5 + 0.5 * sin(t * 2.0 * M_PI / 6.0); + double wave = (sin(t * 2.0 * M_PI * 1.3) * 0.5 + sin(t * 2.0 * M_PI * 3.7) * 0.2) * envelope; + int jitter = (rand() % 41) - 20; // +/-20 + int analogValue = 512 + (int)(wave * 400.0) + jitter; + if(analogValue < 0) analogValue = 0; + if(analogValue > 1023) analogValue = 1023; + g_fakeAnalogValue = analogValue; + + bus->handle(); + + uint8_t* dst = outFrames + (size_t)f * stripLength * 3; + for(unsigned int i = 0; i < stripLength; i++){ + CRGB c = (gifgen_ledsPtr != nullptr && i < gifgen_ledsCount) ? gifgen_ledsPtr[i] : CRGB(0, 0, 0); + dst[i*3 + 0] = c.r; + dst[i*3 + 1] = c.g; + dst[i*3 + 2] = c.b; + } + } + + bus->~AnimationHandlerBus(); + free(busStorage); + + return 0; +} + +} // extern "C" diff --git a/tools/gifgen/requirements.txt b/tools/gifgen/requirements.txt new file mode 100644 index 0000000..7e2fba5 --- /dev/null +++ b/tools/gifgen/requirements.txt @@ -0,0 +1 @@ +Pillow diff --git a/tools/gifgen/shim/Arduino.h b/tools/gifgen/shim/Arduino.h new file mode 100644 index 0000000..5d226e5 --- /dev/null +++ b/tools/gifgen/shim/Arduino.h @@ -0,0 +1,100 @@ +// Minimal host-side stand-in for the Arduino core, just enough of a subset +// to compile the REAL AnimationHandler/AnimationHandlerBus mode code +// (software/lib/AnimationHandler, software/lib/AnimationHandlerBus) on a +// plain gcc/g++ host for tools/gifgen's doc-GIF renderer. Not a general +// Arduino emulation - only implements the exact symbols those files use +// (see tools/gifgen/README-ish notes in generate.py / the task that +// produced this). +// +// This header must resolve before any real Arduino core header could - +// tools/gifgen's build only ever puts this shim directory on the include +// path (never the real Arduino core), so there's nothing to shadow, just +// something to provide. +#ifndef GIFGEN_SHIM_ARDUINO_H +#define GIFGEN_SHIM_ARDUINO_H + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Fake time base. Real millis() is wall-clock; here it reads a global the +// harness advances one simulated frame at a time (see harness.cpp), so a +// mode's "15 real-world seconds" plays out deterministically regardless of +// how long rendering actually takes on this machine. +// --------------------------------------------------------------------------- +extern unsigned long g_fakeMillis; +inline unsigned long millis() { return g_fakeMillis; } + +// --------------------------------------------------------------------------- +// Fake analog input. Real analogRead() reads the mic on A0; here it reads a +// global the harness drives with a synthetic "simulated microphone" signal +// each frame so the audio-reactive modes (BASS_REACT, VU_METER, +// VU_METER_PEAK) have something to react to. Defaults to ESP8266's ADC +// mid-scale (0-1023 range). +// --------------------------------------------------------------------------- +extern int g_fakeAnalogValue; +#define A0 0 +inline int analogRead(int /*pin*/) { return g_fakeAnalogValue; } + +// --------------------------------------------------------------------------- +// Arduino math helpers actually used by the mode code. +// --------------------------------------------------------------------------- +template +inline T constrain_(T x, T lo, T hi) { return x < lo ? lo : (x > hi ? hi : x); } + +inline long map(long x, long in_min, long in_max, long out_min, long out_max) { + if (in_max == in_min) return out_min; + return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; +} + +// --------------------------------------------------------------------------- +// Minimal Arduino String. Only supports what this codebase actually does +// with it: building up log lines for debugFkt() out of numbers/strings via +// '+'/'+=', and .c_str(). Not a full reimplementation. +// --------------------------------------------------------------------------- +class String { + public: + String() : s_() {} + String(const char* cstr) : s_(cstr ? cstr : "") {} + String(char c) : s_(1, c) {} + String(const String& other) : s_(other.s_) {} + + String(int v) { s_ = std::to_string(v); } + String(unsigned int v) { s_ = std::to_string(v); } + String(long v) { s_ = std::to_string(v); } + String(unsigned long v) { s_ = std::to_string(v); } + + // Arduino's String(floatVal, decimalPlaces) - decimalPlaces defaults to 2. + explicit String(float v, unsigned int decimalPlaces = 2) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", decimalPlaces, (double)v); + s_ = buf; + } + explicit String(double v, unsigned int decimalPlaces = 2) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", decimalPlaces, v); + s_ = buf; + } + + const char* c_str() const { return s_.c_str(); } + size_t length() const { return s_.size(); } + + String& operator+=(const String& rhs) { s_ += rhs.s_; return *this; } + String& operator+=(const char* rhs) { s_ += rhs; return *this; } + + String operator+(const String& rhs) const { return String((s_ + rhs.s_).c_str()); } + String operator+(const char* rhs) const { return String((s_ + rhs).c_str()); } + + private: + std::string s_; +}; + +inline String operator+(const char* lhs, const String& rhs) { + return String((std::string(lhs) + rhs.c_str()).c_str()); +} + +#endif diff --git a/tools/gifgen/shim/FastLED.h b/tools/gifgen/shim/FastLED.h new file mode 100644 index 0000000..2f4ec99 --- /dev/null +++ b/tools/gifgen/shim/FastLED.h @@ -0,0 +1,220 @@ +// Minimal host-side stand-in for FastLED, just enough of a subset to compile +// the REAL AnimationHandlerBus mode code (software/lib/AnimationHandlerBus) +// on a plain gcc/g++ host for tools/gifgen's doc-GIF renderer. Reimplements +// the handful of FastLED primitives those files actually call - see the +// grep sweep in the task that produced this file for the exact symbol list. +// +// HeatColor() intentionally reimplements FastLED's real 3-stage +// black->red->orange->yellow->white ramp (scale8_video + the 3-band split) +// since fire.cpp's look depends on it. fill_rainbow()/blend()/etc. are +// "close enough for a documentation preview" reimplementations, not +// pixel-identical to FastLED's own approximations. +#ifndef GIFGEN_SHIM_FASTLED_H +#define GIFGEN_SHIM_FASTLED_H + +#include +#include +#include + +// --------------------------------------------------------------------------- +// CRGB +// --------------------------------------------------------------------------- +struct CRGB { + uint8_t r, g, b; + + CRGB() : r(0), g(0), b(0) {} + CRGB(uint8_t r_, uint8_t g_, uint8_t b_) : r(r_), g(g_), b(b_) {} + + static const CRGB Black; + static const CRGB White; + static const CRGB Blue; + + bool operator==(const CRGB& o) const { return r == o.r && g == o.g && b == o.b; } + bool operator!=(const CRGB& o) const { return !(*this == o); } +}; + +inline const CRGB CRGB::Black{0, 0, 0}; +inline const CRGB CRGB::White{255, 255, 255}; +inline const CRGB CRGB::Blue{0, 0, 255}; + +// --------------------------------------------------------------------------- +// Dummy chipset / color-order tag types - only need to exist so the +// FastLED.addLeds(...) / +// template calls compile. Behavior-wise they're inert on the host: the +// "strip" is just the CRGB buffer itself, there's no real bus to drive. +// --------------------------------------------------------------------------- +struct WS2812 {}; +struct APA102 {}; +struct GRB {}; + +// --------------------------------------------------------------------------- +// scale8 / scale8_video - FastLED's actual 8-bit fixed-point scaling helpers, +// needed for a faithful HeatColor(). +// --------------------------------------------------------------------------- +inline uint8_t scale8(uint8_t i, uint8_t scale) { + return (uint8_t)(((uint16_t)i * (uint16_t)scale) >> 8); +} + +inline uint8_t scale8_video(uint8_t i, uint8_t scale) { + uint8_t j = (uint8_t)(((int)i * (int)scale) >> 8); + if (i && scale) j++; + return j; +} + +// --------------------------------------------------------------------------- +// qsub8 / qadd8 - saturating 8-bit subtract/add. +// --------------------------------------------------------------------------- +inline uint8_t qsub8(uint8_t a, uint8_t b) { return (a > b) ? (uint8_t)(a - b) : 0; } +inline uint8_t qadd8(uint8_t a, uint8_t b) { + unsigned int sum = (unsigned int)a + (unsigned int)b; + return sum > 255 ? 255 : (uint8_t)sum; +} + +// --------------------------------------------------------------------------- +// HeatColor - FastLED's real fire-palette ramp: black -> red -> orange -> +// yellow -> white, in three equal bands of the input range. +// --------------------------------------------------------------------------- +inline CRGB HeatColor(uint8_t temperature) { + CRGB heatcolor; + + uint8_t t192 = scale8_video(temperature, 191); + uint8_t heatramp = t192 & 0x3F; // 0..63 + heatramp <<= 2; // scale up to 0..252 + + if (t192 & 0x80) { + // hottest third: full red, full green, ramp blue + heatcolor.r = 255; + heatcolor.g = 255; + heatcolor.b = heatramp; + } else if (t192 & 0x40) { + // middle third: full red, ramp green, no blue + heatcolor.r = 255; + heatcolor.g = heatramp; + heatcolor.b = 0; + } else { + // coolest third: ramp red, no green, no blue + heatcolor.r = heatramp; + heatcolor.g = 0; + heatcolor.b = 0; + } + return heatcolor; +} + +// --------------------------------------------------------------------------- +// blend - linear per-channel interpolation between two colors. +// --------------------------------------------------------------------------- +inline CRGB blend(const CRGB& a, const CRGB& b, uint8_t amountOfB) { + uint16_t amt = amountOfB; + CRGB out; + out.r = (uint8_t)(((uint16_t)a.r * (255 - amt) + (uint16_t)b.r * amt) / 255); + out.g = (uint8_t)(((uint16_t)a.g * (255 - amt) + (uint16_t)b.g * amt) / 255); + out.b = (uint8_t)(((uint16_t)a.b * (255 - amt) + (uint16_t)b.b * amt) / 255); + return out; +} + +// --------------------------------------------------------------------------- +// fill_solid / fadeToBlackBy +// --------------------------------------------------------------------------- +inline void fill_solid(CRGB* leds, int count, const CRGB& color) { + for (int i = 0; i < count; i++) leds[i] = color; +} + +inline void fadeToBlackBy(CRGB* leds, int count, uint8_t fadeBy) { + uint8_t keep = 255 - fadeBy; + for (int i = 0; i < count; i++) { + leds[i].r = scale8(leds[i].r, keep); + leds[i].g = scale8(leds[i].g, keep); + leds[i].b = scale8(leds[i].b, keep); + } +} + +// --------------------------------------------------------------------------- +// fill_rainbow - reasonable hue sweep (full saturation/value color wheel). +// Not pixel-identical to FastLED's own HSV approximation, which is fine for +// a documentation preview. +// --------------------------------------------------------------------------- +inline CRGB gifgen_hueToRGB(uint8_t hue) { + uint8_t region = hue / 43; // 0..5 + uint8_t remainder = (hue - region * 43) * 6; + uint8_t q = 255 - remainder; + switch (region) { + case 0: return CRGB(255, remainder, 0); + case 1: return CRGB(q, 255, 0); + case 2: return CRGB(0, 255, remainder); + case 3: return CRGB(0, q, 255); + case 4: return CRGB(remainder, 0, 255); + default: return CRGB(255, 0, q); + } +} + +inline void fill_rainbow(CRGB* leds, int count, uint8_t startHue, uint8_t deltaHue) { + uint8_t hue = startHue; + for (int i = 0; i < count; i++) { + leds[i] = gifgen_hueToRGB(hue); + hue = (uint8_t)(hue + deltaHue); + } +} + +// --------------------------------------------------------------------------- +// cos16 - FastLED's 16-bit-angle cosine, used by breathe(). Doesn't need to +// bit-match FastLED's own lookup-table approximation, just be a proper +// cosine over the same [0, 65536) -> [-32767, 32767] mapping. +// --------------------------------------------------------------------------- +inline int16_t cos16(uint16_t theta) { + double rad = (theta / 65536.0) * 2.0 * M_PI; + return (int16_t)(cos(rad) * 32767.0); +} + +// --------------------------------------------------------------------------- +// random8 / random16 - thin wrappers over rand(), seeded once by the +// harness (see harness.cpp's render_mode(), which reseeds deterministically +// per render so GIFs are reproducible across runs). +// --------------------------------------------------------------------------- +inline uint8_t random8() { return (uint8_t)(rand() & 0xFF); } +inline uint8_t random8(uint8_t max) { + if (max == 0) return 0; + return (uint8_t)(rand() % max); +} +inline uint8_t random8(uint8_t min, uint8_t max) { + if (max <= min) return min; + return (uint8_t)(min + (rand() % (max - min))); +} +// Real FastLED signature is uint16_t random16(uint16_t, uint16_t); callers +// in this codebase sometimes pass negative int literals (e.g. +// random16(-silo->timeVariance, silo->timeVariance) in breathe.cpp), which +// wrap the same way here as they would against the real FastLED header. +inline uint16_t random16(uint16_t min, uint16_t max) { + if (max <= min) return min; + return (uint16_t)(min + (rand() % (uint16_t)(max - min))); +} + +// --------------------------------------------------------------------------- +// Dummy FastLED global object. addLeds<...>() just records the leds +// pointer/count for the harness to read directly (see gifgen_ledsPtr / +// gifgen_ledsCount below) - avoids needing any access to +// AnimationHandlerBus's private `leds` member. show() is a no-op; the +// harness reads the buffer straight out of memory after each handle() call. +// --------------------------------------------------------------------------- +inline CRGB* gifgen_ledsPtr = nullptr; +inline unsigned int gifgen_ledsCount = 0; + +struct CFastLED { + // WS2812-style clockless chipsets: addLeds + template + void addLeds(CRGB* leds, int count) { + gifgen_ledsPtr = leds; + gifgen_ledsCount = (unsigned int)count; + } + // APA102-style SPI chipsets: addLeds + template + void addLeds(CRGB* leds, int count) { + gifgen_ledsPtr = leds; + gifgen_ledsCount = (unsigned int)count; + } + + void show() { /* no-op: harness reads gifgen_ledsPtr directly */ } +}; + +inline CFastLED FastLED; + +#endif