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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Firmware/TreeHouse_Controllers/.vscode/c_cpp_properties.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -493,7 +499,7 @@
]
},
"defines": [
"PLATFORMIO=60119",
"PLATFORMIO=60200",
"ARDUINO_ESP32S3_DEV",
"ARDUINO_USB_MODE=1",
"ARDUINO_RUNNING_CORE=1",
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion Firmware/TreeHouse_Controllers/.vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
"ms-vscode.cpptools-extension-pack",
"pioarduino.pioarduino-ide"
]
}
45 changes: 30 additions & 15 deletions Firmware/TreeHouse_Controllers/README.md
Original file line number Diff line number Diff line change
@@ -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; 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 | 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 |

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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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/<location>.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.
5 changes: 5 additions & 0 deletions Firmware/TreeHouse_Controllers/include/net_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 5 additions & 0 deletions Firmware/TreeHouse_Controllers/lib/GardenState/GardenState.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
156 changes: 143 additions & 13 deletions Firmware/TreeHouse_Controllers/lib/Patterns/Patterns.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,71 @@ 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;
}

// 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; }
// 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<uint8_t>(*c);
h *= 16777619u;
}
return h;
}

// 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
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<float>(kRaveColours);
const int from = static_cast<int>(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.
constexpr float kWanderSweepS = 60.0f; // at speed 1.0
constexpr float kWanderPaceJitter = 0.3f;
constexpr float kWanderMinLegS = 4.0f;

// 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 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
Expand Down Expand Up @@ -96,6 +149,36 @@ 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;
// 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() {
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) {
Expand Down Expand Up @@ -130,6 +213,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_;
}
Expand Down Expand Up @@ -189,9 +276,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;
}

Expand All @@ -213,6 +306,35 @@ 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;
}

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<float>(index) / count +
phase_ * 3.0f));
value = drive_ * (0.75f + 0.25f * swell);
break;
}
}

// The Blow-Up Reaction overrides whatever the pattern wanted, then decays
Expand All @@ -229,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<float>(spec_.pixel_count) : 1.0f;
base = raveColour(palette_offset_ + kRaveSpread * static_cast<float>(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)};
}
Expand Down
Loading
Loading