From acafe34cff557f0bbdd130993bff6ab84eb8c844 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:09:39 -0400 Subject: [PATCH 1/2] TreeHouse controllers: run standalone, add the Garage welder Every controller now animates from a fully-active Garden State instead of what the Pi sends, so each channel holds its max_level permanently. The plumbing stays: state is still received and logged, and kFollowGardenState in main.cpp hands control back (ADR-0021). - Garage (new): a cap-discharge arc on a Trigger channel, fired by a Welder in random sessions (8-25 s apart), bursts (1-5 s) and taps (20-50 ms); 16 SK6812 pixels flash violet-white under each tap and cool through orange to dark. Pins are placeholders. garage-selftest added. - Dormer: 12.5 s breath between 40% and 80%. - Julia: drunk walk between 40% and 80%, about a minute per sweep. - Swannatopia: Chandelier now matches the warm incandescent Overhead. - network.json gains treehouse_garage (192.168.1.64). Co-Authored-By: Claude Opus 5.5 --- Firmware/TreeHouse_Controllers/README.md | 45 +++-- .../include/net_config.h | 5 + .../lib/GardenState/GardenState.cpp | 9 + .../lib/GardenState/GardenState.h | 5 + .../lib/Patterns/Patterns.cpp | 103 ++++++++++- .../lib/Patterns/Patterns.h | 37 +++- Firmware/TreeHouse_Controllers/lib/Rng/Rng.h | 46 +++++ .../TreeHouse_Controllers/lib/Weld/Weld.cpp | 54 ++++++ .../TreeHouse_Controllers/lib/Weld/Weld.h | 65 +++++++ Firmware/TreeHouse_Controllers/platformio.ini | 10 +- .../TreeHouse_Controllers/src/Outputs.cpp | 26 +++ Firmware/TreeHouse_Controllers/src/Outputs.h | 28 ++- .../TreeHouse_Controllers/src/SelfTest.cpp | 33 +++- Firmware/TreeHouse_Controllers/src/SelfTest.h | 5 +- Firmware/TreeHouse_Controllers/src/main.cpp | 64 +++++-- .../src/targets/dormer.h | 17 +- .../src/targets/garage.h | 67 ++++++++ .../TreeHouse_Controllers/src/targets/julia.h | 14 +- .../src/targets/swannatopia.h | 51 +++--- .../src/targets/target.h | 2 + .../test/test_patterns/test_patterns.cpp | 160 ++++++++++++++++++ .../test/test_weld/test_weld.cpp | 147 ++++++++++++++++ ShowControl/TreeHouse/settings.json | 3 +- ShowControl/network.json | 3 +- ...-treehouse-esp32s3-location-controllers.md | 2 +- ...ontrollers-standalone-and-garage-welder.md | 99 +++++++++++ docs/agents/firmware.md | 4 +- scripts/hooks/firmware_config_gen.py | 1 + 28 files changed, 1014 insertions(+), 91 deletions(-) create mode 100644 Firmware/TreeHouse_Controllers/lib/Rng/Rng.h create mode 100644 Firmware/TreeHouse_Controllers/lib/Weld/Weld.cpp create mode 100644 Firmware/TreeHouse_Controllers/lib/Weld/Weld.h create mode 100644 Firmware/TreeHouse_Controllers/src/targets/garage.h create mode 100644 Firmware/TreeHouse_Controllers/test/test_weld/test_weld.cpp create mode 100644 docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md diff --git a/Firmware/TreeHouse_Controllers/README.md b/Firmware/TreeHouse_Controllers/README.md index 4966be8..a4677ee 100644 --- a/Firmware/TreeHouse_Controllers/README.md +++ b/Firmware/TreeHouse_Controllers/README.md @@ -1,18 +1,24 @@ # TreeHouse location controllers -Firmware for the four ESP32-S3 controllers that light the TreeHouse — one per +Firmware for the five ESP32-S3 controllers that light the TreeHouse — one per location. See [ADR-0020](../../docs/adr/0020-treehouse-esp32s3-location-controllers.md) -for why it is built this way. - -| Environment | Location | Channels | Static IP | -|---|---|---|---| -| `swannatopia` | Swannatopia | 3 × SK6812 RGBW (8 px each) | 192.168.1.60 | -| `julia` | Julia | 1 × PWM MOSFET (12 V filaments) | 192.168.1.61 | -| `jess` | Jess | 2 × SK6812 RGBW (20 px each) + 1 × PWM MOSFET flash | 192.168.1.62 | -| `dormer` | Dormer | 1 × PWM MOSFET (12 V) | 192.168.1.63 | - -The Pi sends **Garden State**, not pixels. Each controller animates its own -channels, so a lost packet or a dropped access point costs nothing. +for why it is built this way, and +[ADR-0021](../../docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md) +for why it currently ignores the Pi. + +| Environment | Location | Channels | Look | Static IP | +|---|---|---|---|---| +| `swannatopia` | Swannatopia | 3 × SK6812 RGBW | fireplace fire; two warm incandescent overheads | 192.168.1.60 | +| `julia` | Julia | 1 × PWM MOSFET (12 V filaments) | slow drunk walk, 40–80% | 192.168.1.61 | +| `jess` | Jess | 2 × SK6812 RGBW + 1 × PWM MOSFET flash | 4-burst strobe over two strips | 192.168.1.62 | +| `dormer` | Dormer | 1 × PWM MOSFET (12 V) | 12.5 s breath, 40–80% | 192.168.1.63 | +| `garage` | Garage | 1 × cap-discharge arc trigger + 16 × SK6812 RGBW | random welding sessions | 192.168.1.64 | + +The Pi sends **Garden State**, not pixels, and each controller animates its own +channels. **For now the controllers ignore it** (ADR-0021): they animate from +`cg::fullyActive()`, so every channel sits at its `max_level` permanently. +Garden State is still received and logged. Set `kFollowGardenState` in +`src/main.cpp` to `true` to hand control back to the Pi. ## First build @@ -82,6 +88,10 @@ The sequence loops: | Walk | 150 ms per pixel | one white pixel at a time, head to tail | blinking | | Dark | 1 s | off | off | +Trigger channels (the Garage arc) fire one 5 ms tap at the start of red, green +and blue, then a four-tap burst 40 ms apart at the start of white. A single pop +proves the circuit; the burst proves the cap recharges fast enough to weld. + Strips run at 35 %, and dimmers are still capped by that channel's `max_level`. Proving a strip works should not require peak current from a bench supply, and the Jess flash channel at full power is genuinely painful to look at. @@ -138,7 +148,10 @@ this reason; `src/` is the hardware binding and is not part of the test build. ## Bench testing without the Pi -Any OSC sender works — the controller listens on the Fabric addresses directly: +While `kFollowGardenState` is `false` (ADR-0021) the controllers parse and log +these but do not react — the heartbeat line shows `(standalone)`. With it set +to `true`, any OSC sender works — the controller listens on the Fabric addresses +directly: ```bash oscsend osc.udp://192.168.1.62:9000 /flowerbeds/activity f 0.8 @@ -154,6 +167,8 @@ the staleness fallback, not a fault. ## Retuning a location Every look decision for a location is in one header, `src/targets/.h`: -LED counts, colours, pattern choice, and the per-channel Signal Bag weights over -Garden State. Edit and reflash. There is no runtime configuration — see the +LED counts, colours, pattern choice, floor and ceiling (`min_level`, +`max_level`), and the per-channel Signal Bag weights over Garden State. The +Garage's welding rhythm — session, burst and tap gaps, and the arc pulse +width — is the `.weld` block in `garage.h`, along with its placeholder pins. Edit and reflash. There is no runtime configuration — see the trade-off in ADR-0020. diff --git a/Firmware/TreeHouse_Controllers/include/net_config.h b/Firmware/TreeHouse_Controllers/include/net_config.h index 15048ca..d04ab8a 100644 --- a/Firmware/TreeHouse_Controllers/include/net_config.h +++ b/Firmware/TreeHouse_Controllers/include/net_config.h @@ -25,3 +25,8 @@ #define CG_IP_DORMER { 192, 168, 1, 63 } #define CG_IP_STR_DORMER "192.168.1.63" #define CG_OSC_PORT_DORMER 9000 + +// treehouse_garage +#define CG_IP_GARAGE { 192, 168, 1, 64 } +#define CG_IP_STR_GARAGE "192.168.1.64" +#define CG_OSC_PORT_GARAGE 9000 diff --git a/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.cpp b/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.cpp index 44f914e..6027265 100644 --- a/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.cpp +++ b/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.cpp @@ -25,6 +25,15 @@ float GardenState::masterBrightness() const { } } +GardenState fullyActive() { + GardenState state; + state.flowerbeds_activity = 1.0f; + state.captcha_intensity = 1.0f; + state.pipes_activity = 1.0f; + state.show_mode = ShowMode::Active; + return state; +} + void GardenStateStore::handle(const osc::Message& message, uint32_t now_ms) { bool recognised = true; diff --git a/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.h b/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.h index 8358729..e9527fe 100644 --- a/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.h +++ b/Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.h @@ -31,6 +31,11 @@ struct GardenState { float masterBrightness() const; }; +// Every Element at full tilt and the show Active. Controllers animate from +// this instead of what they receive, so each channel sits at its max_level +// permanently (ADR-0021) — the received state is still kept, just unused. +GardenState fullyActive(); + // Default staleness horizon. Two missed heartbeats at the Pi's 5 s interval. constexpr uint32_t kDefaultStaleMs = 10000; diff --git a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp index 32f631f..3c1f1be 100644 --- a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp +++ b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp @@ -54,12 +54,44 @@ float flicker(float time_s, float offset) { // because a stale controller can no longer trust its drive value. A pattern // that never reads drive has nothing to fall back from, so it keeps running // as it is — see patternLevel(). -bool usesDrive(PatternId pattern) { return pattern != PatternId::Flash; } +bool usesDrive(PatternId pattern) { + return pattern != PatternId::Flash && pattern != PatternId::Weld; +} + +// Fire and Weld are the only patterns that read ChannelSpec::base_hot. +// Everything else scales a single base colour, so a channel leaving base_hot at +// its default renders exactly as it did before the ramp existed. +bool usesRamp(PatternId pattern) { + return pattern == PatternId::Fire || pattern == PatternId::Weld; +} + +// FNV-1a over the channel name: a fixed per-channel seed, so two wandering +// channels on one controller do not walk in lockstep. +uint32_t nameSeed(const char* name) { + uint32_t h = 2166136261u; + for (const char* c = name; c != nullptr && *c != '\0'; ++c) { + h ^= static_cast(*c); + h *= 16777619u; + } + return h; +} + +float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); } + +// Wander: how long a leg takes is its distance times the full-sweep time, +// stretched or squeezed by up to 30% so the pace is as unsteady as the path. +// Short legs still take a few seconds — a stumble, not a twitch. +constexpr float kWanderSweepS = 60.0f; // at speed 1.0 +constexpr float kWanderPaceJitter = 0.3f; +constexpr float kWanderMinLegS = 4.0f; -// Fire is the only pattern that reads ChannelSpec::base_hot. Everything else -// scales a single base colour, so a channel leaving base_hot at its default -// renders exactly as it did before the ramp existed. -bool usesRamp(PatternId pattern) { return pattern == PatternId::Fire; } +// Weld: the arc is gone in a blink, the metal it heated takes seconds to cool. +// Each tap adds heat to the metal, so a long burst glows longer than one pop — +// but the metal never gets as bright as the arc, or the flash stops reading. +constexpr float kArcFlashTauS = 0.12f; +constexpr float kArcGlowTauS = 2.5f; +constexpr float kArcGlowPerTap = 0.2f; +constexpr float kArcGlowCeiling = 0.4f; // Flash: four 250 ms bursts, evenly spaced, then a four-second pause. constexpr float kFlashOnS = 0.25f; @@ -96,6 +128,33 @@ void ChannelAnimator::configure(const ChannelSpec& spec) { master_ = 1.0f; level_ = 0.0f; stale_ = false; + rng_.reseed(nameSeed(spec.name)); + wander_from_ = 0.5f; + wander_to_ = 0.5f; + wander_t_ = 1.0f; + wander_leg_s_ = 0.0f; + flash_ = 0.0f; + glow_ = 0.0f; +} + +void ChannelAnimator::strike() { + flash_ = 1.0f; + glow_ += kArcGlowPerTap; + if (glow_ > 1.0f) glow_ = 1.0f; +} + +void ChannelAnimator::updateWander(float dt) { + if (spec_.speed <= 0.0f) return; + wander_t_ += wander_leg_s_ > 0.0f ? dt / wander_leg_s_ : 1.0f; + if (wander_t_ < 1.0f) return; + + wander_from_ = wander_to_; + wander_to_ = rng_.unit(); + const float distance = std::fabs(wander_to_ - wander_from_); + const float pace = rng_.range(1.0f - kWanderPaceJitter, 1.0f + kWanderPaceJitter); + wander_leg_s_ = distance * (kWanderSweepS / spec_.speed) * pace; + if (wander_leg_s_ < kWanderMinLegS) wander_leg_s_ = kWanderMinLegS; + wander_t_ = 0.0f; } void ChannelAnimator::update(float dt, const GardenState& state, bool stale) { @@ -130,6 +189,10 @@ void ChannelAnimator::update(float dt, const GardenState& state, bool stale) { phase_ += dt * spec_.speed * (0.25f + drive_); phase_ -= std::floor(phase_); + if (spec_.pattern == PatternId::Wander) updateWander(dt); + if (flash_ > 0.0f) flash_ *= std::exp(-dt / kArcFlashTauS); + if (glow_ > 0.0f) glow_ *= std::exp(-dt / kArcGlowTauS); + if (spec_.kind == ChannelKind::Dimmer) { level_ = clamp01(patternLevel(0)) * master_; } @@ -189,9 +252,15 @@ float ChannelAnimator::patternLevel(uint16_t index) const { } case PatternId::Breathe: { + // Configured, it swings between the floor and the drive — so min_level + // is the darkest it ever gets. As the stale fallback it has no drive to + // trust and swells gently at idle_level instead. const float swell = 0.5f + 0.5f * std::sin(kTwoPi * phase_); - const float amplitude = stale_ ? spec_.idle_level : drive_; - value = amplitude * (0.35f + 0.65f * swell); + if (stale_) { + value = spec_.idle_level * (0.35f + 0.65f * swell); + } else { + value = spec_.min_level + (drive_ - spec_.min_level) * swell; + } break; } @@ -213,6 +282,26 @@ float ChannelAnimator::patternLevel(uint16_t index) const { value = lit ? spec_.max_level : 0.0f; break; } + + case PatternId::Wander: { + const float t = wander_t_ < 1.0f ? wander_t_ : 1.0f; + const float u = wander_from_ + (wander_to_ - wander_from_) * smoothstep(t); + value = spec_.min_level + (drive_ - spec_.min_level) * u; + break; + } + + case PatternId::Weld: { + // Each pixel sparkles on its own offset while the arc is lit — fast + // enough (14–34 Hz) to read as crackle behind frosted acrylic — then the + // whole run settles into a slow, uneven cooling glow. Like Flash it is + // drive-free and runs at the ceiling: the taps are the only input. + const float sparkle = flicker(time_s_ * 3.0f, pixelNoise(index, 11u)); + const float arc = flash_ * (0.55f + 0.45f * sparkle); + const float shimmer = flicker(time_s_ * 0.5f, pixelNoise(index, 13u)); + const float metal = kArcGlowCeiling * glow_ * (0.85f + 0.15f * shimmer); + value = spec_.max_level * (arc > metal ? arc : metal); + break; + } } // The Blow-Up Reaction overrides whatever the pattern wanted, then decays diff --git a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h index 78e8738..89a38b6 100644 --- a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h +++ b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h @@ -11,6 +11,8 @@ #include #include "GardenState.h" +#include "Rng.h" +#include "Weld.h" namespace cg { @@ -24,6 +26,8 @@ struct Rgbw { enum class ChannelKind { Strip, // SK6812 RGBW Dimmer, // PWM MOSFET + Trigger, // MOSFET dumping a charged cap: fired by pulses, never dimmed. + // Driven by a Welder from ChannelSpec::weld, not by a pattern. }; enum class PatternId { @@ -32,10 +36,15 @@ enum class PatternId { Chase, // lit head with a decaying tail, speed follows drive Mycelium, // travelling wave along the strip Fire, // per-pixel heat, hottest at the hearth, ramped base -> base_hot - Breathe, // slow uniform swell — also the idle fallback + Breathe, // slow uniform swell between min_level and drive — also the + // idle fallback, where it swells at idle_level instead Filament, // dimmer: slow-slewing level with a hint of flicker Flash, // dimmer: binary strobe — a burst of blinks, then a dark pause; // drive-free, so it runs unchanged when Garden State is stale + Wander, // drunk walk between min_level and drive: eases to a random + // level, then picks another; a full sweep takes 60 / speed s + Weld, // strip: dark until strike(), then an arc-white flash that + // cools through base_hot -> base and fades back to dark }; // Per-channel weighting over Garden State. Weights need not sum to 1 — the @@ -55,16 +64,17 @@ struct ChannelSpec { ChannelKind kind = ChannelKind::Strip; uint8_t pin = 0; uint16_t pixel_count = 0; // strips only - Rgbw base; // strips: the channel's colour at full drive - Rgbw base_hot = Rgbw{}; // strips: the hot end of the colour ramp — Fire only, - // ignored by the patterns that scale `base` alone + Rgbw base = Rgbw{}; // strips: the channel's colour at full drive + Rgbw base_hot = Rgbw{}; // strips: the hot end of the colour ramp — Fire and + // Weld only, ignored by the patterns that scale `base` PatternId pattern = PatternId::Solid; - Weights weights; + Weights weights = Weights{}; float min_level = 0.0f; // drive floor while the show is running float max_level = 1.0f; // drive ceiling — caps a too-bright fixture float speed = 1.0f; // pattern-specific rate multiplier float smoothing_s = 0.5f; // EMA time constant applied to drive float idle_level = 0.15f; // breathe amplitude when Garden State is stale + WeldTiming weld = WeldTiming{}; // Trigger only: when the arc fires }; // How hard and how long the Blow-Up Reaction hits: spike to full, then decay @@ -83,6 +93,13 @@ class ChannelAnimator { // and swaps the channel over to its idle behaviour. void update(float dt, const GardenState& state, bool stale); + // Patterns that wander draw from this. configure() seeds it from the + // channel name, so the host tests replay; hardware reseeds from esp_random(). + void seed(uint32_t value) { rng_.reseed(value); } + + // One arc tap landed. Only Weld reads it; every other pattern ignores it. + void strike(); + // Dimmer channels: the 0–1 level to write to PWM, master brightness applied. float level() const { return level_; } @@ -94,6 +111,7 @@ class ChannelAnimator { private: float patternLevel(uint16_t index) const; + void updateWander(float dt); ChannelSpec spec_; float time_s_ = 0.0f; @@ -103,6 +121,15 @@ class ChannelAnimator { float master_ = 1.0f; float level_ = 0.0f; bool stale_ = false; + + Rng rng_; + float wander_from_ = 0.5f; // Wander: 0–1 position within [min_level, drive] + float wander_to_ = 0.5f; + float wander_t_ = 1.0f; // progress through the current leg, 0–1 + float wander_leg_s_ = 0.0f; + + float flash_ = 0.0f; // Weld: the arc itself, gone in a blink + float glow_ = 0.0f; // Weld: the metal it heated, cooling over seconds }; } // namespace cg diff --git a/Firmware/TreeHouse_Controllers/lib/Rng/Rng.h b/Firmware/TreeHouse_Controllers/lib/Rng/Rng.h new file mode 100644 index 0000000..bc06d01 --- /dev/null +++ b/Firmware/TreeHouse_Controllers/lib/Rng/Rng.h @@ -0,0 +1,46 @@ +// A tiny deterministic PRNG for patterns that need to wander. +// +// xorshift32: four instructions a draw, no heap, no global state, and the same +// sequence on the host as on the S3 — so a seeded animator replays exactly in +// the native tests. On hardware, seed from esp_random() to vary boot to boot. +// +// No Arduino headers: this is arithmetic, and it is tested on the host. + +#pragma once + +#include + +namespace cg { + +class Rng { + public: + explicit Rng(uint32_t seed = 1u) { reseed(seed); } + + // xorshift has one fixed point, zero, so a zero seed is remapped rather than + // left to produce zeros forever. + void reseed(uint32_t seed) { state_ = seed != 0u ? seed : 0x9E3779B9u; } + + uint32_t next() { + state_ ^= state_ << 13; + state_ ^= state_ >> 17; + state_ ^= state_ << 5; + return state_; + } + + // Uniform in [0, 1). + float unit() { return static_cast(next() >> 8) / 16777216.0f; } + + // Uniform in [lo, hi). + float range(float lo, float hi) { return lo + (hi - lo) * unit(); } + + // Uniform in [lo, hi], inclusive at both ends. + uint32_t between(uint32_t lo, uint32_t hi) { + if (hi <= lo) return lo; + return lo + next() % (hi - lo + 1u); + } + + private: + uint32_t state_ = 1u; +}; + +} // namespace cg diff --git a/Firmware/TreeHouse_Controllers/lib/Weld/Weld.cpp b/Firmware/TreeHouse_Controllers/lib/Weld/Weld.cpp new file mode 100644 index 0000000..aed1ba7 --- /dev/null +++ b/Firmware/TreeHouse_Controllers/lib/Weld/Weld.cpp @@ -0,0 +1,54 @@ +#include "Weld.h" + +namespace cg { +namespace { + +// Wrap-safe "has `now` reached `due`": millis() rolls over every 49.7 days, +// and a Garage left running that long must not stall for another 49.7. +bool reached(uint32_t now_ms, uint32_t due_ms) { + return static_cast(now_ms - due_ms) >= 0; +} + +} // namespace + +void Welder::configure(const WeldTiming& timing, uint32_t seed, uint32_t now_ms) { + timing_ = timing; + rng_.reseed(seed); + bursts_left_ = 0; + taps_left_ = 0; + taps_fired_ = 0; + next_ms_ = now_ms + rng_.between(timing_.session_gap_min_ms, timing_.session_gap_max_ms); +} + +bool Welder::update(uint32_t now_ms) { + if (!reached(now_ms, next_ms_)) return false; + + // Due, and nothing left to fire: the quiet gap just ended, so a new session + // starts here and this call is its first tap. + if (!inSession()) { + bursts_left_ = rng_.between(timing_.bursts_min, timing_.bursts_max); + } + if (taps_left_ == 0) { + taps_left_ = rng_.between(timing_.taps_min, timing_.taps_max); + if (bursts_left_ > 0) --bursts_left_; + } + + --taps_left_; + ++taps_fired_; + scheduleAfterTap(now_ms); + return true; +} + +void Welder::scheduleAfterTap(uint32_t now_ms) { + uint32_t gap; + if (taps_left_ > 0) { + gap = rng_.between(timing_.tap_gap_min_ms, timing_.tap_gap_max_ms); + } else if (bursts_left_ > 0) { + gap = rng_.between(timing_.burst_gap_min_ms, timing_.burst_gap_max_ms); + } else { + gap = rng_.between(timing_.session_gap_min_ms, timing_.session_gap_max_ms); + } + next_ms_ = now_ms + gap; +} + +} // namespace cg diff --git a/Firmware/TreeHouse_Controllers/lib/Weld/Weld.h b/Firmware/TreeHouse_Controllers/lib/Weld/Weld.h new file mode 100644 index 0000000..855cad8 --- /dev/null +++ b/Firmware/TreeHouse_Controllers/lib/Weld/Weld.h @@ -0,0 +1,65 @@ +// The Garage welding schedule: when the arc light fires. +// +// The arc is a MOSFET dumping a charged capacitor through a very bright LED, so +// the firmware's only job is to say *when* — a short high pulse on the gate is +// one tap. Taps come in bursts, bursts come in sessions, and the gaps at every +// level are random, because the effect only works if it surprises people: +// +// session gap (quiet) -> burst -> burst gap -> burst ... -> session gap +// tap tap tap +// +// Timing is integer milliseconds and the Welder is polled every loop(), not +// every frame: a 20 ms tap gap is barely more than one 16 ms frame, and frame +// jitter would smear the stutter that sells it. +// +// No Arduino headers: this is arithmetic, and it is tested on the host. + +#pragma once + +#include + +#include "Rng.h" + +namespace cg { + +struct WeldTiming { + uint32_t session_gap_min_ms = 8000; // quiet between welding sessions + uint32_t session_gap_max_ms = 25000; + uint32_t bursts_min = 2; // bursts in one session + uint32_t bursts_max = 6; + uint32_t burst_gap_min_ms = 1000; // between bursts inside a session + uint32_t burst_gap_max_ms = 5000; + uint32_t taps_min = 1; // taps in one burst + uint32_t taps_max = 4; + uint32_t tap_gap_min_ms = 20; // between taps inside a burst; the + uint32_t tap_gap_max_ms = 50; // cap has to recharge in this window + uint32_t pulse_ms = 5; // gate high time for one tap +}; + +class Welder { + public: + // Starts with a quiet session gap, so a freshly booted Garage never opens on + // a flash before anyone is looking. + void configure(const WeldTiming& timing, uint32_t seed, uint32_t now_ms); + + // Call as often as possible. Returns true exactly once per tap, on the + // first call at or after it is due. The next gap is measured from that call + // rather than from the due time, so a late loop can never fire two taps + // closer together than tap_gap_min_ms — the cap would not have recharged. + bool update(uint32_t now_ms); + + uint32_t taps() const { return taps_fired_; } + bool inSession() const { return bursts_left_ > 0 || taps_left_ > 0; } + + private: + void scheduleAfterTap(uint32_t now_ms); + + WeldTiming timing_; + Rng rng_; + uint32_t next_ms_ = 0; + uint32_t bursts_left_ = 0; // bursts still to start in this session + uint32_t taps_left_ = 0; // taps still to fire in this burst + uint32_t taps_fired_ = 0; +}; + +} // namespace cg diff --git a/Firmware/TreeHouse_Controllers/platformio.ini b/Firmware/TreeHouse_Controllers/platformio.ini index 34670b8..dfc23f7 100644 --- a/Firmware/TreeHouse_Controllers/platformio.ini +++ b/Firmware/TreeHouse_Controllers/platformio.ini @@ -1,7 +1,7 @@ ; TreeHouse location controllers — one ESP32-S3 per location (ADR-0020). ; ; Pick an environment in the PlatformIO switcher (or `pio run -e jess -t upload`) -; to build for that location. All four share the code in src/ and lib/; only +; to build for that location. All five share the code in src/ and lib/; only ; src/targets/.h differs. ; ; Before the first build: @@ -63,6 +63,10 @@ build_flags = ${esp32s3.build_flags} -D CG_TARGET_JESS extends = esp32s3 build_flags = ${esp32s3.build_flags} -D CG_TARGET_DORMER +[env:garage] +extends = esp32s3 +build_flags = ${esp32s3.build_flags} -D CG_TARGET_GARAGE + ; --------------------------------------------------------------------------- ; Hardware self-test builds. No WiFi, no Garden State, no secrets.h — just a ; fixed colour sequence on every channel, so a fixture that stays dark can only @@ -89,6 +93,10 @@ build_flags = ${esp32s3.build_flags} -D CG_TARGET_JESS -D CG_SELFTEST extends = selftest build_flags = ${esp32s3.build_flags} -D CG_TARGET_DORMER -D CG_SELFTEST +[env:garage-selftest] +extends = selftest +build_flags = ${esp32s3.build_flags} -D CG_TARGET_GARAGE -D CG_SELFTEST + ; --------------------------------------------------------------------------- ; Host tests: `pio test -e native`. Only lib/ is compiled — everything in src/ ; touches Arduino hardware and is deliberately left out. diff --git a/Firmware/TreeHouse_Controllers/src/Outputs.cpp b/Firmware/TreeHouse_Controllers/src/Outputs.cpp index f21bc69..575bb80 100644 --- a/Firmware/TreeHouse_Controllers/src/Outputs.cpp +++ b/Firmware/TreeHouse_Controllers/src/Outputs.cpp @@ -115,4 +115,30 @@ void DimmerOutput::write(float level) { #endif } +bool TriggerOutput::begin(uint8_t pin) { + pin_ = pin; + pinMode(pin_, OUTPUT); + ready_ = true; + off(); + return true; +} + +void TriggerOutput::fire(uint32_t now_ms, uint32_t pulse_ms) { + if (!ready_) return; + digitalWrite(pin_, HIGH); + high_ = true; + high_since_ms_ = now_ms; + pulse_ms_ = pulse_ms; +} + +void TriggerOutput::update(uint32_t now_ms) { + if (high_ && now_ms - high_since_ms_ >= pulse_ms_) off(); +} + +void TriggerOutput::off() { + if (!ready_) return; + digitalWrite(pin_, LOW); + high_ = false; +} + } // namespace cg diff --git a/Firmware/TreeHouse_Controllers/src/Outputs.h b/Firmware/TreeHouse_Controllers/src/Outputs.h index d098cda..bd1ef76 100644 --- a/Firmware/TreeHouse_Controllers/src/Outputs.h +++ b/Firmware/TreeHouse_Controllers/src/Outputs.h @@ -1,5 +1,6 @@ -// Hardware binding for the two kinds of channel a location can have: -// SK6812 RGBW strips (NeoPixelBus over RMT) and PWM MOSFET dimmers (LEDC). +// Hardware binding for the three kinds of channel a location can have: +// SK6812 RGBW strips (NeoPixelBus over RMT), PWM MOSFET dimmers (LEDC), and +// trigger pins that fire a capacitor-discharge flash (plain GPIO). // // The animation engine in lib/Patterns knows nothing about either — it produces // numbers, and this file is the only place those numbers touch a pin. @@ -55,4 +56,27 @@ class DimmerOutput { bool ready_ = false; }; +// A gate that dumps a charged cap through an LED. The flash length is set by +// the cap, not by us, so this is not dimmed: one short high pulse per tap. +// Non-blocking — fire() raises the pin and update() drops it, so the strip and +// network keep running through a burst. +class TriggerOutput { + public: + bool begin(uint8_t pin); + + void fire(uint32_t now_ms, uint32_t pulse_ms); + + // Call every loop(); ends the pulse once pulse_ms has passed. + void update(uint32_t now_ms); + + void off(); + + private: + uint8_t pin_ = 0; + uint32_t high_since_ms_ = 0; + uint32_t pulse_ms_ = 0; + bool high_ = false; + bool ready_ = false; +}; + } // namespace cg diff --git a/Firmware/TreeHouse_Controllers/src/SelfTest.cpp b/Firmware/TreeHouse_Controllers/src/SelfTest.cpp index f7640d5..c883ad0 100644 --- a/Firmware/TreeHouse_Controllers/src/SelfTest.cpp +++ b/Firmware/TreeHouse_Controllers/src/SelfTest.cpp @@ -7,6 +7,13 @@ constexpr uint32_t kColorMs = 2000; constexpr uint32_t kWalkStepMs = 150; constexpr uint32_t kDarkMs = 1000; +// Triggers: one tap at the start of each colour phase proves the circuit; the +// white phase opens with a four-tap burst instead, 40 ms apart, to prove the +// cap recharges fast enough for the welding stutter. +constexpr uint32_t kTriggerPulseMs = 5; +constexpr uint32_t kBurstGapMs = 40; +constexpr uint8_t kBurstTaps = 4; + uint8_t level8(float level) { const float scaled = level * 255.0f; if (scaled <= 0.0f) return 0; @@ -17,11 +24,13 @@ uint8_t level8(float level) { } // namespace void SelfTest::begin(const ChannelSpec* channels, size_t count, StripOutput* strips, - DimmerOutput* dimmers, uint32_t now_ms) { + DimmerOutput* dimmers, TriggerOutput* triggers, uint32_t now_ms) { channels_ = channels; count_ = count; strips_ = strips; dimmers_ = dimmers; + triggers_ = triggers; + taps_this_phase_ = 0; phase_ = Phase::Red; phase_start_ms_ = now_ms; walk_index_ = 0; @@ -49,9 +58,29 @@ void SelfTest::update(uint32_t now_ms) { if (elapsed >= duration) advance(now_ms); } + fireTriggers(now_ms); render(); } +void SelfTest::fireTriggers(uint32_t now_ms) { + uint8_t taps = 0; + switch (phase_) { + case Phase::Red: + case Phase::Green: + case Phase::Blue: taps = 1; break; + case Phase::White: taps = kBurstTaps; break; + case Phase::Walk: + case Phase::Dark: taps = 0; break; + } + if (taps_this_phase_ >= taps) return; + if (now_ms - phase_start_ms_ < taps_this_phase_ * kBurstGapMs) return; + + ++taps_this_phase_; + for (size_t i = 0; i < count_; ++i) { + if (channels_[i].kind == ChannelKind::Trigger) triggers_[i].fire(now_ms, kTriggerPulseMs); + } +} + void SelfTest::advance(uint32_t now_ms) { switch (phase_) { case Phase::Red: phase_ = Phase::Green; break; @@ -62,6 +91,7 @@ void SelfTest::advance(uint32_t now_ms) { case Phase::Dark: phase_ = Phase::Red; break; } phase_start_ms_ = now_ms; + taps_this_phase_ = 0; Serial.printf("[selftest] %s\n", phaseName()); } @@ -105,6 +135,7 @@ void SelfTest::render() { strips_[i].show(); continue; } + if (spec.kind == ChannelKind::Trigger) continue; // fired, not rendered // Dimmers have no colour, so they climb in quarter steps across the four // colour phases instead. Four visibly different brightnesses is also a diff --git a/Firmware/TreeHouse_Controllers/src/SelfTest.h b/Firmware/TreeHouse_Controllers/src/SelfTest.h index a02a960..42e672d 100644 --- a/Firmware/TreeHouse_Controllers/src/SelfTest.h +++ b/Firmware/TreeHouse_Controllers/src/SelfTest.h @@ -24,7 +24,7 @@ class SelfTest { static constexpr float kLevel = 0.35f; void begin(const ChannelSpec* channels, size_t count, StripOutput* strips, - DimmerOutput* dimmers, uint32_t now_ms); + DimmerOutput* dimmers, TriggerOutput* triggers, uint32_t now_ms); // Call once per frame. void update(uint32_t now_ms); @@ -34,17 +34,20 @@ class SelfTest { void advance(uint32_t now_ms); void render(); + void fireTriggers(uint32_t now_ms); const char* phaseName() const; const ChannelSpec* channels_ = nullptr; size_t count_ = 0; StripOutput* strips_ = nullptr; DimmerOutput* dimmers_ = nullptr; + TriggerOutput* triggers_ = nullptr; Phase phase_ = Phase::Red; uint32_t phase_start_ms_ = 0; uint16_t walk_index_ = 0; uint16_t max_pixels_ = 0; + uint8_t taps_this_phase_ = 0; }; } // namespace cg diff --git a/Firmware/TreeHouse_Controllers/src/main.cpp b/Firmware/TreeHouse_Controllers/src/main.cpp index 17777b7..dc7e7a5 100644 --- a/Firmware/TreeHouse_Controllers/src/main.cpp +++ b/Firmware/TreeHouse_Controllers/src/main.cpp @@ -1,9 +1,10 @@ // TreeHouse location controller — one ESP32-S3 per location (ADR-0020). // -// Receives Garden State over UDP/OSC from the TreeHouse Pi and animates this -// location's channels locally. The Pi never sends pixels, so the show keeps -// running through network drops; if the state goes stale the channels fall back -// to an idle breathe rather than freezing or going dark. +// Animates this location's channels locally. The controller still joins the +// Show Network and receives Garden State from the TreeHouse Pi, but for now it +// animates from fullyActive() instead (ADR-0021): every channel at its ceiling, +// all the time. Flip kFollowGardenState to put the Pi back in charge — the +// Signal Bags and the stale fallback are all still wired up. // // Which location this binary is depends on the CG_TARGET_* macro set by the // platformio.ini environment. See src/targets/. @@ -30,6 +31,7 @@ constexpr uint32_t kFrameIntervalMs = 16; // ~60 fps cg::StripOutput g_strips[cg::target::kChannelCount]; cg::DimmerOutput g_dimmers[cg::target::kChannelCount]; +cg::TriggerOutput g_triggers[cg::target::kChannelCount]; uint32_t g_last_frame_ms = 0; #ifdef CG_SELFTEST @@ -40,9 +42,15 @@ cg::SelfTest g_selftest; constexpr uint32_t kHeartbeatMs = 5000; // serial status line +// ADR-0021: animate as if every Element were at full tilt, and ignore what the +// Pi says. Received state is still parsed and logged, so turning this back on +// is a one-line change and a reflash. +constexpr bool kFollowGardenState = false; + cg::Net g_net; cg::GardenStateStore g_state; cg::ChannelAnimator g_animators[cg::target::kChannelCount]; +cg::Welder g_welders[cg::target::kChannelCount]; // Trigger channels only uint32_t g_last_heartbeat_ms = 0; uint32_t g_packets = 0; @@ -55,15 +63,19 @@ void onOscMessage(const osc::Message& message, void* context) { void logHeartbeat(const cg::GardenState& state, bool stale) { const cg::GardenState& raw = g_state.peek(); Serial.printf( - "[%s] %s | fb %.2f cap %.2f pipes %.2f | master %.2f | %u pkt |", + "[%s] %s%s | fb %.2f cap %.2f pipes %.2f | master %.2f | %u pkt |", cg::target::kName, stale ? "STALE" : (g_net.isConnected() ? "ok" : "no-wifi"), - raw.flowerbeds_activity, raw.captcha_intensity, raw.pipes_activity, - state.masterBrightness(), g_packets); + kFollowGardenState ? "" : " (standalone)", raw.flowerbeds_activity, + raw.captcha_intensity, raw.pipes_activity, state.masterBrightness(), g_packets); for (size_t i = 0; i < cg::target::kChannelCount; ++i) { // Dimmers report the level actually written to the pin, not the drive: on a // stale channel those differ, and the pin is the thing being doubted. const cg::ChannelAnimator& animator = g_animators[i]; - if (animator.spec().kind == cg::ChannelKind::Dimmer) { + if (animator.spec().kind == cg::ChannelKind::Trigger) { + Serial.printf(" %s=%u taps%s", animator.spec().name, + static_cast(g_welders[i].taps()), + g_welders[i].inSession() ? " (welding)" : ""); + } else if (animator.spec().kind == cg::ChannelKind::Dimmer) { Serial.printf(" %s=%.2f(pin)", animator.spec().name, animator.level()); } else { Serial.printf(" %s=%.2f", animator.spec().name, animator.drive()); @@ -98,6 +110,10 @@ void setup() { g_strips[i].begin(strip_slot++, spec.pin, spec.pixel_count); Serial.printf("[setup] strip %-16s pin %2u %u px\n", spec.name, spec.pin, spec.pixel_count); + } else if (spec.kind == cg::ChannelKind::Trigger) { + g_triggers[i].begin(spec.pin); + Serial.printf("[setup] trigger %-15s pin %2u %u ms pulse\n", spec.name, spec.pin, + static_cast(spec.weld.pulse_ms)); } else { g_dimmers[i].begin(dimmer_slot++, spec.pin); Serial.printf("[setup] dimmer %-16s pin %2u\n", spec.name, spec.pin); @@ -106,9 +122,18 @@ void setup() { #ifdef CG_SELFTEST g_selftest.begin(cg::target::kChannels, cg::target::kChannelCount, g_strips, g_dimmers, - millis()); + g_triggers, millis()); #else g_net.begin(cg::target::kName, cg::target::kIp, cg::target::kOscPort); + + // Seeded after the radio is up: esp_random() is only truly random with RF + // on, and the whole point of the Garage is that nobody can learn its rhythm. + for (size_t i = 0; i < cg::target::kChannelCount; ++i) { + g_animators[i].seed(esp_random()); + if (cg::target::kChannels[i].kind == cg::ChannelKind::Trigger) { + g_welders[i].configure(cg::target::kChannels[i].weld, esp_random(), millis()); + } + } #endif g_last_frame_ms = millis(); @@ -122,17 +147,32 @@ void loop() { g_last_frame_ms = now; g_selftest.update(now); } + for (cg::TriggerOutput& trigger : g_triggers) trigger.update(now); return; #else g_net.poll(now); g_packets += static_cast(g_net.receive(onOscMessage, &g_state)); + // The arc runs every loop(), not every frame: its taps are 20-50 ms apart, + // and a frame is 16 ms. A tap lights the trigger now and every animator on + // its next frame — only Weld channels do anything with it. + for (size_t i = 0; i < cg::target::kChannelCount; ++i) { + const cg::ChannelSpec& spec = cg::target::kChannels[i]; + if (spec.kind != cg::ChannelKind::Trigger) continue; + if (g_welders[i].update(now)) { + g_triggers[i].fire(now, spec.weld.pulse_ms); + for (cg::ChannelAnimator& animator : g_animators) animator.strike(); + } + g_triggers[i].update(now); + } + if (now - g_last_frame_ms < kFrameIntervalMs) return; const float dt = static_cast(now - g_last_frame_ms) / 1000.0f; g_last_frame_ms = now; - const bool stale = g_state.isStale(now); - const cg::GardenState state = g_state.consume(); + const bool stale = kFollowGardenState && g_state.isStale(now); + const cg::GardenState received = g_state.consume(); + const cg::GardenState state = kFollowGardenState ? received : cg::fullyActive(); for (size_t i = 0; i < cg::target::kChannelCount; ++i) { cg::ChannelAnimator& animator = g_animators[i]; @@ -143,7 +183,7 @@ void loop() { g_strips[i].setPixel(p, animator.pixel(p)); } g_strips[i].show(); - } else { + } else if (animator.spec().kind == cg::ChannelKind::Dimmer) { g_dimmers[i].write(animator.level()); } } diff --git a/Firmware/TreeHouse_Controllers/src/targets/dormer.h b/Firmware/TreeHouse_Controllers/src/targets/dormer.h index 36dcd29..25d73e1 100644 --- a/Firmware/TreeHouse_Controllers/src/targets/dormer.h +++ b/Firmware/TreeHouse_Controllers/src/targets/dormer.h @@ -1,8 +1,9 @@ // Dormer — one PWM MOSFET channel on a 12 V LED circuit. // -// The Dormer is the highest thing on the structure and reads from across the -// room, so it is capped below full: at 1.0 it flares in photographs and pulls -// attention off the windows. +// A soft glow breathing slowly between 40% and 80%, never dark. The Dormer is +// the highest thing on the structure and reads from across the room, so it is +// capped below full: at 1.0 it flares in photographs and pulls attention off +// the windows. #pragma once namespace cg { @@ -19,11 +20,13 @@ constexpr ChannelSpec kChannels[] = { .pin = 7, .pixel_count = 0, .base = Rgbw{}, - .pattern = PatternId::Filament, + .pattern = PatternId::Breathe, .weights = {.flowerbeds = 0.4f, .captcha = 0.4f, .pipes = 0.2f, .bias = 0.25f}, - .min_level = 0.20f, - .max_level = 0.80f, - .speed = 0.15f, + .min_level = 0.40f, // bottom of the breath + .max_level = 0.80f, // top of the breath + // Phase runs at speed x (0.25 + drive), and drive sits at max_level, + // so 0.076 x 1.05 is one breath every 12.5 s. + .speed = 0.076f, .smoothing_s = 1.5f, .idle_level = 0.25f, }, diff --git a/Firmware/TreeHouse_Controllers/src/targets/garage.h b/Firmware/TreeHouse_Controllers/src/targets/garage.h new file mode 100644 index 0000000..96d08b6 --- /dev/null +++ b/Firmware/TreeHouse_Controllers/src/targets/garage.h @@ -0,0 +1,67 @@ +// Garage — the welding effect. A capacitor-discharge arc flash plus a run of +// SK6812 RGBW pixels behind the same frosted acrylic windows. +// +// Not real welding: mad-scientist welding. The arc is dark for long, random +// stretches and then goes off in stuttering bursts. The pixels are what the +// arc leaves behind: a crackling violet-white flash under each tap, then +// metal cooling through orange back to dark. When the arc is lit it washes +// the pixels out, which is fine — they are for the moments in between. +// +// The arc channel is not dimmed. Its MOSFET dumps a charged cap, so one tap +// is just a short high pulse on the gate and the cap decides how long the +// flash is. The cap recharges fast enough for taps 20-50 ms apart. +#pragma once + +namespace cg { +namespace target { + +constexpr const char* kName = "Garage"; +constexpr uint8_t kIp[4] = CG_IP_GARAGE; +constexpr uint16_t kOscPort = CG_OSC_PORT_GARAGE; + +// Placeholders — set these to the real wiring on the bench. Avoid the +// strapping pins (0/3/45/46), the USB pair (19/20) and the flash/PSRAM range. +constexpr uint8_t kArcPin = 7; +constexpr uint8_t kGlowPin = 4; +constexpr uint16_t kGlowPixels = 16; + +constexpr ChannelSpec kChannels[] = { + { + .name = "Arc", + .kind = ChannelKind::Trigger, + .pin = kArcPin, + .weld = + { + .session_gap_min_ms = 8000, // quiet between welding sessions + .session_gap_max_ms = 25000, + .bursts_min = 2, // bursts per session + .bursts_max = 6, + .burst_gap_min_ms = 1000, // between bursts in a session + .burst_gap_max_ms = 5000, + .taps_min = 1, // taps per burst + .taps_max = 4, + .tap_gap_min_ms = 20, // the cap's recharge window + .tap_gap_max_ms = 50, + .pulse_ms = 5, // gate high time; must stay well + // under tap_gap_min_ms + }, + }, + { + .name = "Glow", + .kind = ChannelKind::Strip, + .pin = kGlowPin, + .pixel_count = kGlowPixels, + .base = Rgbw{255, 60, 0, 0}, // metal cooling — orange to dark + .base_hot = Rgbw{140, 120, 255, 180}, // the arc — violet-white + .pattern = PatternId::Weld, + .max_level = 1.0f, + }, +}; + +static_assert(kChannels[0].weld.pulse_ms < kChannels[0].weld.tap_gap_min_ms, + "the arc pulse must end before the next tap can start"); + +constexpr size_t kChannelCount = sizeof(kChannels) / sizeof(kChannels[0]); + +} // namespace target +} // namespace cg diff --git a/Firmware/TreeHouse_Controllers/src/targets/julia.h b/Firmware/TreeHouse_Controllers/src/targets/julia.h index e4bfd39..dd2e75f 100644 --- a/Firmware/TreeHouse_Controllers/src/targets/julia.h +++ b/Firmware/TreeHouse_Controllers/src/targets/julia.h @@ -1,8 +1,8 @@ // Julia — one PWM MOSFET channel dimming a 12 V LED filament string. // -// Filaments have visible thermal lag, so the smoothing here is deliberately -// slow: chasing Garden State frame by frame would look wrong on this fixture -// even if the electronics could do it. +// A soft glow on a very slow drunk walk between 40% and 80%, never dark: it +// eases toward a random level, then picks another, and a full sweep from one +// end to the other takes about a minute. #pragma once namespace cg { @@ -19,11 +19,11 @@ constexpr ChannelSpec kChannels[] = { .pin = 7, .pixel_count = 0, .base = Rgbw{}, // unused on a dimmer channel - .pattern = PatternId::Filament, + .pattern = PatternId::Wander, .weights = {.flowerbeds = 0.5f, .captcha = 0.2f, .pipes = 0.3f, .bias = 0.20f}, - .min_level = 0.12f, // never fully dark while the show is running - .max_level = 1.0f, - .speed = 0.2f, + .min_level = 0.40f, // the low end of the walk + .max_level = 0.80f, // the high end + .speed = 1.0f, // full sweep in 60 / speed s .smoothing_s = 2.0f, .idle_level = 0.20f, }, diff --git a/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h b/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h index 820df9e..1bb1491 100644 --- a/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h +++ b/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h @@ -1,4 +1,6 @@ -// Swannatopia — three SK6812 RGBW strips. +// Swannatopia — three SK6812 RGBW strips: the fireplace, and the two overhead +// lights over the dining and living rooms. The overheads are the same warm +// incandescent on purpose, so they share one spec and differ only in wiring. // // LED counts are a placeholder until the strips are cut and counted; change // kPixels and reflash. Data pins avoid the ESP32-S3 strapping pins (0/3/45/46), @@ -14,21 +16,25 @@ constexpr uint16_t kOscPort = CG_OSC_PORT_SWANNATOPIA; constexpr uint16_t kPixels = 8; // per strip — provisional +constexpr ChannelSpec overhead(const char* name, uint8_t pin, uint16_t pixel_count) { + return ChannelSpec{ + .name = name, + .kind = ChannelKind::Strip, + .pin = pin, + .pixel_count = pixel_count, + .base = Rgbw{255, 180, 80, 255}, // warm white with an amber cast + .pattern = PatternId::Incandescent, + .weights = {.flowerbeds = 0.6f, .captcha = 0.2f, .pipes = 0.2f, .bias = 0.15f}, + .min_level = 0.20f, + .max_level = 1.0f, + .speed = 0.5f, + .smoothing_s = 0.8f, + .idle_level = 0.18f, + }; +} + constexpr ChannelSpec kChannels[] = { - { - .name = "Overhead", - .kind = ChannelKind::Strip, - .pin = 11, - .pixel_count = kPixels, - .base = Rgbw{255, 180, 80, 255}, // warm white with an amber cast - .pattern = PatternId::Incandescent, - .weights = {.flowerbeds = 0.6f, .captcha = 0.2f, .pipes = 0.2f, .bias = 0.15f}, - .min_level = 0.20f, - .max_level = 1.0f, - .speed = 0.5f, - .smoothing_s = 0.8f, - .idle_level = 0.18f, - }, + overhead("Overhead", 11, kPixels), { .name = "Fireplace", .kind = ChannelKind::Strip, @@ -44,20 +50,7 @@ constexpr ChannelSpec kChannels[] = { .smoothing_s = 0.35f, // still short, so a Blow-Up flares the fire .idle_level = 0.12f, }, - { - .name = "Chandelier", - .kind = ChannelKind::Strip, - .pin = 13, - .pixel_count = 46, - .base = Rgbw{80, 255, 120, 40}, // green with a touch of white - .pattern = PatternId::Incandescent, - .weights = {.flowerbeds = 0.5f, .captcha = 0.2f, .pipes = 0.3f, .bias = 0.10f}, - .min_level = 0.15f, - .max_level = 0.95f, - .speed = 0.28f, - .smoothing_s = 1.2f, - .idle_level = 0.15f, - }, + overhead("Chandelier", 13, 46), }; constexpr size_t kChannelCount = sizeof(kChannels) / sizeof(kChannels[0]); diff --git a/Firmware/TreeHouse_Controllers/src/targets/target.h b/Firmware/TreeHouse_Controllers/src/targets/target.h index ec887bc..a93ce6f 100644 --- a/Firmware/TreeHouse_Controllers/src/targets/target.h +++ b/Firmware/TreeHouse_Controllers/src/targets/target.h @@ -14,6 +14,8 @@ #include "jess.h" #elif defined(CG_TARGET_DORMER) #include "dormer.h" +#elif defined(CG_TARGET_GARAGE) +#include "garage.h" #else #error "No CG_TARGET_* defined. Build through one of the platformio.ini environments." #endif diff --git a/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp b/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp index 2362fb5..c143bf7 100644 --- a/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp +++ b/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp @@ -1,5 +1,7 @@ #include +#include + #include "Patterns.h" namespace { @@ -346,6 +348,156 @@ void test_channels_without_base_hot_are_unchanged() { TEST_ASSERT_TRUE(animator.pixel(0).w > 250); // full white base, not lerped away } +// ADR-0021: controllers animate from fullyActive(), not from what they hear. +// Every Signal Bag saturates there, so each channel sits at its own ceiling. +void test_fully_active_drives_every_channel_to_its_ceiling() { + cg::ChannelSpec spec = dimmerSpec(cg::PatternId::Solid); + spec.weights = {.flowerbeds = 0.3f, .captcha = 0.5f, .pipes = 0.2f, .bias = 0.0f}; + spec.min_level = 0.2f; + spec.max_level = 0.7f; + cg::ChannelAnimator animator; + animator.configure(spec); + run(animator, cg::fullyActive(), 5.0f); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.7f, animator.level()); +} + +// The Dormer: never below its floor, all the way up to its ceiling. +void test_breathe_swings_between_floor_and_ceiling() { + cg::ChannelSpec spec = dimmerSpec(cg::PatternId::Breathe); + spec.weights.bias = 1.0f; + spec.min_level = 0.4f; + spec.max_level = 0.8f; + spec.speed = 0.5f; + cg::ChannelAnimator animator; + animator.configure(spec); + run(animator, cg::fullyActive(), 5.0f); + + float low = 1.0f; + float high = 0.0f; + for (int i = 0; i < 1000; ++i) { // 20 s: several breaths + animator.update(0.02f, cg::fullyActive(), false); + if (animator.level() < low) low = animator.level(); + if (animator.level() > high) high = animator.level(); + } + TEST_ASSERT_FLOAT_WITHIN(0.02f, 0.4f, low); + TEST_ASSERT_FLOAT_WITHIN(0.02f, 0.8f, high); +} + +cg::ChannelSpec wanderSpec() { + cg::ChannelSpec spec = dimmerSpec(cg::PatternId::Wander); + spec.name = "Julia Filaments"; + spec.weights.bias = 1.0f; + spec.min_level = 0.4f; + spec.max_level = 0.8f; + spec.speed = 1.0f; // a full sweep in about a minute + return spec; +} + +// The Julia filaments: wander the whole range, never leave it, never hurry. +void test_wander_drifts_slowly_across_its_range() { + cg::ChannelAnimator animator; + animator.configure(wanderSpec()); + run(animator, cg::fullyActive(), 5.0f); + + const float dt = 0.02f; + float low = 1.0f; + float high = 0.0f; + float fastest = 0.0f; + float previous = animator.level(); + for (int i = 0; i < 30000; ++i) { // ten minutes + animator.update(dt, cg::fullyActive(), false); + const float level = animator.level(); + TEST_ASSERT_TRUE(level >= 0.4f - 0.001f); + TEST_ASSERT_TRUE(level <= 0.8f + 0.001f); + const float rate = std::fabs(level - previous) / dt; + if (rate > fastest) fastest = rate; + if (level < low) low = level; + if (level > high) high = level; + previous = level; + } + TEST_ASSERT_TRUE(high - low > 0.3f); // it really does go up and down + // A 0.4 sweep in ~60 s averages 0.007/s; smoothstep peaks at 1.5x that and a + // fast leg is 30% quicker. Nothing should get anywhere near 0.05/s. + TEST_ASSERT_TRUE(fastest < 0.05f); +} + +void test_wander_reseeds_to_a_different_walk() { + cg::ChannelAnimator a; + cg::ChannelAnimator b; + a.configure(wanderSpec()); + b.configure(wanderSpec()); + b.seed(12345u); + run(a, cg::fullyActive(), 120.0f); + run(b, cg::fullyActive(), 120.0f); + TEST_ASSERT_TRUE(std::fabs(a.level() - b.level()) > 0.0001f); +} + +cg::ChannelSpec weldSpec() { + cg::ChannelSpec spec = stripSpec(cg::PatternId::Weld, 16); + spec.base = cg::Rgbw{255, 60, 0, 0}; // cooling metal + spec.base_hot = cg::Rgbw{140, 120, 255, 180}; // arc violet-white + return spec; +} + +uint32_t total(const cg::Rgbw& c) { return c.r + c.g + c.b + c.w; } + +void test_weld_is_dark_until_the_arc_strikes() { + cg::ChannelAnimator animator; + animator.configure(weldSpec()); + run(animator, cg::fullyActive(), 5.0f); + for (uint16_t i = 0; i < 16; ++i) TEST_ASSERT_EQUAL_UINT32(0, total(animator.pixel(i))); +} + +void test_weld_flashes_arc_white_then_cools_orange_then_goes_dark() { + cg::ChannelAnimator animator; + animator.configure(weldSpec()); + run(animator, cg::fullyActive(), 1.0f); + + animator.strike(); + animator.update(0.016f, cg::fullyActive(), false); + const cg::Rgbw arc = animator.pixel(3); + TEST_ASSERT_TRUE(arc.b > arc.r); // the hot end: violet-white + TEST_ASSERT_TRUE(arc.w > 64); + + run(animator, cg::fullyActive(), 1.0f); + const cg::Rgbw metal = animator.pixel(3); + TEST_ASSERT_TRUE(total(metal) > 0); // still glowing + TEST_ASSERT_TRUE(total(metal) < total(arc)); + TEST_ASSERT_TRUE(metal.r > metal.b); // and orange now + + run(animator, cg::fullyActive(), 20.0f); + TEST_ASSERT_EQUAL_UINT32(0, total(animator.pixel(3))); +} + +void test_a_long_burst_glows_longer_than_one_tap() { + cg::ChannelAnimator one; + cg::ChannelAnimator four; + one.configure(weldSpec()); + four.configure(weldSpec()); + one.strike(); + for (int i = 0; i < 4; ++i) { + four.strike(); + four.update(0.03f, cg::fullyActive(), false); + } + one.update(0.12f, cg::fullyActive(), false); + run(one, cg::fullyActive(), 2.0f); + run(four, cg::fullyActive(), 2.0f); + TEST_ASSERT_TRUE(total(four.pixel(0)) > total(one.pixel(0))); +} + +void test_strike_is_ignored_by_other_patterns() { + cg::ChannelSpec spec = stripSpec(cg::PatternId::Solid, 4); + spec.weights.bias = 1.0f; + spec.max_level = 0.5f; + cg::ChannelAnimator animator; + animator.configure(spec); + run(animator, cg::fullyActive(), 5.0f); + const uint8_t before = animator.pixel(0).w; + animator.strike(); + animator.update(0.016f, cg::fullyActive(), false); + TEST_ASSERT_INT_WITHIN(1, before, animator.pixel(0).w); +} + void test_gamma_duty_endpoints_and_monotonicity() { const uint32_t max_duty = 8191; TEST_ASSERT_EQUAL_UINT32(0, cg::gammaDuty(0.0f, max_duty)); @@ -383,6 +535,14 @@ int main(int, char**) { RUN_TEST(test_fire_burns_hottest_at_the_hearth); RUN_TEST(test_fire_ramps_from_ember_red_to_amber); RUN_TEST(test_channels_without_base_hot_are_unchanged); + RUN_TEST(test_fully_active_drives_every_channel_to_its_ceiling); + RUN_TEST(test_breathe_swings_between_floor_and_ceiling); + RUN_TEST(test_wander_drifts_slowly_across_its_range); + RUN_TEST(test_wander_reseeds_to_a_different_walk); + RUN_TEST(test_weld_is_dark_until_the_arc_strikes); + RUN_TEST(test_weld_flashes_arc_white_then_cools_orange_then_goes_dark); + RUN_TEST(test_a_long_burst_glows_longer_than_one_tap); + RUN_TEST(test_strike_is_ignored_by_other_patterns); RUN_TEST(test_gamma_duty_endpoints_and_monotonicity); return UNITY_END(); } diff --git a/Firmware/TreeHouse_Controllers/test/test_weld/test_weld.cpp b/Firmware/TreeHouse_Controllers/test/test_weld/test_weld.cpp new file mode 100644 index 0000000..5d90efa --- /dev/null +++ b/Firmware/TreeHouse_Controllers/test/test_weld/test_weld.cpp @@ -0,0 +1,147 @@ +#include + +#include + +#include "Weld.h" + +namespace { + +// Tap times from polling a Welder every `step_ms` for `duration_ms`. +std::vector tapTimes(uint32_t seed, uint32_t duration_ms, uint32_t step_ms = 1, + uint32_t start_ms = 0) { + cg::Welder welder; + welder.configure(cg::WeldTiming{}, seed, start_ms); + std::vector taps; + for (uint32_t elapsed = 0; elapsed < duration_ms; elapsed += step_ms) { + const uint32_t now = start_ms + elapsed; // wraps, as millis() does + if (welder.update(now)) taps.push_back(elapsed); + } + return taps; +} + +enum class Gap { Tap, Burst, Session, Invalid }; + +// The three default ranges do not overlap, so every gap is exactly one kind. +Gap classify(uint32_t gap_ms) { + const cg::WeldTiming t; + if (gap_ms >= t.tap_gap_min_ms && gap_ms <= t.tap_gap_max_ms) return Gap::Tap; + if (gap_ms >= t.burst_gap_min_ms && gap_ms <= t.burst_gap_max_ms) return Gap::Burst; + if (gap_ms >= t.session_gap_min_ms && gap_ms <= t.session_gap_max_ms) return Gap::Session; + return Gap::Invalid; +} + +constexpr uint32_t kTenMinutesMs = 10u * 60u * 1000u; + +} // namespace + +void setUp() {} +void tearDown() {} + +void test_a_fresh_garage_starts_quiet() { + const cg::WeldTiming t; + for (uint32_t seed = 1; seed <= 20; ++seed) { + const std::vector taps = tapTimes(seed, 60000); + TEST_ASSERT_FALSE(taps.empty()); + TEST_ASSERT_TRUE(taps.front() >= t.session_gap_min_ms); + TEST_ASSERT_TRUE(taps.front() <= t.session_gap_max_ms); + } +} + +void test_every_gap_is_a_tap_burst_or_session_gap() { + const std::vector taps = tapTimes(7u, kTenMinutesMs); + TEST_ASSERT_TRUE(taps.size() > 20); + for (size_t i = 1; i < taps.size(); ++i) { + TEST_ASSERT_TRUE(classify(taps[i] - taps[i - 1]) != Gap::Invalid); + } +} + +// Walks the gaps to recover the structure: taps per burst, bursts per session. +void test_bursts_and_sessions_vary_within_their_ranges() { + const cg::WeldTiming t; + const std::vector taps = tapTimes(42u, 4u * kTenMinutesMs); + + std::vector taps_per_burst; + std::vector bursts_per_session; + uint32_t taps_in_burst = 1; + uint32_t bursts_in_session = 1; + for (size_t i = 1; i < taps.size(); ++i) { + const Gap gap = classify(taps[i] - taps[i - 1]); + if (gap == Gap::Tap) { + ++taps_in_burst; + continue; + } + taps_per_burst.push_back(taps_in_burst); + taps_in_burst = 1; + if (gap == Gap::Burst) { + ++bursts_in_session; + } else { + bursts_per_session.push_back(bursts_in_session); + bursts_in_session = 1; + } + } + + // The final burst and session may be cut off by the end of the run, so only + // the closed ones are counted. + TEST_ASSERT_TRUE(bursts_per_session.size() > 10); + bool saw_single = false; + bool saw_max_taps = false; + for (uint32_t n : taps_per_burst) { + TEST_ASSERT_TRUE(n >= t.taps_min && n <= t.taps_max); + if (n == t.taps_min) saw_single = true; + if (n == t.taps_max) saw_max_taps = true; + } + TEST_ASSERT_TRUE(saw_single); + TEST_ASSERT_TRUE(saw_max_taps); + + uint32_t fewest = 1000; + uint32_t most = 0; + for (uint32_t n : bursts_per_session) { + TEST_ASSERT_TRUE(n >= t.bursts_min && n <= t.bursts_max); + if (n < fewest) fewest = n; + if (n > most) most = n; + } + TEST_ASSERT_TRUE(most > fewest); +} + +// A loop() that stalls must not fire a backlog of taps back to back: the cap +// needs its recharge window whatever the loop was doing. +void test_a_slow_loop_never_fires_faster_than_the_cap_recharges() { + const cg::WeldTiming t; + const std::vector taps = tapTimes(3u, kTenMinutesMs, /*step_ms=*/70); + TEST_ASSERT_TRUE(taps.size() > 20); + for (size_t i = 1; i < taps.size(); ++i) { + TEST_ASSERT_TRUE(taps[i] - taps[i - 1] >= t.tap_gap_min_ms); + } +} + +void test_same_seed_replays_and_different_seeds_do_not() { + const std::vector a = tapTimes(11u, kTenMinutesMs); + const std::vector b = tapTimes(11u, kTenMinutesMs); + const std::vector c = tapTimes(12u, kTenMinutesMs); + TEST_ASSERT_TRUE(a == b); + TEST_ASSERT_FALSE(a == c); +} + +void test_keeps_welding_across_millis_rollover() { + const uint32_t start = 0xFFFFFFFFu - 30000u; // wraps 30 s in + const std::vector taps = tapTimes(5u, 3u * 60u * 1000u, 1, start); + size_t after_wrap = 0; + for (uint32_t elapsed : taps) { + if (elapsed > 30000u) ++after_wrap; + } + TEST_ASSERT_TRUE(after_wrap > 5); + for (size_t i = 1; i < taps.size(); ++i) { + TEST_ASSERT_TRUE(classify(taps[i] - taps[i - 1]) != Gap::Invalid); + } +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_a_fresh_garage_starts_quiet); + RUN_TEST(test_every_gap_is_a_tap_burst_or_session_gap); + RUN_TEST(test_bursts_and_sessions_vary_within_their_ranges); + RUN_TEST(test_a_slow_loop_never_fires_faster_than_the_cap_recharges); + RUN_TEST(test_same_seed_replays_and_different_seeds_do_not); + RUN_TEST(test_keeps_welding_across_millis_rollover); + return UNITY_END(); +} diff --git a/ShowControl/TreeHouse/settings.json b/ShowControl/TreeHouse/settings.json index fe766b8..b2eb460 100644 --- a/ShowControl/TreeHouse/settings.json +++ b/ShowControl/TreeHouse/settings.json @@ -21,7 +21,8 @@ "treehouse_swannatopia", "treehouse_julia", "treehouse_jess", - "treehouse_dormer" + "treehouse_dormer", + "treehouse_garage" ] }, diff --git a/ShowControl/network.json b/ShowControl/network.json index 67bcec5..46fed5d 100644 --- a/ShowControl/network.json +++ b/ShowControl/network.json @@ -21,6 +21,7 @@ "treehouse_swannatopia": { "ip": "192.168.1.60", "mac": null, "osc_port": 9000 }, "treehouse_julia": { "ip": "192.168.1.61", "mac": null, "osc_port": 9000 }, "treehouse_jess": { "ip": "192.168.1.62", "mac": null, "osc_port": 9000 }, - "treehouse_dormer": { "ip": "192.168.1.63", "mac": null, "osc_port": 9000 } + "treehouse_dormer": { "ip": "192.168.1.63", "mac": null, "osc_port": 9000 }, + "treehouse_garage": { "ip": "192.168.1.64", "mac": null, "osc_port": 9000 } } } diff --git a/docs/adr/0020-treehouse-esp32s3-location-controllers.md b/docs/adr/0020-treehouse-esp32s3-location-controllers.md index 625cf5a..6f3dd28 100644 --- a/docs/adr/0020-treehouse-esp32s3-location-controllers.md +++ b/docs/adr/0020-treehouse-esp32s3-location-controllers.md @@ -1,6 +1,6 @@ # ADR 0020 — TreeHouse lighting: one ESP32-S3 per location, driven by Garden State -**Status:** Accepted +**Status:** Accepted, amended by [ADR-0021](0021-treehouse-controllers-standalone-and-garage-welder.md) (controllers currently ignore Garden State; a fifth location, the Garage) **Supersedes:** [ADR-0010](0010-treehouse-led-pico-architecture.md) ## Context diff --git a/docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md b/docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md new file mode 100644 index 0000000..53f46b0 --- /dev/null +++ b/docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md @@ -0,0 +1,99 @@ +# ADR 0021 — TreeHouse controllers run standalone; a fifth controller welds in the Garage + +**Status:** Accepted +**Amends:** [ADR-0020](0020-treehouse-esp32s3-location-controllers.md) + +## Context + +ADR-0020 put one ESP32-S3 at each TreeHouse location and had the Pi drive them with Garden +State: each channel's Signal Bag reduced the Fabric fields to a drive level, and patterns +animated from it. + +For this round of the show, the looks matter more than the reactivity. Each location has a +specific, fixed look it should hold all the time, and Garden-State-driven brightness was +actively working against that: a quiet installation meant dim windows. Separately, a fifth +location was added: the Garage, with a welding effect. + +## Decision + +### Animate from a fully-active Garden State, ignore what arrives + +Every controller animates from `cg::fullyActive()`: every Element at 1.0 and the show +Active. Every Signal Bag saturates there, so each channel sits permanently at its +`max_level`, which is the look it was tuned to have at full activity. The patterns still +move. Only the level they move around stops tracking the room. + +The plumbing stays. Controllers still join the Show Network, parse every OSC packet into +the `GardenStateStore` and log it on the heartbeat (marked `(standalone)`), and the Pi's +`location_sender.py` keeps sending. A single constant, `kFollowGardenState` in +`src/main.cpp`, chooses which state the animators see. Turning reactivity back on is that +one line and a reflash. + +Consequences of not following Garden State: + +- Channels never go stale, so the idle breathe fallback never runs. +- No Blow-Up Reaction. +- `/treehouse/mode` and `/treehouse/brightness` no longer dim anything. The only way to + darken a location is to cut its power. + +The last point reverses ADR-0020's "inactive mode is the only path to darkness", knowingly. + +### Per-location looks + +| Location | Channel | Look | +|---|---|---| +| Dormer | dimmer | `Breathe`: a 12.5 s breath between 40% and 80%, never dark | +| Julia | dimmer | `Wander`: a drunk walk between 40% and 80%; a full sweep takes about a minute | +| Jess | 2 strips + flash | unchanged for now | +| Swannatopia | 3 strips | `Fire` in the fireplace; both overheads the same warm `Incandescent` | +| Garage | strip + arc | `Weld` (below) | + +`Breathe` and `Wander` both span `min_level` up to the drive, so the floor of a look is +the channel's `min_level`, the same field that already meant "never darker than this". + +### The Garage: a cap-discharge arc and 16 glow pixels + +The arc light is a MOSFET dumping a charged capacitor through a very bright LED. The cap +sets how long the flash lasts, so the arc is not dimmed. One **tap** is a short high pulse +on the gate. This is a new channel kind, `ChannelKind::Trigger`, driven by a `Welder` +rather than by a pattern. + +The `Welder` (`lib/Weld`) works on three nested, random timescales: + +| Level | Gap | Count | +|---|---|---| +| Session | 8–25 s of quiet between sessions | 2–6 bursts | +| Burst | 1–5 s between bursts | 1–4 taps | +| Tap | 20–50 ms between taps (the cap's recharge window) | 5 ms gate pulse | + +The ranges live in `ChannelSpec::weld` in `src/targets/garage.h`. + +- **Timing granularity.** The `Welder` is polled every `loop()`, not every 16 ms frame, + because frame jitter would smear a 20 ms stutter. +- **Recharge guarantee.** Each gap is measured from the moment a tap actually fires. A + stalled loop therefore can never fire two taps closer together than the cap can + recharge. +- **Seeding.** The `Welder` is seeded from `esp_random()` after the radio is up, so the + rhythm differs on every boot. + +Every tap also calls `strike()` on every animator. The strip channel runs the `Weld` +pattern, which is the only pattern that reads `strike()`: + +- Each strike gives a crackling violet-white flash under the arc, which is gone in about + 0.1 s. +- Behind it, the heated metal cools through orange to dark over a few seconds. +- Each tap adds heat, so a four-tap burst glows longer than a single pop. + +Between sessions the Garage is dark, which is what makes the next one surprising. + +## Consequences + +- `network.json` gains `treehouse_garage` (192.168.1.64), and `firmware_config_gen.py` + generates `CG_IP_GARAGE`. The Pi's `locations.controllers` lists it too. It receives + Garden State it ignores, so the sender stays uniform. +- `platformio.ini` gains `garage` and `garage-selftest`. Under self-test the arc taps once + at the start of each colour phase, then fires a four-tap, 40 ms burst at the start of + the white phase to prove the cap recharges in time. +- The Signal Bag weights in each target header are now inert. They are kept so that + flipping `kFollowGardenState` restores the ADR-0020 behaviour without retuning. +- The Garage pins in `garage.h` are placeholders until the board is wired. diff --git a/docs/agents/firmware.md b/docs/agents/firmware.md index bb7ff70..dcd97f8 100644 --- a/docs/agents/firmware.md +++ b/docs/agents/firmware.md @@ -19,7 +19,9 @@ Required Arduino libraries: `Dynamixel2Arduino`, `Ethernet`, `OSCMessage` (CNMAT `Firmware/TreeHouse_Controllers/` — PlatformIO / Arduino C++ on ESP32-S3, one controller per location (see [ADR-0020](../adr/0020-treehouse-esp32s3-location-controllers.md)). -- One project, four environments: `swannatopia`, `julia`, `jess`, `dormer`. Everything specific to a location is in `src/targets/.h`. +- One project, five environments: `swannatopia`, `julia`, `jess`, `dormer`, `garage`. Everything specific to a location is in `src/targets/.h`. +- **Controllers currently ignore Garden State** ([ADR-0021](../adr/0021-treehouse-controllers-standalone-and-garage-welder.md)): they animate from `cg::fullyActive()`, so every channel holds its `max_level`. `kFollowGardenState` in `src/main.cpp` switches the Pi back on. +- The Garage arc is a `ChannelKind::Trigger` (cap-discharge flash, pulsed not dimmed) driven by `lib/Weld`'s `Welder`; every tap `strike()`s the `Weld` glow strip. - The Pi sends **Garden State**, not pixels — the Fabric addresses (`/flowerbeds/activity`, `/captcha/intensity`, `/captcha/blowup`, `/pipes/activity`, `/treehouse/mode`, `/treehouse/brightness`) over UDP. Each controller animates locally, so a dropped packet costs nothing. - IPs come from `ShowControl/network.json` via a generated `include/net_config.h` — do not hardcode them. Run `scripts/hooks/firmware_config_gen.py` after editing network.json. - WiFi credentials live in a gitignored `include/secrets.h`; copy `include/secrets.h.example`. diff --git a/scripts/hooks/firmware_config_gen.py b/scripts/hooks/firmware_config_gen.py index 35c26d1..da34efb 100755 --- a/scripts/hooks/firmware_config_gen.py +++ b/scripts/hooks/firmware_config_gen.py @@ -35,6 +35,7 @@ "treehouse_julia": "JULIA", "treehouse_jess": "JESS", "treehouse_dormer": "DORMER", + "treehouse_garage": "GARAGE", } From 2a3c892854916f59f92cfd925bd4d969750abb95 Mon Sep 17 00:00:00 2001 From: charlie Date: Fri, 2 Oct 2026 16:13:14 -0400 Subject: [PATCH 2/2] fw tweaks --- .../.vscode/c_cpp_properties.json | 9 +- .../.vscode/extensions.json | 3 +- Firmware/TreeHouse_Controllers/README.md | 4 +- .../lib/Patterns/Patterns.cpp | 53 +++++++++-- .../lib/Patterns/Patterns.h | 4 + .../src/targets/garage.h | 6 +- .../TreeHouse_Controllers/src/targets/jess.h | 20 ++-- .../src/targets/swannatopia.h | 18 +++- .../test/test_patterns/test_patterns.cpp | 91 +++++++++++++++++-- ...ontrollers-standalone-and-garage-welder.md | 6 +- 10 files changed, 180 insertions(+), 34 deletions(-) diff --git a/Firmware/TreeHouse_Controllers/.vscode/c_cpp_properties.json b/Firmware/TreeHouse_Controllers/.vscode/c_cpp_properties.json index 9a03658..e66f248 100644 --- a/Firmware/TreeHouse_Controllers/.vscode/c_cpp_properties.json +++ b/Firmware/TreeHouse_Controllers/.vscode/c_cpp_properties.json @@ -11,6 +11,8 @@ "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/include", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/src", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/Patterns", + "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/Weld", + "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/Rng", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/GardenState", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/WiFi/src", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/OscRx", @@ -215,6 +217,7 @@ "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_qspi/include", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/cores/esp32", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/variants/esp32s3", + "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/.pio/libdeps/swannatopia/Unity/src", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/ArduinoOTA/src", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/BLE/src", @@ -254,6 +257,8 @@ "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/include", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/src", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/Patterns", + "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/Weld", + "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/Rng", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/GardenState", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/WiFi/src", "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/lib/OscRx", @@ -458,6 +463,7 @@ "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_qspi/include", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/cores/esp32", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/variants/esp32s3", + "/home/charliehuge/projects/CommunityGarden/Firmware/TreeHouse_Controllers/.pio/libdeps/swannatopia/Unity/src", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/ArduinoOTA/src", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src", "/home/charliehuge/.platformio/packages/framework-arduinoespressif32/libraries/BLE/src", @@ -493,7 +499,7 @@ ] }, "defines": [ - "PLATFORMIO=60119", + "PLATFORMIO=60200", "ARDUINO_ESP32S3_DEV", "ARDUINO_USB_MODE=1", "ARDUINO_RUNNING_CORE=1", @@ -516,6 +522,7 @@ "ARDUINO_VARIANT=\"esp32s3\"", "ARDUINO_BOARD=\"Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)\"", "ARDUINO_PARTITION_default", + "UNITY_INCLUDE_CONFIG_H", "" ], "cStandard": "gnu99", diff --git a/Firmware/TreeHouse_Controllers/.vscode/extensions.json b/Firmware/TreeHouse_Controllers/.vscode/extensions.json index 080e70d..a91ca8f 100644 --- a/Firmware/TreeHouse_Controllers/.vscode/extensions.json +++ b/Firmware/TreeHouse_Controllers/.vscode/extensions.json @@ -5,6 +5,7 @@ "platformio.platformio-ide" ], "unwantedRecommendations": [ - "ms-vscode.cpptools-extension-pack" + "ms-vscode.cpptools-extension-pack", + "pioarduino.pioarduino-ide" ] } diff --git a/Firmware/TreeHouse_Controllers/README.md b/Firmware/TreeHouse_Controllers/README.md index a4677ee..3dfe59d 100644 --- a/Firmware/TreeHouse_Controllers/README.md +++ b/Firmware/TreeHouse_Controllers/README.md @@ -8,9 +8,9 @@ for why it currently ignores the Pi. | Environment | Location | Channels | Look | Static IP | |---|---|---|---|---| -| `swannatopia` | Swannatopia | 3 × SK6812 RGBW | fireplace fire; two warm incandescent overheads | 192.168.1.60 | +| `swannatopia` | Swannatopia | 3 × SK6812 RGBW | fireplace fire; warm incandescent overhead; dimmer, warmer chandelier | 192.168.1.60 | | `julia` | Julia | 1 × PWM MOSFET (12 V filaments) | slow drunk walk, 40–80% | 192.168.1.61 | -| `jess` | Jess | 2 × SK6812 RGBW + 1 × PWM MOSFET flash | 4-burst strobe over two strips | 192.168.1.62 | +| `jess` | Jess | 2 × SK6812 RGBW + 1 × PWM MOSFET flash | slow `Rave` colour drift on both strips; 4-pulse strobe every 20 s | 192.168.1.62 | | `dormer` | Dormer | 1 × PWM MOSFET (12 V) | 12.5 s breath, 40–80% | 192.168.1.63 | | `garage` | Garage | 1 × cap-discharge arc trigger + 16 × SK6812 RGBW | random welding sessions | 192.168.1.64 | diff --git a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp index 3c1f1be..1192204 100644 --- a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp +++ b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp @@ -78,6 +78,26 @@ uint32_t nameSeed(const char* name) { float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); } +// Rave: a loop of saturated club colours. No white channel and no warm end, +// so it never reads as room light. Neighbours are eased into each other, so +// a pixel crossing a seam fades rather than steps. +constexpr Rgbw kRavePalette[] = { + Rgbw{255, 0, 140, 0}, // hot pink + Rgbw{140, 0, 255, 0}, // violet + Rgbw{0, 30, 255, 0}, // deep blue + Rgbw{0, 210, 255, 0}, // cyan +}; +constexpr int kRaveColours = sizeof(kRavePalette) / sizeof(kRavePalette[0]); +constexpr float kRaveSpread = 0.5f; // fraction of the palette one strip shows at once + +Rgbw raveColour(float position) { + position -= std::floor(position); + const float scaled = position * static_cast(kRaveColours); + const int from = static_cast(scaled) % kRaveColours; + const int to = (from + 1) % kRaveColours; + return mixRgbw(kRavePalette[from], kRavePalette[to], smoothstep(scaled - std::floor(scaled))); +} + // Wander: how long a leg takes is its distance times the full-sweep time, // stretched or squeezed by up to 30% so the pace is as unsteady as the path. // Short legs still take a few seconds — a stumble, not a twitch. @@ -93,11 +113,12 @@ constexpr float kArcGlowTauS = 2.5f; constexpr float kArcGlowPerTap = 0.2f; constexpr float kArcGlowCeiling = 0.4f; -// Flash: four 250 ms bursts, evenly spaced, then a four-second pause. -constexpr float kFlashOnS = 0.25f; -constexpr float kFlashPeriodS = 0.5f; // on, then an equal gap +// Flash: four pulses on an eighth-note grid at 120 BPM — one every 250 ms, +// each lit for half its slot — then a twenty-second pause. +constexpr float kFlashOnS = 0.125f; +constexpr float kFlashPeriodS = 0.25f; // on, then an equal gap constexpr int kFlashBursts = 4; -constexpr float kFlashPauseS = 4.0f; +constexpr float kFlashPauseS = 20.0f; constexpr float kFlashCycleS = kFlashBursts * kFlashPeriodS + kFlashPauseS; } // namespace @@ -135,6 +156,9 @@ void ChannelAnimator::configure(const ChannelSpec& spec) { wander_leg_s_ = 0.0f; flash_ = 0.0f; glow_ = 0.0f; + // From the name, so two Rave strips on one controller start apart. Mixed, + // because names like "Jess A" and "Jess B" hash only a few bits apart. + palette_offset_ = pixelNoise(0u, nameSeed(spec.name)); } void ChannelAnimator::strike() { @@ -302,6 +326,15 @@ float ChannelAnimator::patternLevel(uint16_t index) const { value = spec_.max_level * (arc > metal ? arc : metal); break; } + + case PatternId::Rave: { + // The colour does the moving (see pixel()); the level only swells + // gently, on a wave that runs the opposite way to the colours. + const float swell = 0.5f + 0.5f * std::sin(kTwoPi * (static_cast(index) / count + + phase_ * 3.0f)); + value = drive_ * (0.75f + 0.25f * swell); + break; + } } // The Blow-Up Reaction overrides whatever the pattern wanted, then decays @@ -318,10 +351,18 @@ Rgbw ChannelAnimator::pixel(uint16_t index) const { // result but not the ramp position — dimming the show must not recolour it. // Keyed off the configured pattern rather than the effective one, so a stale // Fireplace breathes in fire colours instead of flat ember red. + // Rave takes its colour from the pixel's place in the palette instead, which + // also keeps a stale Rave strip breathing in its own colours. const float heat = patternLevel(index); const float value = heat * master_; - const Rgbw base = - usesRamp(spec_.pattern) ? mixRgbw(spec_.base, spec_.base_hot, heat) : spec_.base; + Rgbw base = spec_.base; + if (spec_.pattern == PatternId::Rave) { + const float count = spec_.pixel_count > 0 ? static_cast(spec_.pixel_count) : 1.0f; + base = raveColour(palette_offset_ + kRaveSpread * static_cast(index) / count + + phase_); + } else if (usesRamp(spec_.pattern)) { + base = mixRgbw(spec_.base, spec_.base_hot, heat); + } return Rgbw{scale8(base.r, value), scale8(base.g, value), scale8(base.b, value), scale8(base.w, value)}; } diff --git a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h index 89a38b6..e6caa9e 100644 --- a/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h +++ b/Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.h @@ -45,6 +45,8 @@ enum class PatternId { // level, then picks another; a full sweep takes 60 / speed s Weld, // strip: dark until strike(), then an arc-white flash that // cools through base_hot -> base and fades back to dark + Rave, // strip: saturated pink/violet/blue/cyan bands that cross-fade + // as they drift along the run; ignores base and base_hot }; // Per-channel weighting over Garden State. Weights need not sum to 1 — the @@ -130,6 +132,8 @@ class ChannelAnimator { float flash_ = 0.0f; // Weld: the arc itself, gone in a blink float glow_ = 0.0f; // Weld: the metal it heated, cooling over seconds + + float palette_offset_ = 0.0f; // Rave: where in the palette this channel starts }; } // namespace cg diff --git a/Firmware/TreeHouse_Controllers/src/targets/garage.h b/Firmware/TreeHouse_Controllers/src/targets/garage.h index 96d08b6..36300af 100644 --- a/Firmware/TreeHouse_Controllers/src/targets/garage.h +++ b/Firmware/TreeHouse_Controllers/src/targets/garage.h @@ -21,8 +21,8 @@ constexpr uint16_t kOscPort = CG_OSC_PORT_GARAGE; // Placeholders — set these to the real wiring on the bench. Avoid the // strapping pins (0/3/45/46), the USB pair (19/20) and the flash/PSRAM range. -constexpr uint8_t kArcPin = 7; -constexpr uint8_t kGlowPin = 4; +constexpr uint8_t kArcPin = 6; +constexpr uint8_t kGlowPin = 8; constexpr uint16_t kGlowPixels = 16; constexpr ChannelSpec kChannels[] = { @@ -42,7 +42,7 @@ constexpr ChannelSpec kChannels[] = { .taps_max = 4, .tap_gap_min_ms = 20, // the cap's recharge window .tap_gap_max_ms = 50, - .pulse_ms = 5, // gate high time; must stay well + .pulse_ms = 15, // gate high time; must stay well // under tap_gap_min_ms }, }, diff --git a/Firmware/TreeHouse_Controllers/src/targets/jess.h b/Firmware/TreeHouse_Controllers/src/targets/jess.h index 2ca498c..2d034b3 100644 --- a/Firmware/TreeHouse_Controllers/src/targets/jess.h +++ b/Firmware/TreeHouse_Controllers/src/targets/jess.h @@ -1,6 +1,9 @@ // Jess — two SK6812 RGBW strips plus one MOSFET channel driving a very bright // LED. // +// The strips are the rave: saturated pink, violet, blue and cyan cross-fading +// along each run, slowly enough that it is a mood rather than a light show. +// // The bright LED is a punctuation mark, not a light source: it is dark most of // the time, strobes in short bursts, and goes full on for the Blow-Up // Reaction. Its ceiling is held below 1.0 because at full power, this close @@ -28,13 +31,13 @@ constexpr ChannelSpec kChannels[] = { .kind = ChannelKind::Strip, .pin = 8, .pixel_count = 12, - .base = Rgbw{255, 160, 60, 200}, - .pattern = PatternId::Incandescent, + .base = Rgbw{}, // unused: Rave brings its own palette + .pattern = PatternId::Rave, .weights = {.flowerbeds = 0.5f, .captcha = 0.3f, .pipes = 0.2f, .bias = 0.15f}, .min_level = 0.18f, .max_level = 1.0f, - .speed = 0.5f, - .smoothing_s = 0.8f, + .speed = 0.008f, // one lap of the palette in about three minutes + .smoothing_s = 3.0f, // Garden State swings fade in over seconds .idle_level = 0.18f, }, { @@ -42,13 +45,14 @@ constexpr ChannelSpec kChannels[] = { .kind = ChannelKind::Strip, .pin = 9, .pixel_count = 36, - .base = Rgbw{60, 120, 255, 30}, // cool counterpoint to Jess A - .pattern = PatternId::Mycelium, + .base = Rgbw{}, + .pattern = PatternId::Rave, .weights = {.flowerbeds = 0.3f, .captcha = 0.4f, .pipes = 0.3f, .bias = 0.08f}, .min_level = 0.12f, .max_level = 0.95f, - .speed = 0.35f, - .smoothing_s = 1.0f, + .speed = 0.006f, // a touch slower than Jess A, so the two drift in and + // out of step instead of mirroring each other + .smoothing_s = 3.0f, .idle_level = 0.14f, }, { diff --git a/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h b/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h index 1bb1491..44b61ae 100644 --- a/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h +++ b/Firmware/TreeHouse_Controllers/src/targets/swannatopia.h @@ -1,6 +1,6 @@ -// Swannatopia — three SK6812 RGBW strips: the fireplace, and the two overhead -// lights over the dining and living rooms. The overheads are the same warm -// incandescent on purpose, so they share one spec and differ only in wiring. +// Swannatopia — three SK6812 RGBW strips: the fireplace, the overhead light +// and the chandelier. The chandelier is the overhead's warm incandescent, +// but at half the brightness and a deeper amber. // // LED counts are a placeholder until the strips are cut and counted; change // kPixels and reflash. Data pins avoid the ESP32-S3 strapping pins (0/3/45/46), @@ -33,6 +33,16 @@ constexpr ChannelSpec overhead(const char* name, uint8_t pin, uint16_t pixel_cou }; } +// Half the overhead's levels, and less blue and white for a warmer cast. +constexpr ChannelSpec chandelier(const char* name, uint8_t pin, uint16_t pixel_count) { + ChannelSpec spec = overhead(name, pin, pixel_count); + spec.base = Rgbw{255, 140, 30, 150}; + spec.min_level = 0.10f; + spec.max_level = 0.5f; + spec.idle_level = 0.09f; + return spec; +} + constexpr ChannelSpec kChannels[] = { overhead("Overhead", 11, kPixels), { @@ -50,7 +60,7 @@ constexpr ChannelSpec kChannels[] = { .smoothing_s = 0.35f, // still short, so a Blow-Up flares the fire .idle_level = 0.12f, }, - overhead("Chandelier", 13, 46), + chandelier("Chandelier", 13, 46), }; constexpr size_t kChannelCount = sizeof(kChannels) / sizeof(kChannels[0]); diff --git a/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp b/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp index c143bf7..c096f4c 100644 --- a/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp +++ b/Firmware/TreeHouse_Controllers/test/test_patterns/test_patterns.cpp @@ -1,6 +1,7 @@ #include #include +#include #include "Patterns.h" @@ -197,7 +198,7 @@ void test_flash_keeps_strobing_while_stale() { blowup.captcha_blowup = true; int lit_ms = 0; - for (int ms = 0; ms < 6000; ++ms) { + for (int ms = 0; ms < 20000; ++ms) { animator.update(0.001f, blowup, /*stale=*/true); if (animator.level() > 0.0f) { ++lit_ms; @@ -205,7 +206,7 @@ void test_flash_keeps_strobing_while_stale() { TEST_ASSERT_EQUAL_FLOAT(0.8f, animator.level()); } } - TEST_ASSERT_INT_WITHIN(8, 1000, lit_ms); + TEST_ASSERT_INT_WITHIN(8, 500, lit_ms); } void test_flash_channel_strobes_in_bursts() { @@ -215,12 +216,13 @@ void test_flash_channel_strobes_in_bursts() { cg::ChannelAnimator animator; animator.configure(spec); - // Sample one 6 s cycle at 1 ms and count the on/off runs: four bursts of - // 250 ms, then a 4 s pause. + // Sample 20 s at 1 ms — one burst and most of its pause, stopping short of + // the next cycle — and count the on/off runs: four 125 ms pulses, one every + // 250 ms, then a 20 s pause. int edges = 0; int lit_ms = 0; bool lit = false; - for (int ms = 0; ms < 6000; ++ms) { + for (int ms = 0; ms < 20000; ++ms) { animator.update(0.001f, active(), false); const bool now_lit = animator.level() > 0.0f; if (now_lit) { @@ -231,7 +233,7 @@ void test_flash_channel_strobes_in_bursts() { lit = now_lit; } TEST_ASSERT_EQUAL_INT(8, edges); // four rising, four falling - TEST_ASSERT_INT_WITHIN(8, 1000, lit_ms); // 4 x 250 ms lit per cycle + TEST_ASSERT_INT_WITHIN(8, 500, lit_ms); // 4 x 125 ms lit per cycle } void test_max_level_caps_even_the_blowup_spike() { @@ -498,6 +500,79 @@ void test_strike_is_ignored_by_other_patterns() { TEST_ASSERT_INT_WITHIN(1, before, animator.pixel(0).w); } +// The Jess strips: Rave carries its own palette, so the spec leaves base dark. +cg::ChannelSpec raveSpec(const char* name, uint16_t pixels) { + cg::ChannelSpec spec = stripSpec(cg::PatternId::Rave, pixels); + spec.name = name; + spec.base = cg::Rgbw{}; + spec.weights.bias = 1.0f; + spec.speed = 0.008f; + spec.smoothing_s = 3.0f; + return spec; +} + +int maxChannelStep(const cg::Rgbw& a, const cg::Rgbw& b) { + const int steps[] = {std::abs(a.r - b.r), std::abs(a.g - b.g), std::abs(a.b - b.b), + std::abs(a.w - b.w)}; + int largest = 0; + for (int step : steps) largest = step > largest ? step : largest; + return largest; +} + +void test_rave_paints_saturated_colours_without_white() { + cg::ChannelAnimator animator; + animator.configure(raveSpec("Jess B", 36)); + run(animator, active(), 30.0f); + + for (uint16_t i = 0; i < 36; ++i) { + const cg::Rgbw p = animator.pixel(i); + TEST_ASSERT_EQUAL_UINT8(0, p.w); + TEST_ASSERT_TRUE(p.r > 100 || p.b > 100); // never dark, never grey + TEST_ASSERT_TRUE(p.g < 200 || p.r < 50); // no yellow or white + } +} + +void test_rave_drifts_slowly_and_smoothly() { + cg::ChannelAnimator animator; + animator.configure(raveSpec("Jess A", 12)); + run(animator, active(), 10.0f); // let the drive settle + + const cg::Rgbw start = animator.pixel(0); + cg::Rgbw last = start; + int biggest_frame_step = 0; + for (int frame = 0; frame < 60 * 50; ++frame) { // one minute at 50 fps + animator.update(0.02f, active(), false); + const cg::Rgbw now = animator.pixel(0); + const int step = maxChannelStep(last, now); + if (step > biggest_frame_step) biggest_frame_step = step; + last = now; + } + TEST_ASSERT_TRUE(biggest_frame_step <= 2); // no visible jumps + TEST_ASSERT_TRUE(maxChannelStep(start, last) > 60); // but it does move +} + +void test_rave_strips_on_one_controller_do_not_match() { + cg::ChannelAnimator a; + cg::ChannelAnimator b; + a.configure(raveSpec("Jess A", 12)); + b.configure(raveSpec("Jess B", 12)); + run(a, active(), 5.0f); + run(b, active(), 5.0f); + TEST_ASSERT_TRUE(maxChannelStep(a.pixel(0), b.pixel(0)) > 30); +} + +// A stale Rave strip breathes like everything else, but in its own colours — +// its base is dark, so falling back to base would black it out. +void test_stale_rave_keeps_its_colours() { + cg::ChannelAnimator animator; + animator.configure(raveSpec("Jess B", 36)); + run(animator, active(), 5.0f, /*stale=*/true); + + const cg::Rgbw p = animator.pixel(0); + TEST_ASSERT_TRUE(p.r > 0 || p.g > 0 || p.b > 0); + TEST_ASSERT_EQUAL_UINT8(0, p.w); +} + void test_gamma_duty_endpoints_and_monotonicity() { const uint32_t max_duty = 8191; TEST_ASSERT_EQUAL_UINT32(0, cg::gammaDuty(0.0f, max_duty)); @@ -543,6 +618,10 @@ int main(int, char**) { RUN_TEST(test_weld_flashes_arc_white_then_cools_orange_then_goes_dark); RUN_TEST(test_a_long_burst_glows_longer_than_one_tap); RUN_TEST(test_strike_is_ignored_by_other_patterns); + RUN_TEST(test_rave_paints_saturated_colours_without_white); + RUN_TEST(test_rave_drifts_slowly_and_smoothly); + RUN_TEST(test_rave_strips_on_one_controller_do_not_match); + RUN_TEST(test_stale_rave_keeps_its_colours); RUN_TEST(test_gamma_duty_endpoints_and_monotonicity); return UNITY_END(); } diff --git a/docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md b/docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md index 53f46b0..ae701a4 100644 --- a/docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md +++ b/docs/adr/0021-treehouse-controllers-standalone-and-garage-welder.md @@ -44,8 +44,8 @@ The last point reverses ADR-0020's "inactive mode is the only path to darkness", |---|---|---| | Dormer | dimmer | `Breathe`: a 12.5 s breath between 40% and 80%, never dark | | Julia | dimmer | `Wander`: a drunk walk between 40% and 80%; a full sweep takes about a minute | -| Jess | 2 strips + flash | unchanged for now | -| Swannatopia | 3 strips | `Fire` in the fireplace; both overheads the same warm `Incandescent` | +| Jess | 2 strips + flash | `Rave` on both strips: pink/violet/blue/cyan drifting a palette lap every ~3 min; the flash strobes 4 eighth-note pulses at 120 BPM, then waits 20 s | +| Swannatopia | 3 strips | `Fire` in the fireplace; warm `Incandescent` overhead; the chandelier the same look at half brightness and more amber | | Garage | strip + arc | `Weld` (below) | `Breathe` and `Wander` both span `min_level` up to the drive, so the floor of a look is @@ -64,7 +64,7 @@ The `Welder` (`lib/Weld`) works on three nested, random timescales: |---|---|---| | Session | 8–25 s of quiet between sessions | 2–6 bursts | | Burst | 1–5 s between bursts | 1–4 taps | -| Tap | 20–50 ms between taps (the cap's recharge window) | 5 ms gate pulse | +| Tap | 20–50 ms between taps (the cap's recharge window) | 15 ms gate pulse | The ranges live in `ChannelSpec::weld` in `src/targets/garage.h`.