A Linux daemon that renders WS2812 (NeoPixel) effects on the host and streams the frames over USB serial to an ESP32, which just clocks them out to the strip. All the logic — sensors, rules, effects — lives on the host, so changing behavior never means reflashing the microcontroller.
Built for an AMD BC-250 running its case lighting from CPU temperature and load, but nothing is board-specific. Co-authored with Claude (Anthropic).
host (led daemon) ESP32 strip
sensors → rules → effect → frames ──serial──→ SPI/DMA ──→ WS2812
- An ESP32 or ESP32-C3 dev board over USB (
/dev/ttyUSB0or/dev/ttyACM0). Prefer the C3: its native USB lets the firmware tell whether the host is actually running, so the boot animation also plays on reboots and on machines that keep standby power on their USB ports. A plain ESP32 can't see any of that over UART and only plays it after a true power cycle (see How it works). - A WS2812/WS2812B strip: data to the GPIO set as
strip.pin(default 13), plus 5 V and GND from a supply that can handle it (not the board's regulator; share grounds). - Optionally, a momentary power button and a wire to the PSU's PS_ON# line — the receiver can then stand in for the PS_ON→GND jumper an ATX/FSP supply needs before it starts (see Power switch).
- For the BC-250 there is a small carrier PCB in
hardware/bc250_carrier: the C3 Super Mini solders onto it, the PSU's 10-pin Mini-Fit Jr plug goes straight onto a header, and the strip, button, sense wire and four PWM fan headers each get a labelled connector — the power switch, strip and fan wiring below, on one board.
Two things are needed on the host:
g++andmaketo build the daemon. On CachyOS (or any Arch) they come in thebase-develpackage group.esptoolto flash the receiver (pluscurl, which is preinstalled). You do not need an ESP32 toolchain —make flashdownloads a prebuilt firmware image.
sudo pacman -S --needed base-devel # compiler + make
pipx install esptool # ESP32 flasher (or: pip install --user esptool)On Bazzite (immutable base image) esptool installs the same way, but
g++/make aren't in the base image — build the daemon inside a distrobox,
or copy a prebuilt led binary over.
Then build, flash, and start:
sudo make install # daemon + systemd unit + default config
sudo make flash # download prebuilt firmware + flash the ESP32
sudo systemctl enable --now led-controllermake flash reads the chip (target, default esp32c3) and port
(serial.port) from the config; override on the command line, e.g.
sudo make flash PORT=/dev/ttyACM0 TARGET=esp32. It stops the service during the
write and restarts it after.
Everything this repo can put on the receiver — the ATX power switch, the PWM
fans, the BLE phone remote — is described in the config, in the blocks
power_switch, fans and ble_remote (each has its own section below).
make flash bakes all three into the receiver's small settings partitions the
same way it already bakes strip.pin, so the full flash is the plain one:
sudo make flashThe config is /etc/led-controller/config.json when it exists, else the
repo's (CONFIG=path overrides). The shipped blocks are all off, so a fresh
box writes all three features off — the shipped power_switch block carries
the carrier board's pins with "enabled": false, and an explicit false
is written. That matters only on a receiver whose power switch is already
on: written off, it releases PS_ON# and cuts the machine's power (see Power
switch). Flashing such a box from a config that has no
power_switch block at all leaves the chip's switch settings alone, so
delete the block, or enable it, before flashing a machine whose power
already hangs on the receiver. Writing the features from one file also arms
the encoders' strictest cross-checks: a pin claimed by two blocks, or by a
block and strip.pin, is a hard error. Each feature's section has the flash-pwr / flash-fan /
flash-ble target that rewrites just that block in seconds.
Finally, edit /etc/led-controller/config.json for your hardware — at least
strip.leds, strip.pin, and serial.port — then sudo systemctl restart led-controller.
To try an effect directly (stop the service first, it holds the port):
led /etc/led-controller/config.json rainbowThis runs unprivileged once the serial-group membership added by make install
is active — that takes effect on your next login; before then, prefix sudo.
The daemon renders every frame and sends it over serial; the receiver only displays what arrives. Pin and LED count travel in each frame, so the firmware is generic and never needs reflashing for a config or effect change.
While the daemon isn't driving the line — at cold boot before it starts, and after it exits on shutdown — the receiver plays a boot and shutdown animation from flash. The daemon renders those too (through the same color pipeline), records them, and uploads them once at startup; the receiver stores them and replays them. So editing the boot/shutdown effects also needs no reflash — just a daemon restart. A fresh, never-recorded board stays blank until the daemon has run once and uploaded the recordings.
When the boot animation plays depends on the board. It always runs from chip reset until the daemon's first frame. On USB-native chips (the C3 and friends) it also re-arms whenever the USB host goes away and comes back — the bus keepalives stop for a few seconds and return — which covers a reboot (shutdown animation, dark while the host is down, boot animation as it comes back up) and a power-on where standby VBUS kept the receiver running the whole time. A plain ESP32's UART carries no trace of the host's state, so it shows the boot animation only when it genuinely loses power; on a reboot it stays dark after the shutdown animation until the daemon returns. If the power-on animation matters to you, get a C3.
The serial baud auto-negotiates: if bytes arrive but no frame decodes, the
receiver cycles through the supported rates until frames validate, then
remembers the working rate in flash. Changing serial.baud just needs a
service restart.
The firmware is a native ESP-IDF
project under firmware/ (no arduino-cli, no .ino). CI builds it for each
target and publishes merged images to GitHub Releases; make flash downloads
the one for TARGET and writes it — no toolchain needed.
sudo make flash # download prebuilt image + flash (default)
sudo make flash FW_RELEASE=v1.0.0 # pin a release instead of latestThe image is written at 0x0 and doesn't touch the NVS or LittleFS partitions,
so a reflash keeps a board's saved state.
Building from source is only needed to modify the firmware, or to bake a
non-default serial.baud / host_timeout_ms into it. It builds inside
Espressif's ESP-IDF container (needs docker or podman — nothing is installed
on the host, and make receiver-clean drops the cached image), or against a
native ESP-IDF if you set IDF_PATH=...:
sudo make flash-source # build (container/native) + flash
make receiver # build only, no flashClearing the board's saved state — the reset switch when the receiver acts
as if it remembers something wrong. Each erases one partition region; the app
and the power-switch wiring (pwrcfg) are untouched:
sudo make clear-nvs # remembered baud + strip geometry
sudo make clear-recordings # stored boot/shutdown animationsThe daemon re-uploads the recordings on its next start (and the receiver
re-formats the partition), so clear-recordings is also how you get past a
stale or half-written slot that the skip-unchanged hash check would otherwise
keep. As with flashing, esptool resets the chip — see the caveat under
Power switch.
The receiver has no console (the link is the daemon's), so it keeps a small log ring in RAM and hands it back over the link on request. Set
and its lines appear in journalctl -u led-controller — the recording uploads
and where they went, boot/shutdown replays, host-gone/host-back transitions, the
strip blanking on timeout, and the power switch's own decisions. The ring is in
RAM on a board running from 5VSB, so events logged while the host was down (a
follow-down, a boot replay) drain once the daemon is back. With the flag off the
daemon never asks and the receiver never sends a log line — the only thing it
ever says unprompted is a power-button request (see
Power switch).
/etc/led-controller/config.json. The first half describes the receiver — the
chip, the link, and everything it does on its own when the daemon isn't
running — the second half the host's rendering:
{
"target": "esp32c3", // chip to build/flash for (esp32, esp32c3, ...)
"serial": {
"port": "/dev/ttyUSB0", // "none" or "" (or LED_PORT=none) runs headless;
"baud": 921600, // omitting the key defaults to /dev/ttyUSB0
"debug_log": false // drain the receiver's log to journalctl
},
"host_timeout_ms": 5000, // receiver blanks after this long with no frame —
// baked into the firmware by `make flash-source`
// only; the prebuilt image always uses 5000
"power_on": { ... }, // boot/shutdown animations the receiver replays
"shutdown": { ... }, // (standalone effects only, see below)
"power_switch": { // the ATX power button, see Power switch
"enabled": false, // enabled + pins: flash-time (make flash-pwr)
"hold_seconds": 2, // hold this long for a hard cut ┐ the four tunings:
"boot_timeout_seconds": 10, // rail not up by then = release PS_ON# │ live (reload, or
"sense_low_mv": 800, // │ the phone's cog
"sense_high_mv": 2000, // ┘ on the Power tab)
"pins": { "ps_on": 3, "button": 1, "button_gnd": null, "sense": 2, "led": 8,
"wake": null }, // wake: an OpenPuck's power-on pulse, see Power switch
"short_press": null // what the daemon runs on a short press,
// e.g. "systemctl poweroff"; null = ignore it
},
"ble_remote": { // the phone power button + dashboard, see BLE remote
"enabled": false,
"name": "BC250",
"token": "" // 8-16 chars; readable by anyone with a shell here
},
"fans": [ // the fans, see Fans — each an input, a curve
{ "name": "pump", "output": "header1", // and an output (a receiver
"input": "fallback", "fallback": 65, "boost": 100 }, // header, a pin, or the
{ ... } // host's own pwm output)
],
"strip": {
"leds": 58,
"pin": 13, // ESP32 GPIO driving the strip
"reverse": false, // flip so LED 0 is at the far end
"brightness": 0.2, // 0..1, linear
"gamma": "2.2", // one value, or three ("2.0 2.2 2.4") per R/G/B
"white_balance": "ffb0f0" // RRGGBB neutral-white gain; ffffff = off
},
"sensors": [ ... ], // hwmon candidates, see Sensors
"rules": [ ... ]
}Configs from before September 2026 had an esp32 block and a sinks.serial
one; the daemon names the old key and what it became, and exits, rather than
run on a half-read file.
led --check /etc/led-controller/config.json # or: make checkWould the daemon start on this file, and is it the file you meant to write?
The check parses it and runs every block through the same validation the
daemon does at startup — retired keys, the fans block, the rules and their
conditions and effect names, the boot and shutdown recordings (rendered, so a
segment that can't record is caught here) — and then the things the daemon
would run on but nobody could have intended: a key that isn't one inside
serial, strip, power_switch, ble_remote, a rule or a recording
segment (with a "did you mean" for a near miss), a value of the wrong type, a
color that isn't RRGGBB, a LED count over the receiver's 100, an inverted
sense hysteresis, an enabled ble_remote without a token. Each is a line
naming the key; any of them fails the check (exit 1). A note: line is
something to look at that may be intended — a top-level key the daemon
doesn't read by name (effects see every top-level key as a shared default
setting), or a rule below a catch-all that can never be reached — and
doesn't fail it. It opens no port and sends nothing; the sensor lookups only
say "not found yet" on a box without them.
make install runs the check on the deployed file with the freshly built
daemon before it swaps the binary and restarts the service, so a key the new
version retired is reported while the old daemon still runs, not as a crash
loop after; make install CHECK=0 skips that.
The running daemon watches its config file and picks up a save within a
second or two — rules, effects, sensors, strip settings, the fans block —
with no restart. A change confined to the fans or power_switch blocks
leaves the running effect alone; anything else drops the current effect and
the matching rule starts afresh (a crossfade on the strip, like any other
switch). systemctl reload led-controller (SIGHUP) does the same
immediately. A file that doesn't parse or validate is reported in the journal
and the running config is kept, so a typo costs a log line rather than a
crash loop; fix it and save again. Two things still need more than a reload:
the serial block, because the port is opened once (the daemon says so when
it changes; restart), and anything flash-time on the receiver — the power
switch's pins and enable, the board's fan header map, ble_remote — which
is make flash's business (the fans' outputs are not: a reload moves them). The power switch's four tunings and short_press are
not flash-time: a reload pushes the tunings to the receiver and changes what
a press does at once. Editing power_on, shutdown or strip re-renders
and re-sends the boot and shutdown recordings, which holds the strip for
about a second. Edits from the phone's dashboard take a shorter path: they
are applied live and written into the file by the daemon itself (fans,
strip, a scene rule's color/level, the power_switch tunings and
short_press), which then treats the file's new mtime as its own doing
rather than a change to reload.
brightness, white_balance, and gamma fold into one per-channel lookup
table applied on the host, so they cost nothing per frame.
How to read the values — every knob is per-channel, red green blue:
white_balance "b4b0f0" gamma "1.9 1.8 1.7"
││││└┴─ blue └┬┘ └┬┘ └┬┘
││└┴─── green red green blue
└┴───── red
Two rules cover every adjustment:
- White balance scales a channel the same at every level. Lower a pair →
less of that color, from full white down to the dimmest glow.
ff= leave the channel alone. - Gamma changes how a channel fades. A higher number makes that channel
die away faster as colors dim; full-on and off never move.
2.2is neutral; you only need three different numbers when a tint appears only at low levels.
So: name the color the white is leaning toward, then move that channel —
down in white_balance if the tint is there at full brightness, up in
gamma if it only creeps in as things get dim.
Dial the knobs in by symptom, in this order:
- Everything too bright or dim for the room →
brightness(0..1). - Full white looks tinted →
white_balance: lower the channel(s) of the tint. White leans green → lower the middle pair (ffb0f0). This is the strip+diffuser calibration; start from the diffuser table below. - Full white is neutral, but dim white picks up a tint →
per-channel
gamma: raise the tint's channel so it fades faster (dim white leans blue →"2.2 2.2 2.4") — or equivalently lower the channel that's going missing (dim white leans blue because red drops out →"2.0 2.2 2.2").
The solid effect exists for exactly this — it puts up a fixed test field
through the normal correction pipeline:
led /etc/led-controller/config.json solid # full white: judge white_balanceLook, adjust the config, run again. For the gamma step you need dim white;
a forced effect has no rule settings, but effects fall back to top-level
config keys, so temporarily add "level": 0.25 at the top level of the
config (next to "strip") and run solid again. Remove it when done.
From the couch, with the BLE remote: the shipped config's first rule is the same test field as a switch — a static color with a level, which doubles as a plain light when it isn't tuning anything —
{ "if": "file:/tmp/led-static-color", "effect": "solid", "hold": 0,
"settings": { "color": "ffffff", "level": 1.0 } }— and the phone's dashboard shows every such file: rule as a scene
with a switch (README BLE remote). Switch "solid" on, drag
the white-balance sliders on the LEDs tab while looking at the strip
(each release applies to the live strip at once and lands in the config's
strip block), pull the scene's level slider down for the gamma step,
switch it off. The color picker on the scene changes the rule's color, so
the same switch tests a pure red or a warm white. Any file: rule with a
color or level setting gets the same controls; one without is just a
switch (the /tmp/led-night rule below, say). Put these rules first so they
win over everything else while on.
white_balance is the total correction — strip and diffuser combined into
one measured value. Starting points per diffuser (always confirm against
solid white):
| Diffuser | white_balance |
Why |
|---|---|---|
| none / clear cover | ffb0f0 |
just the strip's own green excess |
| white (milky) | ffb0f0 |
near-neutral filter; scatters but barely tints — trim blue a touch (ffb0e0) if white reads cool |
| black / smoked film | b4b0f0 |
dark film passes red most, so take the bare-strip value and pull red down ~30% on top of it. Films vary a lot: one measured black window film landed at b4ffff instead — its green/blue cut cancelled the strip's own green excess. Pair with per-channel gamma (e.g. "1.9 1.8 1.7") if dim white still drifts |
Low brightness costs no smoothness: the corrected values ride the wire with 8
extra fractional bits and the receiver rounds them away with a temporal dither
at its own strip-refresh rate (several hundred Hz — far above flicker fusion),
so dim gradients glide instead of stepping or shimmering. There is nothing to
configure; the old dither key is gone (this needs receiver firmware from the
same version — reflash once after updating). A change to strip re-renders
the boot/shutdown recordings so they carry the new colors — on a hand edit as
part of the reload, after a phone edit ten seconds after the last slider
moved (each re-render holds the strip for about a second).
sensors is an ordered list of chip:label hwmon candidates; the first present
wins (a bare chip name means its temp1_input). Used by temp conditions.
The daemon keeps rescanning until one appears. The default
covers the BC-250 (k10temp:Tctl, then the NCT6686D under either nct driver).
Two more kinds of candidate take the same slot: pmbus:CPU VRM / pmbus:GPU VRM
read the BC-250's VRM controller over I2C, and file:/path reads a file
holding one temperature — both described under Fans, where they are
also fan sources.
Evaluated top to bottom every 0.5 s; the first rule whose if holds wins. A rule
without if always matches (put a catch-all last). No match → strip dark.
{
"if": "proc:gamescope & temp>75",
"effect": "ember",
"hold": 5, // min seconds since last switch (default 0)
"for": 3, // condition must hold this long to count (default 0)
"settings": { "speed": 1.4 } // effect-specific
}Switches crossfade over the top-level crossfade_ms (default 600; 0 =
instant). for is an on-delay on the condition: it has to have held for that
many seconds before the rule matches, so a brief load spike never starts a
load rule. Dropping out is immediate. hold is the blunter guard: no switch
into this rule until that long after the last switch, whatever the
conditions did meanwhile.
| syntax | true when |
|---|---|
always |
always |
temp>N / temp<N |
sensor temperature above/below N °C |
cpu_load>N / cpu_load<N |
CPU busy above/below N % (bare cpu_load = >20) |
gpu_load>N / gpu_load<N |
amdgpu busy above/below N % (bare gpu_load = >20) |
proc:NAME |
a process with that name is running (15-char kernel limit) |
steam_dl |
a Steam download is actively moving bytes |
audio_playing |
system audio is actually playing (see below) |
file:/PATH |
the file exists |
!COND |
negation |
A & B |
both hold |
A | B |
either holds (& binds tighter; no parentheses) |
file: is the external control surface — touch /tmp/led-night to switch modes,
rm to switch back. A rule whose whole condition is one file: path is also
a scene on the phone's dashboard (BLE remote): a switch
the daemon flips by creating or removing that file, with a color picker or a
level slider when the rule's settings carry color or level.
The steam_download effect turns the strip into a live download progress bar
(percent driven by network throughput; green pulse at 100%). Reading other
users' Steam libraries needs the daemon running as root (the systemd unit does).
The audio_* effects visualise what's playing (combine them with cycle for
variety — they share one capture stream and auto-gain, so hopping between them
is seamless). The effects capture samples by spawning parec (or
pw-record) on the default sink's monitor while active; running as root
(the systemd unit does) lets it find the user session's PipeWire socket under
/run/user/.
The audio_playing condition costs nothing while the system is silent (one
procfs read per tick). Once anything is audible it asks the sound server for
its playback stream list (pactl -f json list sink-inputs, spawned
asynchronously about once a second): an uncorked stream means playing, corked
or none means paused/stopped. That verdict is immune to quiet passages inside
the music and reacts to pause/resume within a second or two. It is also the
only source of truth: if the list can't be fetched while audio is audible
(no pactl, no reachable session), the condition stays false and a note is
left on stderr. Placing the rule below the load rule means load takes the
strip whenever it's active and music has it otherwise — first matching rule
wins.
| name | description | key settings (defaults) |
|---|---|---|
alarm |
urgent heartbeat throbbing from the center | color (ff0000), pulses_per_second (2) |
audio_music |
audio-reactive bloom from the center: loudness sets its reach, bass pumps it and sends crests to the tips | palette (4a00b4,e02090,ff9c28), pulse (0.8), gain_seconds (6) |
audio_ripple |
audio-reactive: each bass hit sends a wave crest with a luminous wake racing from the center to the tips, reflecting back off them, colors walking the palette per beat | palette (0030a0,00c0e0,8040ff), travel_seconds (1.0), sensitivity (1.0), reflect (0.6) |
audio_spectrum |
audio-reactive liquid analyzer: a spring-loaded bar grows from the strip start with the loudness, a meniscus shine on its tip, bass pumping its base and treble its tip, a peak dot falling back with a comet tail between hits | palette (00e6b4,3a56ff,ff2e88), pump (0.7), bounce (0.6), peak_color (ffffff) |
aurora |
slow drifting color curtains walking a palette | palette (10ff80,10a0ff,8040ff), speed (1.0) |
boot |
calm bloom from the center into a full wash, then exhales into a dim hold | intro_seconds (7), color (ffffff) |
breathe |
palette wash on a slow breath with a drifting bright band | palette (0d4a44,30e090), period_seconds (3.5) |
button_off |
experimental: the lit strip collapses into the power button | duration_seconds (1.4), color (ffffff), reverse (true) |
button_on |
experimental: light spills out of the power button, then pulses | spread_seconds (2.5), period_seconds (4), color (ffffff) |
caustics |
underwater light caustics: two multiplied noise fields carve bright wandering filaments that merge and dissolve | palette (020818,004060,00b0c0,a0fff0), sharp (1.5), gain (3.2) |
comet |
Larson scanner with a fading tail, optional mirror | palette (8040ff,30c0ff), sweeps_per_second (0.7), tail_pixels (8) |
cycle |
rotates through a list of other effects | period_seconds (30), effects (list) |
drift |
soft palette glows wandering over a dark base | palette (2858ff,30d0b0), blobs (3), speed (1.4) |
ember |
slow warm aurora-style flow through an ember palette | palette (2a0a00,ff7d1e), speed (1.4) |
ink |
drops of ink recolor the whole strip in slow sweeping fronts, a glistening rim on each leading edge | palette (6a00ff,0080ff,00d0a0,ffb000,ff2060), period_seconds (10), spread_seconds (4) |
lava |
lava lamp: soft blobs drift, meet and merge into hotter spots | palette (ff0050,ff5a00,ffd000,00e5ff,7a00ff,ff00d0), blobs (4), speed (5.0) |
load |
CPU/GPU bars from center over a palette wash, each with a heartbeat that quickens under load; each side's palette window slides toward the hot end with its load | palette (00e0c0,2060ff,a040ff,ff4060,ff9000), heat_span (0.45), color_drift (0.3), pulse (0.5) |
load_boil |
CPU/GPU load as a lava lamp: each half's blobs boil harder and shift toward the palette's hot end with that side's load | palette (00e0c0,2060ff,a040ff,ff4060,ff9000), blobs (2), boil (2.5) |
orbit |
binary stars: glow pairs orbit a shared center, whipping through conjunctions that bloom white-hot | palette (1030ff,00d0ff,c080ff,ff40a0), pairs (2), origin (0.5) |
plasma |
drifting sines + noise walking a palette | palette (0040ff,00d0a0,c040ff,0040ff), speed (1.4) |
pulse |
soft palette rings expanding from the center | palette (5028ff,30d0ff), period_seconds (4) |
rainbow |
scrolling hue cycle with shimmer | cycles_per_second (0.9) |
shoreline |
waves roll in from both ends, decelerating and whitening into foam as they break at the center | palette (003048,00a090,60e0c0), period_seconds (6), foam_color (ffffff) |
shutdown |
CRT-style collapse to a point, then a slow phosphor fade | duration_seconds (5.0), color (0028ff) |
silk |
tide through a drifting warp field: color bands stretch, buckle and fold like fabric in a breeze | palette (3a1078,c03080,ff9040,20b0c0,3a1078), fold (0.5), sheen (0.3) |
solid |
fixed color everywhere — the color-calibration test field, or a plain static light | color (ffffff), level (1.0) |
steam_download |
live Steam download bar, green pulse at 100% | palette (d4009c,ff5a1e,ffd23c), done_color (00ff66) |
tide |
a palette gradient sliding and breathing | palette (102060,1890d0,30d0a0,8040ff,102060), speed (1.4) |
Colors are "RRGGBB" or "#RRGGBB"; a palette is several separated by
non-hex ("2a0a00, ff7d1e"), spaced evenly and blended in RGB. Every effect
also takes frame_ms (delay between frames; default 16 ≈ 60 fps).
Settings resolve per-rule first, then top-level config, then the defaults above.
Set power_on / shutdown to any standalone effect, or a sequence played
into one recording — typically a one-shot intro into a looping idle:
"power_on": {
"sequence": [
{ "effect": "boot", "record_seconds": 4,
"settings": { "intro_seconds": 3, "color": "ff7818" } },
{ "effect": "breathe", "record_seconds": 5, "loop": true,
"settings": { "palette": "2a0a00,ff7818", "period_seconds": 5 } }
]
},
"shutdown": { "effect": "shutdown", "settings": { "color": "0028ff" } }The last segment's loop decides the tail (loop from its first frame, else hold
the last frame); earlier segments play once. For a seamless loop make the
looping segment's record_seconds a whole multiple of its period. The whole
recording replays at one rate — the first segment's frame_ms — so a later
segment's own frame_ms has no effect on replay. Finite
effects (shutdown) record to completion; open-ended ones (boot) record for
record_seconds. Unchanged recordings aren't rewritten to flash (a hash is
compared), so wear is near zero. cycle can't be a slot (it needs the JSON
config).
Preview a slot exactly as the receiver will replay it, without hardware:
make virtual-strip && ./virtual-strip # one terminal
LED_PORT=none ./led config.json --preview power_on # anotherAn ATX/FSP supply (like the BC-250's FSP500-30AS) doesn't start until its PS_ON# line (the green wire) is pulled to ground — normally a permanent jumper. With the receiver powered from the PSU's 5VSB standby rail, it can drive that line instead and act as the machine's power button (after Thunkar/bc250-esp32-switch, minus its WiFi/BLE):
- Press the button while off → PS_ON# is sunk to ground, the PSU starts, the board boots. It fires on the press itself, not on the release, so that keeping it held afterwards is free to mean the failsafe below.
- Short press while the machine is up → the ordinary PC power-button
gesture: the OS is asked to shut itself down gracefully. The receiver can't
do that itself, so it asks over the link and the daemon runs the
power_switch.short_presscommand ("systemctl poweroff"); the machine then powers itself off and the sense wire's follow-down releases PS_ON#. This needsshort_pressset to a command — it shipsnull, so a box that hasn't opted in ignores the press (and says so in the journal). If nobody answers within 3 s — daemon down, no OS yet, feature off — the request is dropped and the feedback LED blinks fast for a moment, because otherwise an unheard press looks exactly like a broken button. Nothing about this path touches PS_ON#: the worst case is a machine that stays on. - Hold for 2 s while on → PS_ON# is released — a hard power-off for a wedged machine, and the answer to an OS that accepts the graceful request and then hangs. (Only a press that began while the machine was already up can do this: the press that powered it on never turns it back off, however long it's held.)
- With the sense wire (BC-250: TPMS1 pin 9, ~2.9 V while the board is up): a graceful OS shutdown is followed down (the sense line drops, the receiver releases PS_ON# so the PSU turns off too), and a board that never comes up within 10 s releases the PSU instead of leaving it energized. The wire is optional but strongly recommended — without it both are skipped, and after every OS shutdown the PSU stays energized, holding PS_ON#, until a 2 s long-press; what's left is less a power button than a toggle that sticks on.
- Keep holding the button → the failsafe: while it is physically held the sense line cannot power anything off. Follow-down is suppressed, and the boot timeout is parked, counting its 10 s from the release instead of from the power-on. This is the way out of a mis-wired sense line, which is otherwise self-sealing: a sense pin that reads low while the board is really up (wrong pin, wire fallen off, thresholds wrong) makes the boot timeout cut the PSU ~10 s into every boot, so the machine can never stay up long enough to reflash the config that would fix it. Press to power on, keep your finger down, and the machine stays up while you reflash — including through the reflash: a receiver that comes up with the button already held adopts that press as the failsafe, and won't read it as a fresh 2 s force-off either. The sense line is still sampled and logged while held (see the debug log below) — that log is how the right pin and thresholds get found.
Wiring (the shipped power_switch.pins, for the ESP32-C3; null = not wired):
| receiver pin | connects to |
|---|---|
GPIO3 (ps_on) |
gate of an N-channel MOSFET that sinks PS_ON# to ground (see note below) — not PS_ON# directly |
GPIO1 (button) |
momentary switch terminal A (internal pull-up, pressed = low) |
button_gnd: null when the switch is wired to a real GND (the carrier board), else e.g. GPIO21 |
switch terminal B — driven low as a local ground, so the button needs no run to a real GND. (GPIO21 is U0TXD: free while the host link is USB) |
GPIO2 (sense, null = not wired) |
optional board-power sense, e.g. BC-250 TPMS1 pin 9, which is the board's main 3.3 V rail. Emphatically not pin 15 (3VSB): that stays up whenever PS_ON# is held, so it reads like a working sense wire and then never fires follow-down or the boot timeout. Read as an averaged ADC voltage with hysteresis (sense_low_mv / sense_high_mv); the ADC saturates near 3.1 V, so a healthy rail logs ~2.9–3.1 V |
GPIO8 (led, null = none) |
optional feedback: the board's own little LED blinks while the button reads pressed (and through a wake pulse), so the wiring can be eyeballed without a PSU. GPIO8 is the plain onboard LED on common C3 dev boards; a blink shows regardless of the LED's polarity |
wake (null = not wired; GPIO20 on the carrier's J11) |
optional wake input: a 3.3 V active-high pulse from another device that wants the machine on — see Waking from an OpenPuck below. Internal pull-down; a rising edge while OFF powers on, and a pulse in any other state is dropped, so it can never shut anything down. Because it can do nothing else, this is the one pin that also moves at runtime, like the tunings: the daemon pushes pins.wake at startup and on a reload, and the phone's power settings pick it from the receiver's free pins (see The dashboard) |
| 5VSB + GND | PSU standby rail, so the receiver runs while the machine is off — read the warning below before also plugging in USB |
⚠️ Critical — 5VSB and USB at the same time. In this role the receiver is powered from 5VSB and still plugged into the host's USB for the LED link. That is two hard-paralleled 5 V supplies: most dev boards tie the USB connector's VBUS straight to their 5 V pin, so whichever rail sits higher pushes current into the other with nothing to limit it. Whenever the machine is off — the normal state for a power button — that means the receiver back-feeds the host's dead USB port continuously: current flows backwards through the port's VBUS circuitry (which is not designed for it and can be damaged) and phantom-powers part of the motherboard's 5 V rail off the PSU's small standby supply. Cut or unpin the 5 V wire (red) in the USB cable; keep D+, D− and GND. The receiver then runs solely from 5VSB and USB still works fully as a self-powered device — flashing, the LED link and the USB host-presence detection are unaffected. Some dev boards do have a protection diode between the USB jack and the 5 V pin, which blocks the back-feed — but many compact clones don't, so don't bet the port on it.
The PS_ON# pin drives a MOSFET, not PS_ON# directly. A 3.3 V pad can't be
tied to PS_ON#: the line is pulled to 5 V inside the PSU, and letting it rise
back-feeds the pad's clamp diode so it never reaches a clean "off" — direct
drive doesn't work. Wire a small N-channel MOSFET (a 2N7000 is plenty for
PS_ON#'s ~1 mA) as a low-side switch: gate ← the ps_on pin, source →
GND, drain → PS_ON#. ps_on HIGH turns the MOSFET on and pulls PS_ON#
to ground (PSU on); LOW releases it (PSU off). Add a gate pull-down (~100 kΩ,
gate → GND) so the MOSFET stays off whenever the pad isn't driving it — at
power-up before the firmware runs, during a reset, and on the "enabled": false release
path; without it a floating gate could start the PSU on its own. (100 kΩ is
ample: the gate is a near-pure capacitance, and the line switches about once
per boot.)
ESP32-C3 (running on the PSU's 5VSB standby rail — see the USB warning above)
──────────────────────────────────────────────────────────────────────────────
5V ── PSU 5VSB
GND ── PSU GND
GPIO4 ── WS2812B strip DIN (strip.pin) ── the LED output; daemon-set
GPIO3 ── 2N7000 gate (pins.ps_on) ── PS_ON# via MOSFET, below
GPIO1 ── button N (common) (pins.button)
GPIO21 ── button NO (pins.button_gnd) ── or a real GND, and null here
GPIO2 ── BC-250 TPMS1 pin 9 (pins.sense) ── board sense (3.3 V rail)
GPIO8 ── onboard LED (pins.led) ── feedback, no wiring
GPIO20 ── OpenPuck pin 017 (pins.wake) ── optional wake pulse; null here
PS_ON# drive — low-side N-channel MOSFET (2N7000)
──────────────────────────────────────────────────────────────────────────────
PS_ON# (PSU "green" wire; the PSU pulls it up to +5 V)
│
│ D (drain)
┌──┴──┐
GPIO3 ──┬─────┤ G │ 2N7000
│ └──┬──┘ (N-channel)
[100 kΩ] │ S (source)
│ │
GND GND
GPIO3 HIGH → MOSFET on → PS_ON# pulled to GND → PSU ON
GPIO3 LOW → MOSFET off → PS_ON# floats to +5 V → PSU OFF
The 100 kΩ gate→GND pull-down holds it OFF whenever GPIO3 isn't driving.
Button (momentary; N = common, NO = normally-open)
──────────────────────────────────────────────────────────────────────────────
Pressing shorts N→NO. GPIO1 idles high on its internal pull-up; GPIO21 is
driven low as the button's local ground, so a press pulls GPIO1 low.
Press = power on (on the press edge), 2 s hold while up = force off, and
held = sense failsafe. (Wire N and NO — NOT the NC terminal.)
LED strip (WS2812B)
──────────────────────────────────────────────────────────────────────────────
DIN ← GPIO4; the strip's +5 V and GND come from the PSU's main 5 V rail (a
26-LED strip is more than the 5VSB rail should carry), sharing a common GND
with the ESP32-C3. GPIO4 is the C3 build's value — the strip pin is the
daemon's `strip.pin` (pushed at runtime, cached in NVS), not one of the
`power_switch.pins`, so it just must not collide with the pins above or the C3's
flash pins (GPIO12–17). The repo's default `strip.pin` is 4 to match the
default `TARGET` (esp32c3); a plain ESP32 wants something like 13.
Three pin caveats. First, the sense pin is GPIO2 — one of the C3's
strapping pins, and that's fine. It was briefly moved to GPIO0 on hygiene
grounds (TPMS1 pin 9 sits at 0 V whenever the machine is off, and Espressif
recommend pulling GPIO2 up for glitch immunity), which turned out to be a bad
trade: the theory was thin — the same design guidelines are explicit that on
the C3 GPIO2 "does not determine SPI Boot and Joint Download Boot mode"
(that's GPIO9, with GPIO8 supporting), and GPIO2 was verified booting fine on
real hardware with pin 9 grounded — while the cost of a default that doesn't
match the wire in the machine is severe, because a sense pin reading a rail
that isn't there means the boot timeout cuts the PSU 10 s into every boot (this
happened; the failsafe above exists because of it). So GPIO2 it is, and
tools/pwrcfg.py no longer warns about it. Severity is chip-specific,
though: on a plain ESP32 the strapping pins select boot mode outright (GPIO0
low = download boot), so there a sense line grounded half the time really can
stop the chip booting — the encoder still warns for those. Second, if the sense
wire isn't connected, set "sense": null rather than leaving the input
floating: a floating ADC pin reads noise, and the boot timeout may cut the PSU
seconds after every power-on. Third, the defaults are C3-specific: on a plain
ESP32, GPIO1/3 are its UART0 console and 0/2 are strapping pins — pick
different ones.
Waking from an OpenPuck. An OpenPuck
(open firmware for a SuperMini nRF52840 that stands in for the Steam
Controller 2's puck) has a ColdBoot feature: when the paired controller's
Steam button is pressed while the host reads USB-unmounted, the puck drives a
GPIO high for 300 ms — on a motherboard, through a transistor across the
power-switch header. Here the receiver is the motherboard, so the transistor
goes: the puck's ColdBoot pin (silkscreen 017, its default) straight to
the wake pin, puck GND to the GND beside it — both are 3.3 V logic. It
is a plain active-high edge, debounced like the button and read with the
internal pull-down, so an unplugged or unpowered puck reads idle. Two rules
make it safe to hang a stranger's output on the machine's power: it is
edge-triggered and armed only after reading idle for 1 s (both boards come
up together on 5VSB and the puck's pin floats until its firmware runs; a level
held high across a receiver reset is not a press), and a pulse in BOOTING or
ON is logged and dropped — on a PC that same pulse would mean "shut down",
here it can only ever mean "on", whatever the puck's own gating decides.
Build the puck with EXTRA_FLAGS="-DOPK_PWR_SWITCH=1"; its pulse polarity is
a build flag too (PWR_SWITCH_ACTIVE), and the receiver expects the default,
active-high.
Power the puck from 5VSB so it keeps listening while the machine is off: on the SuperMini that is its BAT pin (the board has no pin on the USB side — the Pro Micro "RAW" position is unconnected). BAT reaches the board's supply node through a P-MOSFET that the host's USB VBUS switches off, and is isolated from the USB connector by the charger and a Schottky (BAT60B), so 5VSB cannot back-feed the host's port and the puck's USB cable stays intact — it must: the nRF's VBUS pin sits on the connector side, and the host's VBUS dropping when the machine dies is what clears OpenPuck's "USB mounted" gate. (This is the one place the 5VSB-and-USB warning above does not apply; the receiver's own dev board has no such diode.) 5 V on a charger's battery pin is off-label but harmless — the charger sees a full cell and idles, the LDO takes up to 6 V; a 1N5817 in series brings it nearer a battery's voltage if that bothers you. Once the machine's OS enumerates the puck it stops firing on its own; a second Steam press during BIOS lands in BOOTING and is dropped.
The feature is off until opted into: its settings are the config's
power_switch block, and they live on the receiver in a small dedicated flash
partition (pwrcfg), not in the firmware image, so the prebuilt image works
and pins change without touching source. make flash writes the partition
from the block alongside a normal flash, and make flash-pwr writes only
that partition — re-pinning takes a couple of seconds and keeps the installed
firmware. Three guardrails to know about:
- A config with no
power_switchblock leaves the chip's settings alone. The fans and the remote are written off in that case, harmlessly; the power switch written off releases an asserted PS_ON# once the receiver reboots — it cuts the machine's power — so only an explicit"enabled": falsedoes that. Put the jumper back first if the machine's power already hangs on the receiver. - The firmware must be v1.6.0 or newer — that's where the
pwrcfgpartition first exists.make flash-pwragainst an older installed firmware (or an olderFW_RELEASEpinned) writes a sector that firmware never reads: a silent no-op. Update the firmware itself first. (Individual settings added later are simply ignored by firmware that predates them — e.g. v1.6.0 exactly reads everything but theledpin, and thewakepin needs the firmware from Sep 2026 on.) - The block is validated before anything is written:
tools/pwrcfg.pyrejects pins the firmware would silently drop (nonexistent ontarget, SPI-flash or host-link pads, a non-ADC sense pin, a collision with thestrip.pinLED data pin or a fan header) and nonsense tunings (inverted hysteresis thresholds), and an unknown or missing key. The receiver has no console, so a config it can't use would otherwise just look like a dead button.
sudo make flash # firmware + the config's power_switch block (and the fans', and the remote's)
sudo make flash-pwr # rewrite only the power_switch block: secondsThe switch runs standalone — no daemon involved; the button matters exactly
when the host is off. Only short_press is the daemon's.
Of the block, the pins and enabled are flash-time only — a pin the
machine's power hangs on is nothing to change from a phone, and the flash
tool is where the wiring gets checked. The four tunings are not:
hold_seconds, boot_timeout_seconds, sense_low_mv and sense_high_mv
are values the switch's task reads on every poll, so the partition's are
just their defaults. The daemon pushes the config's values to the receiver
at startup, on a live reload and after a phone edit
(command 0x0E), the receiver applies them at once and keeps them in NVS
layered over the partition's — re-flashing pwrcfg with different
tunings wins again, the fans' rule — and the BLE dashboard
edits them from the Power tab's cog, against the sense wire's live reading.
short_press follows the same path on the daemon's side: a reload or a phone
edit changes what a press does without a restart.
Reflashing caveat: once the machine's power hangs on this pin, remember
that flashing the receiver from that machine resets the chip mid-write. On
a C3 the firmware defends itself: the asserted level is latched in the
always-on pad domain (gpio_hold_en) so it rides through the chip's internal
resets — including into the flashing ROM — and on any reboot that isn't a true
loss of standby power the intent is restored first thing at boot, read from
NVS or from the still-latched hold itself, so even a reflash or make clear-nvs that empties NVS can't talk the new firmware into releasing the
pin. (That double-check matters: make flash used to overwrite the NVS
region with the merged image's padding — it skips it now — and the freshly
booted firmware, finding no saved intent, would cut the machine's own power
at the end of the flash, hard enough to corrupt files on it.) Still, verify a
make flash on your wiring once — with a temporary PS_ON→GND jumper in
place — before trusting it with unsaved work. On a
plain ESP32 neither defense works for flashing: esptool resets it by
pulling the EN pin, a genuine chip power cycle that drops pad holds and reads
as a power-on reset. Flash one from another machine or with the jumper in
place. (Crashes/watchdog resets are still ridden out there too, provided
the PS_ON pin is one of its RTC-capable pins: 0, 2, 4, 12–15, 25–27, 32, 33.)
After a genuine 5VSB power loss the machine always stays off until the button
is pressed.
The receiver drives up to six 4-pin PWM fans at the fan spec's 25 kHz — built for an AIO liquid cooler, whose pump is just another 4-pin channel. The fans are powered from the PSU directly (they draw far more than a GPIO ever could); only their PWM input wires connect to the receiver, with all grounds common. The receiver's 3.3 V push-pull output is comfortably above the spec's ~2.8 V logic-high threshold, so standard 4-pin fans read it fine; the fans' tach (sense) wires stay unconnected. On the carrier board the four headers are labelled FAN1–FAN4; elsewhere, see the wiring table below. The daemon can also drive a fan header on the host itself — the BC-250's own, on the NCT6686D — the way CoolerControl does (see Host outputs).
Everything about the fans is the config's fans block: a list of fans, each
an input read through a curve onto an output. Every fan has a
name, an output, an input and a fallback, a curve for every input
but fallback (a parked fan's curve and fallback are optional — it drives
nothing), optionally a boost, and
optionally its tunings — hysteresis, ramp, boost_seconds. The list's
order is the phone's; there is nothing global in the block.
"fans": [
{
"name": "pump", // log lines and the phone's card
"output": "header1", // the receiver's header 1 (FAN1 on the carrier)
"input": "fallback", // fixed speed: the fan runs its fallback, always — no curve
"fallback": 65, // AIO pumps want a steady 65-100
"boost": 100, // the duty for the first boost_seconds after power-on
"boost_seconds": 5 // (no boost key, or null = none; default 5 s)
},
{
"name": "radiator",
"output": "header2",
"input": "temp", // the top-level `sensors` pick, in °C
"curve": "45:35 60:55 75:100", // input:percent points, linear between, flat beyond the ends
"hysteresis": 3, // the temperature must fall this many °C before the fan slows (default 3)
"ramp": 5, // max percent per second on the way down; speed-ups are immediate (default 5)
"fallback": 100 // what it runs when the input can't be read, and when no daemon drives it
},
{
"name": "exhaust",
"output": "header3",
"input": "gpio:0", // the BC-250's own fan header's PWM wire, on receiver GPIO0: the
"curve": "0:25 100:80", // RECEIVER reads it and runs this curve — daemon or no daemon.
"ramp": 0, // The board's firmware already smooths its output, so no ramp on top
"fallback": 100
},
{
"name": "board fan",
"output": "nct6686:pwm2", // the BC-250's own fan header, driven by this daemon (a host output)
"input": "k10temp:Tctl",
"curve": "50:30 80:100",
"fallback": 60
}
]output is what the fan drives:
| output | drives |
|---|---|
headerN |
header N of the receiver's board — the board's header → GPIO map (tools/pincheck.py FAN_PINS; the carrier's FAN1–FAN4 = GPIO5, 6, 7, 10) |
gpio:N |
a receiver GPIO by number, for a hand-wired build |
chip:pwmN |
a pwm output of the host, driven by the daemon — see Host outputs |
"" |
nothing: the fan is parked, its settings kept for later. Its input still shows on the phone — a host fan header as the input ("output": "", "input": "nct6686:pwm2") is how to watch what the board runs there, duty and rpm |
Every output is a runtime setting: the phone can move a fan to another header
or pin, and the receiver attaches its PWM there at once. The fans on receiver
outputs take the receiver's six PWM channels in list order. A receiver header
no fan names is not driven — and that is not "off": a 4-pin fan with a
floating PWM input runs at full speed, per the fan spec. A fan you want
stopped is one with "input": "fallback", "fallback": 0 (most fans stop at
0 % duty; the spec allows a fan to keep a minimum speed instead, so check
yours).
input is what the curve reads; the x unit of the curve follows it, and
so does where the curve runs — on whichever side can read the input:
| input | reads | x unit | runs on |
|---|---|---|---|
fallback |
nothing — the fan runs its fallback value, always; no curve |
— | receiver (a host output: daemon) |
gpio:N |
the duty of a PWM signal on the receiver's GPIO N — a fan header's PWM wire (the BC-250's own, say), so the receiver follows the board's BIOS curve with no daemon and the machine off. A receiver output only | % | receiver |
esp32_temp |
the receiver chip's own temperature sensor — a rough reading of the case air around the receiver (see below). The receiver runs the curve itself, with no daemon and the machine off. A receiver output only, on a chip with the sensor (the C3 yes, the plain ESP32 no) | °C | receiver |
temp |
the top-level sensors pick |
°C | daemon |
chip:label |
any hwmon temperature, same syntax as sensors (amdgpu:edge, nct6686:CPU; a comma list of candidates works too). The phone's picker lists every labelled one the machine has, with its reading; so does --fan-status |
°C | daemon |
pmbus:CPU VRM / pmbus:GPU VRM |
the BC-250's two VRM rails, read from the board's PMBus controller over I2C by the daemon itself — see VRM and GDDR6 temperatures for the two-wire mod that exposes the bus. Listed in the picker once the controller answers | °C | daemon |
smu:VRAM hotspot / smu:VRAM average / smu:VRAM 0..7 |
the eight GDDR6 chips — the hottest, the mean, or a named chip — through the bc250_memory kernel driver, or read from the SMU by the daemon when a patched BIOS has unlocked it; see VRM and GDDR6 temperatures. Code 80 saturates the sensor at 120 °C. Listed in the picker once patched |
°C | daemon |
file:/path |
one temperature in a plain file, in millidegrees (the sysfs convention: 1000 and up) or degrees. For telemetry some other program publishes as files. Files under /run/bc250 named *_temp are listed in the picker; any other path goes in through its Temperature file… row |
°C | daemon |
chip:pwmN |
a hwmon pwm output — the board's own fan header, i.e. what its BIOS fan curve is asking for, read over the host instead of a wire. The phone's picker lists each one with its rpm. Not one the daemon drives (it would read its own duty back), so not the fan's own output either | % (0..255 read as 0..100) | daemon |
cpu_load / gpu_load |
the rule conditions' readings | % | daemon |
curve is x:percent points in one string (up to eight), linear
between points and flat beyond the ends, so a curve never has to spell out 0
or 100 on the x axis. Floors, ceilings and scaling all live in the curve
("0:25 100:80" is a floor of 25 scaled to 80 %). A fallback input takes
no curve and every other input needs one (a parked fan's is optional) — the daemon (and
make flash) refuse the other combinations, along with an unknown or missing
key, so a typo is a startup error rather than a silently odd fan. A gpio
curve's inputs are whole percents 0..100, an esp32_temp curve's whole °C
0..100 (both travel to the receiver as bytes).
gpio:N as an input is the wired twin of chip:pwmN: a two-wire lead
from the fan header's PWM (pin 4) and GND pins to a free receiver GPIO and its
GND — only those two, never the header's +12 V, which would parallel the
board's fan rail with the PSU's. The receiver reads the pin with its internal
pull-up (a PC fan header drives its PWM open-drain, so the level is the
receiver's own 3.3 V; a push-pull 5 V driver needs a series resistor — check
with a meter), samples the duty four times a second, and runs the curve with
the fan's own ramp. Unplugged, the pin reads high — 100 %, the fan spec's
own answer to a missing signal. On the carrier board the free pins are GPIO0,
20 and 21 on J11 (each with a GND beside it; 20 and 21 are also the J5 UART
candidates), and make flash refuses anything else: a header's pin, another
fan's output, the strip, the power switch's pins, the flash pads, the USB
pair, and the strap pins (8, 9 — a PWM at 0 % is a pin held low at reset,
which is the download-mode strap). A pin that arrives at runtime (a phone
edit, a daemon push) is checked again on the receiver, which logs a refusal
and runs the fallback. The phone never has to guess: the receiver publishes
the pins that pass that check, for inputs and for outputs, and the editor
lists them.
esp32_temp reads the temperature sensor inside the receiver's own
chip — nothing to wire. It is the die's temperature, not the air's: the chip
sits a few degrees above the air around it (more with the radio busy), and
the offset varies from chip to chip, so treat it as a trend of the case air
near the receiver rather than a thermometer, and write the curve against what
it actually reads (the phone's picker shows it live). The receiver reads it
once a second and smooths it over ~8 s, so it has no hysteresis of its own
(the key is refused); ramp applies as usual. Its use is a curve that keeps
working with no daemon — a case fan that tracks how warm the box is, on a
bench build or while the host is booting — not a replacement for the host's
real temperatures. The page shows the reading whether or not a fan follows
it; a receiver with no sensor doesn't offer the input, and one that is sent
it anyway runs the fallback.
boost and fallback are the fan's standalone settings — what the
receiver does on its own. Full speed is the safe answer for cooling, which is
why fallback is 100 in most examples; a fan with no boost starts at its
fallback instead of boosting. Reading the radiator above as a timeline:
- Power button pressed. The receiver asserts PS_ON#, sees the rail up, and
runs the pump (which has a boost) at 100 % for
boost_seconds. - Boost ends; the daemon isn't up yet, so the radiator runs its fallback, 100 %.
- The daemon starts, reads the temperature and pushes the curve's 35 %. The radiator settles there.
- The temperature sensor stops reading (a driver unloaded, a thermistor unplugged): the radiator runs its fallback until it reads again.
- The daemon crashes or is stopped under a running machine. Fifteen seconds later the receiver notices the silence and the radiator goes back to 100 %.
- Normal shutdown. The daemon's shutdown notice says the machine itself is
powering off, and the receiver holds the last live duties instead, so the
fans wind down from where they are when the PSU cuts — no full-speed blip.
(A plain
systemctl restartorstopdoesn't say that, and the fans fall back as in step 5 if no daemon comes back.)
The pump, a fallback input, runs 65 % from step 2 on and never notices the
daemon; the exhaust, a gpio input, follows the board's own fan header from
step 2 on the same way.
Each receiver fan's duty comes from four places, strongest first: the
boost while its window runs; the receiver's own curve for a gpio
or esp32_temp input; the daemon's live duty (a host curve's output, pushed on the 0.5 s
rule tick when it changes and refreshed every 5 s, never persisted); and the
fallback. With the power switch configured, "host powers on" means its
power-on event — the button press asserting PS_ON# — confirmed by the sense
wire reading the rail up, so a reset of the receiver itself (a crash, a
reflash, a daemon reconnect) never re-fires the boost. Without the power
switch, USB host presence stands in (debounced 3 s so a bus reset doesn't
re-fire it), else the boost fires once at receiver boot. Each fan's window is
its own boost_seconds long; no boost (or boost_seconds: 0) sits it out. A
boost is the receiver's, run before the daemon exists, so a host output has
none (the key is refused there).
The fallback is a slider on the phone, on every fan's card, and the
output and input are pickers in the card's editor; all of them write to
wherever the value lives. While a daemon is connected, to the daemon: into the
config file like every other edit, and the daemon pushes the standalone values
to the receiver. With no daemon at all, straight to the receiver (a control
op over BLE, nothing relayed to a host), stored in its flash so it survives
power cycles — and the pickers then offer just what a receiver runs by itself:
its headers and pins, and the fallback, gpio:N and esp32_temp
inputs. So the receiver is a fan controller on its own: every fan on it runs
its fallback or its own curve, the boost still primes a pump at power-on, and the phone dials, adds,
moves and removes them. That is the bench case (a carrier board, a PSU and
fans, no BC-250 booting) and the machine-off case too — dial the fans down at
night without waking anything. What the receiver can't do alone is a host
curve: it has none of the host's temperatures to read, so a temp or load fan
runs its fallback until a daemon connects. There is one source of truth, and it is the
config: a daemon connecting pushes the file's list over whatever the receiver
held — every output, fallback, boost, input kind and receiver curve — so a
fan dialled standalone lasts exactly until then, and the phone writes into the
file whenever a daemon is there.
hysteresis, ramp and boost_seconds are each fan's own, with
a default where the fan says nothing (3 °C, 5 %/s, 5 s): a board header
mirrored over gpio:N or chip:pwmN is already smoothed by the board's
firmware and wants ramp: 0, while the temperature curve next to it wants the
ramp — so each says for itself. Each applies to some fans only and is refused
on the rest, so a key that would silently do nothing is a startup error
instead: hysteresis to a temperature input (temp, chip:label, pmbus:,
smu:, file:), ramp to any input with a curve, boost_seconds to a fan
with a boost. The receiver runs ramp and boost_seconds itself (rounded
to whole units); hysteresis is the daemon's, since only its curves read a
temperature.
The old shape is refused. Until Sep 2026 the block was an object of
header1..header6 blocks with a source; the daemon (and led --check)
now refuse it with the converted list printed, ready to paste over the old
block — source became input, each header's number became its output,
and the old pins override became gpio:N outputs. The constant input is
refused with a line saying it became fallback.
A fan whose output is chip:pwmN is driven by the daemon writing the
host's own hwmon pwm output — the BC-250's fan header on the NCT6686D
(fan2/pwm2 is the main fan header per the BC-250 docs; --fan-status and
the phone's picker show every output with the rpm of the tachometer beside
it, which tells the wired one). It works the way CoolerControl and lm-sensors'
fancontrol do: pwmN_enable = 1 takes the output over (manual), pwmN is
the duty (0..255), and the value pwmN_enable held before (2 on the nct6687
driver: the board's own curve) is what hands it back.
It needs a driver that can set the output: the out-of-tree
nct6687d (force=1) can; the
in-kernel nct6683 is read-only — the daemon then says so once, leaves the
output to the board, and the phone's card reads "read-only". Only one program
may drive an output: stop CoolerControl or fancontrol for the outputs the
config names (the daemon notices a pwm value it didn't write and says so).
Switching between the board and the host is the output, from the phone or the
file: a host output no fan names is the board's — park the fan ("output": "")
to hand it back, and make the header its input to keep watching it there. A host output's fallback is what it runs while its input
can't be read; it has no boost.
The board always gets its output back. Every write to a pwmN_enable
goes through one place (daemon/pwmout.hpp), and:
- before an output is taken over, its name, its path and the
pwmN_enableandpwmNvalues found are written to a claims record under the state dir (/var/lib/led-controller/pwm-claims, systemd'sStateDirectory) and synced to disk — no record, no takeover; - it is handed back —
pwmNrestored first, thenpwmN_enable, read back to check (lm-sensors'fancontrolorder) — whenever no fan drives it any more (an edit, a reload, a fan deleted), and on every exit the daemon runs code for; one that won't go back is set to full speed instead (pwmN_enable = 0, then manual at 255), never left at a low duty; ExecStopPost=/usr/local/bin/led --release-fanshands back whatever the record still holds after any stop — a crash,kill -9, the watchdog — and a starting daemon does the same before it claims anything;WatchdogSec=30: the main loop pings systemd, and a hung daemon is killed (thenExecStopPostruns), so a fan can't freeze at its last duty;- a hwmon driver reloaded while an output is driven comes back under another
hwmonNwith the chip still in manual mode: the claim follows the output by name, so the values restored are the board's, never the daemon's own; - every 5 s the output is read back: a
pwmN_enablesomething else reset (a resume from suspend) is taken back, and the journal says so; - a lock in the state dir keeps a second daemon (a
./led cfg aurorabeside the service) from touching the outputs;--fan-statusand--checknever do.
sudo led --release-fans # hand everything back by hand (a no-op while a daemon holds them)
cat /var/lib/led-controller/pwm-claims # what is taken over right now (absent: nothing)The BC-250's two voltage regulators (the CPU rail and the GPU rail) sit on
one PMBus controller at I2C address 0x60, and each reports its own
temperature — the part of the board that gets hottest under load and that no
hwmon driver sees. The daemon reads them itself, no other service needed, as
pmbus:CPU VRM and pmbus:GPU VRM: a fan input, a sensors candidate for
the LED temp rules, and two rows in the phone's picker.
The bus has to be brought out first. I2C_HEADER1 (3 pins: SDA, SCL, GND)
carries nothing on its own; the live SMBus is on the TPMS1 debug header. Two
jumper wires bridge them: I2C_HEADER1 SCL → TPMS1 pin 4 (SMB_CLK_MAIN)
and SDA → TPMS1 pin 6 (SMB_DATA_MAIN), no ground wire needed. The pinout
photos in BC250-Telemetry's hardware guide
show exactly which pins; that project also worked out the controller's
register formats, which the daemon's reads follow. The kernel's i2c-dev
module is what makes the buses appear as /dev/i2c-*; make install lists
it for every boot (/etc/modules-load.d/led-controller.conf) and loads it
right away, so the wires are the only step. To see the bus yourself:
sudo i2cdetect -l # the buses
sudo i2cdetect -y 4 # 60 should answer (usually bus 4)The daemon finds the bus on its own: it probes every /dev/i2c-N for a device
at 0x60 whose output-voltage register reads sensibly (a read only — it writes
nothing to a bus that hasn't answered), logs pmbus: VRM controller at 0x60 on /dev/i2c-4, and reads both rails every half second on a thread of its own
while a fan, a rule or a watching phone wants them. The rails are read only
while wanted, and a controller that stops answering (the wires came off) is
dropped and searched for again, the fan on its fallback meanwhile like
any lost sensor. If nothing answers on any bus, --fan-status shows the
input as (not found) and the daemon keeps looking every 30 s.
While a phone watches, the same controller's rail voltages and currents and its 12 V input are read too, for the Host power card on the Power tab (The dashboard); nothing reads them otherwise.
The eight GDDR6 chips are a harder case. Nothing in the kernel reads them: the
value lives behind the SMU (the GPU's management microcontroller), which has no
stock command that returns it. The way around it — worked out by
pan-Rijovich/bc250-memory-temperature
and packaged by BC250-Telemetry,
both MIT — is to upload a small program into the SMU and point an unused command
slot at it; that program then asks the memory controller for each chip. The
daemon does this itself, exposing the chips as smu: sources — no setting to
turn on; it works wherever it can and touches nothing where it can't:
"input": "smu:VRAM hotspot" // the hottest chip
"input": "smu:VRAM average" // the mean of the eight
"input": "smu:VRAM 3" // one named chip, 0..7"vram_temps_interval_ms": 1000 // optional, top level: how often to re-read (default 2000, floored at 250)- Nothing is opened until something asks for an
smu:reading — a fan's input, a rule, or the phone's sensor picker. - The
bc250_memorykernel driver, when loaded, does the reading (see below); the daemon then never commands the SMU. - Otherwise the SMU's secure-access gate must already be open, done by a patched BIOS such as RescueMei's DXEv3 build. The daemon does not run the unlock itself — that is an exploit, the risky half, and it belongs in firmware that runs once at boot. If the gate is closed the daemon says so once and offers no readings.
The default 2 s is well inside how fast a GDDR6 package heats (its thermal time constant is tens of seconds), eight SMU commands every 2 s are nothing next to a GPU governor sharing the window, and the kernel driver caches its readings for 1 s, so reading faster gains nothing there.
The daemon also checks the board is a BC-250 on stock P3.0 firmware (the
build the uploaded program was compiled against) and reads its own upload back
before trusting it. The patch lives in the SMU's RAM only and is gone on the
next reboot; the daemon re-applies it when asked. On anything that is not a
BC-250 the check fails before the SMU is opened, and the sources simply never
read. Code 80 is the sensor's
ceiling and reads as 120 °C, meaning "at least that" — set a VRAM curve's top
below 120. --fan-status prints whether the patch took and, if not, why (a
locked SMU, the wrong board or BIOS); the daemon logs the same to the journal.
Running alongside a GPU governor. The SMU is reached through one shared
register window (0xB8/0xBC on 00:00.0), and an SMU GPU governor such as
filippor/cyan-skillfish-governor
(smu branch) drives the same window. That governor takes flock on the PCI
config file around its accesses, and the daemon takes the same lock around
each of its own, so the two coordinate rather than corrupt each other's
transactions. This makes concurrent use safe in the ordinary case. It is not a
hard guarantee — the governor locks per register access rather than per
address+data pair, so a residual race on its own writes remains — so keep the
read cadence modest (the 3 s default is fine; there is little reason to poll
VRAM temperatures fast) and, if you can, run only one SMU tool. A reader that
does not take the lock (BC250-Telemetry's own memory service) must not run
with the governor at all.
Risk. The uploaded program waits on the memory controller with no timeout of its own, and this integration has not been validated on hardware. A wedged SMU may need a full power-off (standby included) to recover.
If you would rather not have the daemon touch the SMU, BC250-Telemetry's own
memory service publishes the same readings as millidegree files that the
file: input reads (file:/run/bc250/memory_hotspot_temp,
memory_avg_temp), and the picker lists any /run/bc250/*_temp file. The same
files exist for its VRM readings (cpu_vrm_temp, gpu_vrm_temp).
The linux-cachyos-bc250
kernels ship two drivers for the same hardware,
bc250_vrm (loaded on every
BC-250) and bc250_memory
(opt-in). Each talks to what the daemon would otherwise talk to itself, and
neither can share: bc250_vrm claims address 0x60, so i2c-dev refuses
the daemon the bus, and bc250_memory drives the SMU's mailbox from the
kernel without the lock above, so two readers would garble each other's
commands. The daemon therefore steps aside for them, and no config changes:
bc250_vrmregistered →pmbus:CPU VRM/pmbus:GPU VRM(and the Host power card) are read from its hwmon files; the bus is never opened. A driver loaded after the daemon started takes over within 5 s, the bus released first. Without the wires the driver registers nothing and the daemon's own scan finds nothing either — same as before.bc250_memoryloaded →smu:sources are read from its hwmon files, and the daemon sends the SMU no command — only one SMU client is safe. A module loaded while the daemon is reading the SMU itself is noticed before its next command. If the module is loaded but its probe failed (SMU locked), it has no sensors and the daemon still leaves the SMU alone, saying so in the journal and--fan-status.
The drivers' own names work as specs too and mean the same reading:
bc250_vrm:CPU VRM Temp is pmbus:CPU VRM, bc250_memory:VRAM Chip 3 is
smu:VRAM 3 — read on the source's own thread (a bc250_vrm sysfs read
sleeps ~5 ms in the kernel, which the render loop shouldn't wait on). The
phone's picker lists them once, under pmbus and smu. --fan-status
says which way each is read (vrm: read through the bc250_vrm driver (...)).
The hotspot and average are computed from the eight chips either way.
Two consumers read the same block:
make flash/make flash-sourcebake the standalone part — the board's header map, and per fan on a receiver output its output,fallback,boost,boost_seconds,ramp, the input kind and agpioinput's pin and curve — into the receiver'sfancfgflash partition, exactly asstrip.pinis baked. So a box that never runs the daemon is configured by editing the block and runningsudo make flash-fan(the fancfg partition alone, a couple of seconds); its fans run theirfallbackduties and their gpio curves, because those are the receiver's own. Fans on host outputs are the daemon's and are only checked here. The config is/etc/led-controller/config.jsonwhen it exists, else the repo's (CONFIG=pathoverrides).- The daemon reads the block at runtime, drives the curves, and pushes the
same standalone part at startup (and after every edit), so an edit takes
effect without a reflash. The receiver persists that push in NVS; a
re-flashed fancfg with different values outranks a stale push (the same
newer-default-wins rule as its saved baud), and
make clear-nvsalso reverts to the flashed values.
sudo make flash # firmware + the config's fans block, always
sudo make flash-fan # rewrite only the fans block: seconds
sudo make flash-fan CONFIG=other.json # ...from a different config
led /etc/led-controller/config.json --fan-status # what each fan resolves to right now--fan-status prints every fan's output (the receiver slot, or the host's
pwm file) and input, the sysfs file (or pmbus: rail, or file: path) it
resolved to, its current reading and the duty it would run, plus the
catalogue of sensors and outputs as the phone's pickers see it (a fallback
or gpio fan on a receiver output is marked as the receiver's to run) — the
first thing to run when a fan isn't doing what the curve says, and whether
dashboard edits can be written back to the config. It never takes a host
output over.
With the BLE remote on, the web page shows every fan live and lets you
redraw its curve, pick its output and input, set boost and fallback, change
its hysteresis, ramp and boost length — and add and delete fans — see the
dashboard under BLE remote. An edit travels phone → receiver →
daemon, which validates it exactly as it validates the config, applies it on
its next tick, and writes it into the config file —
/etc/led-controller/config.json on a deployed box. Only the bytes of the
fans block are replaced (re-printed in the file's own four-space style,
every key and fan in the order you wrote them); everything around it stays
byte for byte as it was, and the first rewrite of a daemon's run leaves the
file as it found it in config.json.bak beside it. So the config remains the
one place the box is described: back it up, move it to a new install, diff
it, and the phone's edits come along. The same file watch that reloads a hand
edit (Live reload) works the other way too: edit a curve in
the file, and the phone's dashboard shows the new curve a moment later.
Every edit names the list's revision as the phone saw it, so an edit made against a list that has changed since (a second phone, a hand edit) is refused rather than landing on the wrong fan; a refused edit comes back with the reason, and the editor stays open. Names (up to 16 characters) are editable too; a sensor or host output that isn't on the machine is refused there and then (a hand edit in the file may name one that turns up later). A sensor that stops reading while the daemon runs — a thermistor unplugged (the chip then reports 0 °C), a driver unloaded — puts its fan on the fallback speed, not on a curve fed a temperature nobody measured; the journal says so once, the phone's row says "no reading", and the sensor is looked up again until it is back. The dashboard is read-only when the daemon can't write its config (the page hides the cogs); a write that fails mid-way is reported in the journal and the edit runs until the next restart. The daemon takes edits only from the receiver's link, which only the receiver's BLE service writes, and that only with the flash-time token — the same trust the power button has.
Wiring:
| output | receiver pin (ESP32-C3) | connects to |
|---|---|---|
| header1–header4 | GPIO5, 6, 7, 10 — the carrier board's FAN1–FAN4 | one fan's PWM input each (pin 4 on the 4-pin connector) |
| — | — | fan +12 V and GND come from the PSU, sharing a common ground with the receiver; tach (pin 3) unconnected |
a gpio:N input |
GPIO0, 20 or 21 — J11 pins 12, 8, 10, each with a GND beside it | a fan header's PWM (pin 4) and GND, two wires only — see gpio:N above |
The header → GPIO map is the board's, not the config's (tools/pincheck.py,
FAN_PINS; the plain ESP32 gets 16, 17, 18, 19, untested). A hand-wired
build on other pins, or with a fifth and sixth fan, names its pins as
outputs: "output": "gpio:20". tools/fancfg.py validates the pins like the
power switch's encoder does — nonexistent on TARGET, flash/host-link pads,
input-only pads, a raw strap pin, collisions with strip.pin, the power
switch's pins (a warning from flash-fan alone, an error from make flash,
which writes both blocks), a header the board doesn't have, or each other —
and the receiver checks every pin again whenever one moves, refusing (and
logging) one it can't drive rather than driving it.
Two guardrails, mirroring the power switch's:
- The chip must run a firmware that reads this block's layout (the
FAN5fancfg, from the fan list on; older blobs are not read) and whose partition table has thefancfgentry (v1.13.0 or newer).make flash-fanagainst an older layout writes a sector that firmware never reads — a silent no-op, except the receiver's debug log saysno fancfg partitionat boot — and a firmware that knows only an older layout reads the new blob as "no config" and turns the fans off.make flashwrites both halves together, so onlyflash-fanalone can hit this: reflash the firmware first. The daemon, the firmware and the page moved together to the fan list: update all three (make install,make flash, the page). - A full
make flash/make flash-sourcerewrites fancfg from the config and wipes the daemon's persisted push, so the receiver runs exactly the flashed block until the daemon next starts. Which is the same block, so nothing changes unless the two copies of the config differ.
With the power switch fitted, the receiver can also be the machine's remote
power button: it advertises a small Bluetooth LE service (idling on 5VSB even
while the machine is off), and a phone within radio range can press the
button — no WiFi, no app store, works wherever the machine is carried. The client is the Web Bluetooth page in docs/ (host it on GitHub
Pages and "install" it from Chrome once; it works offline afterwards) — or
any BLE tool that can write a GATT characteristic. Web Bluetooth is a
Chrome/Edge-on-Android (and desktop) feature; Safari has none and WebKit has
said it won't add it. On an iPhone open the page in
Bluefy (free;
WebBLE, paid, also works) — a browser app that ships its own Web Bluetooth
polyfill on top of CoreBluetooth. The page needs nothing beyond the standard
API, so it runs there unchanged; opened in Safari it offers the Bluefy link.
Like the other receiver features it is standalone (no daemon involved)
and configured in the config's ble_remote block, which make flash bakes
into its own 4 KB blecfg partition:
"ble_remote": { "enabled": true, "name": "BC250", "token": "5f3a9c1e2b7d" }sudo make flash # firmware + the block
sudo make flash-ble # rewrite only the block: seconds (re-token, rename, disable)token (8–16 characters, required when enabled; openssl rand -hex 6 makes
a good one) is the shared secret the phone must present with every command —
the only thing standing between anyone within radio range and your power
button, so tools/blecfg.py refuses to write an enabled block without one.
Enter it once on the web page; it is remembered on the phone. It sits in the
config in the clear: anyone with a shell on the box can read it, and can also
just run systemctl poweroff, which is all it guards. name is the
advertised device name (public by definition).
Several machines: the page keeps every receiver the Bluetooth chooser has
ever granted and lists them on the Receiver tab (tap the receiver's name in
the header to get there), each marked on / booting / off / nearby / out of
range from its advertisements while the tab is open — pick one to switch (the
page holds one connection at a time, and reconnects to the last pick on its
own), "add a receiver" opens the chooser for a new board. Tokens
are remembered per receiver: a new board first tries the token you entered
first; if that board was flashed with a different one, its first command is
rejected and the page asks for that board's token. Give each board its own
name so the list reads as more than BC250, BC250 (2).
What the remote can do to the machine is deliberately narrow — the same gestures as the physical button, and nothing else (the fan dashboard below edits the daemon's curves, never the power):
- Power on (the press-while-off edge,
pwr::remoteRequest), taking the exact same path as a real press: the fan boost arms, the power-on animation replays, the sense wire confirms the boot. - Graceful shutdown (the short press): asks the host over the link to run
its poweroff command — needs
power_switch.short_pressset in the daemon config, like the button. - Force off (the hold): release PS_ON# and cut the PSU immediately — the crash rescue, which is the one moment a remote power button really earns its keep. The page asks twice (the sheet, then a confirmation); the firmware accepts it while booting too (a boot that never comes up is exactly a case for it).
The page is four tabs, each fitting a phone screen; the tabs, the editors
and the shutdown sheet are history entries, so the phone's back gesture
steps out of them the way a native app does. A sticky header on every tab
names the receiver (a tap opens the Receiver tab), shows the PSU
state, and — while the daemon reports — the host's readings: CPU
temperature (the top-level sensors pick), CPU load and GPU load.
- Power — the ring alone, centred where a thumb reaches. Its color is
the PSU state and its label the one thing a tap does: connect, power on,
or (while on) open the sheet with Shut down and Force off. Under
it, while the machine is on and its VRM controller answers (VRM and GDDR6
temperatures), the Host power card: per
rail (CPU, GPU) the volts, amps, watts and VRM °C, the 12 V input, and the
two rails' draw together in its title. A cog
in the corner opens the power switch's settings: the sense wire's two
thresholds as sliders under its live reading in millivolts (read it with
the machine on and off, put the values well apart between the two), the
boot timeout, the hold-to-force-off time, and — while the machine is on —
the short press switch (whether a press asks the machine to shut
down; the same
short_presssetting the sheet's Shut down uses), and the wake input: the pin an OpenPuck's power-on pulse arrives on, picked from the receiver's free input pins (the same list agpio:Nfan input offers, less the pins fans read on) or set to none — the one pin a phone may move, since a pulse there can only ever power the machine on. The rest of the wiring is listed, read-only: those pins are set when flashing. Save goes to the daemon while the machine is up (into the config'spower_switchblock — the wake pin intopins.wake— then pushed to the receiver —savedmeans the config came back) and straight to the receiver otherwise, where it is stored until a daemon next connects and the config wins again; the editor says which. The receiver checks a wake pin against its own facts (another feature's pin, a flash, strap or link pin) and refuses one it can't use, keeping the pin it had — the page compares the two views a few seconds after a routed save and says so; a daemon from before the setting never pushes a wake pin, so the card then says the pin is kept on the receiver alone. A receiver on firmware from before the settings shows no cog. - Fans — one row per fan, in the config's order: its name, its output
and what it follows with that input's current reading (
Header 2 · CPU temperature · 58.3 °C,nct6686 pwm2 · Board curve), and the duty actually applied. A chip appears only when the fan is not doing what it is set up to do, and the row then says what it runs instead:boostfor the power-on boost,fallbackwhen nothing is driving it (the host off, the daemon not yet up),holdwhile the host powers down and the last live speed is kept, and for a host outputread-only,no outputornot drivenwhen the daemon can't drive it. Tapping a row shows its curve with the operating point marked; the cog opens the fan's editor: name, Output (the receiver's headers and free pins, the host's pwm outputs a driver can set — each with its duty and the rpm of the matching tach — or none; an output another fan has is listed with that fan's name and can't be picked), Follows (every input kind in the Fans table that fits the output, each with its live reading beside it so the pick is made on the numbers — then every labelled hwmon temperature and every board pwm output the machine has, by name, from the catalogue the daemon sends while a phone watches; picking one fills thechip:labelspec, and an "Other sensor…" row keeps the typed field for one that isn't present right now; on a host output, Board curve hands it back to the board; a machine with more sensors than fit the receiver's 2 KB loses pwm outputs first, then temperatures from the end, never one a fan follows, and the row says how many are missing; a pin list forgpio), the curve (drag the points or type them; add and remove up to eight) with its ramp and, for a temperature input, its hysteresis, the fallback speed and, on a receiver output, the power-on boost with its length. A number box keeps what is typed while it has focus — emptying it to type another value is safe — and falls back to the stored value when left empty. Save sends that fan and the row sayssavedonly when the daemon has applied it, written it into the config and pushed the config back; a refusal is said in the status line and the editor stays open — see Editing fans from the phone. add a fan under the list opens the same editor on a new fan (on the first free header, or no output), and the editor's Delete removes one. A fan on a receiver output the receiver isn't driving (a pin it refused) says so on its row. With no daemon at all the rows come from the receiver's own stored slots: Output then offers its headers and pins, Followsfallbackandgpio— what a receiver runs alone — and Save (or Delete, or add a fan in a free slot) stores it on the receiver until a daemon next connects and the config wins again. A receiver on firmware from before the fan list shows a note to update it (and the daemon) instead of the rows. - LEDs — the config's
stripblock, no Save: a slider applies on release, a switch on the tap — the daemon corrects the live strip on its next frame and writes the value into the config, and the card sayssavedwhen the config comes back. Scenes first: every rule whose condition is a barefile:path, as a switch the daemon flips by creating or removing that file (nothing is written to the config for a toggle — the rule reacts on its next tick, like a shell'stouch); a scene whose settings have acolorgets a color picker, one with alevela slider, both written into that rule. Then Brightness, White — a swatch of what the strip will show (the first scene carrying a color, at its level, run through gamma, white balance and brightness in the daemon's order), the balance as one slider per channel, gamma per channel — and Direction (thereverseswitch). This is the white-balance tuning flow, see Tuning the colors. A receiver on firmware from before the strip view shows the tab empty. - Receiver — the boards this phone knows (tap to switch; "add a receiver" opens the chooser), and for the current one its firmware version, uptime, free memory, whether a host is on the link, plus Change token and Forget.
The page is Preact with htm, vendored into docs/vendor/ so there is no
build step and nothing loads from a CDN (docs/ble.js is the link and the
protocol, docs/app.js the screens). Every file is precached by the
service worker: bump its VERSION when any of them changes, or installed
phones keep serving the old one.
Nothing about the dashboard costs anything while no phone has it open: the
receiver only assembles its view while a page is subscribed, and only then
tells the daemon to read and send the readings (a keepalive every 10 s; the
daemon stops 30 s after the last). A receiver on firmware from before the
dashboard shows the Power tab alone. Open docs/index.html?demo (or the hosted
page with ?demo; &tab=fans, &tab=leds, &nodaemon for the machine-off
view) to see the dashboard on sample data with no receiver at all.
Radio policy: the receiver advertises in both PSU states — a crashed machine
must be reachable, and it counts as "on" — every 300 ms in either state:
opening the page waits for two advertisements (seeing the receiver, then
connecting to it), so the interval is most of the time to connect, and
300 ms is still gentle on 5VSB. Once a phone connects, the receiver asks it
for a 15 ms connection interval (phones pick 30–50 ms on their own): every
step of loading the dashboard is a request and an answer, one at a time, each
waiting for the next exchange, so a shorter interval loads it about three
times faster. A phone may decline; debug_log shows the interval it settled
on. The strip's bitstream leaves the chip by SPI
with DMA (render.cpp) for exactly this coexistence: the earlier RMT path refilled its buffer from an
interrupt, and the BLE controller's interrupts delayed that refill enough to
tear bits — random LEDs flickering while a phone was connected. The RMT path
is still there should a board ever need it: make flash-source STRIP_USE_RMT=1.
The advertisement also carries the host's state (manufacturer data under the unregistered id 0xFFFF: a version byte, then 0 off / 1 booting / 2 on), so the page's Receiver tab can show which boards are nearby and which are on without connecting to each. Chrome only hands a page that data for a receiver granted with it asked for: a receiver added before this page version shows as nearby only until it is added again (Receiver tab → add a receiver, pick the same board).
Guardrails, mirroring the fans': the chip must run a firmware whose partition
table has the blecfg entry — make flash-ble against an older layout is a
silent no-op except for the receiver's debug log saying no blecfg partition
at boot. A config with no ble_remote block writes the remote off.
The power switch is optional. On a receiver without one (power_switch
absent or off — a plain ESP32 on a desk, say) the remote still advertises and
serves the Fans, LEDs and Receiver tabs; the Power tab says there is no
switch, the receiver refuses the power commands, and the header's on/off is
whether the daemon is streaming to it rather than the PSU's state.
Frames go to a list of sinks — the serial transport and/or an on-screen
viewer — so the daemon runs with no strip attached. Set LED_PORT=none (or
serial.port empty) to skip serial:
make virtual-strip
./virtual-strip # on-screen strip; waits for frames
LED_PORT=none led config.json aurora # renders into the viewer, no deviceThe viewer shows the exact bytes the strip would (already gamma/white-balance/
brightness corrected). Drop LED_PORT=none to drive a real strip and preview
at once.
make # build ./led
sudo make install # /usr/local/bin/led + systemd unit + default config
sudo make uninstall # remove everything, including the configmake install never overwrites an existing config, checks the deployed one
against the new build first (see Checking a config),
restarts the service if
it's running (so a rebuilt daemon goes live immediately), and adds your user
to the serial port's group (effective next login) so led can run
unprivileged for testing. The unit restarts on serial failure every 2 s, so unplugging the
adapter heals itself.
led <config> run the rules
led <config> <effect> run a single effect forever (testing)
led --list list available effects
led --check <config> validate a config without running it
Drop a file in daemon/effects/ — it's compiled in and self-registers. Effects
render against the host Strip; the receiver just replays what's streamed. Pure
animations (no host data) can also be a boot/shutdown slot; data-driven ones
(load) can't.
#include "effect.hpp"
class MyEffect : public Effect {
public:
void init(const EffectConfig& cfg, int leds) override { ... }
void render(Strip& strip, float t) override { ... }
};
REGISTER_EFFECT("my_effect", MyEffect)Pixel frame, host → receiver:
| bytes | content |
|---|---|
| 2 | sync 0xAA 0x55 |
| 1 | GPIO pin |
| 2 | LED count (LE) |
| 3·n | RGB, one triple per LED |
| 1 | checksum: XOR of pin, count, and RGB bytes |
Command frame (distinct second sync byte, so the pixel parser skips it):
| bytes | content |
|---|---|
| 2 | sync 0xAA 0x56 |
| 1 | command (0x01 shutdown, 0x02–0x04 recording upload) |
| 2 | payload length (LE) |
| n | payload |
| 1 | checksum: XOR of command, length, and payload bytes |
Receiver → host, three frame types on the same line, each with its own second
sync byte: a log frame (0xAA 0x58, the debug backchannel), a request
frame (0xAA 0x59, the power button asking for a graceful shutdown; answered
with command 0x06), and a message frame (0xAA 0x5A) for the BLE
dashboard — kind(1) len(2, LE) payload checksum, up to 512 bytes, sent
once, carrying a phone's fan edit (0x01), "a phone is watching" (0x02), a
strip edit (0x03) or a power switch edit (0x04). The dashboard's host →
receiver commands are 0x0A (the fan list as the daemon runs it), 0x0B
(its live readings), 0x0C (the strip settings and scenes), 0x0D (the
sensor catalogue: every hwmon temperature and pwm output a fan could follow
or drive, with readings, every 5 s while a phone watches) and 0x0F (the
power switch's tunings and short-press command as the daemon runs them). All
of these payloads are JSON texts in the shapes daemon/fans.hpp,
daemon/strip_remote.hpp and daemon/power_remote.hpp document, up to 2 KB
(protocol.hpp DASH_*_MAX; the receiver takes any command up to that
whatever its strip length) — the receiver relays them to the phone without
parsing them, and the phone reads one longer than a GATT value's 512 bytes
in pages. The fans' standalone settings (0x11: six 25-byte slot records,
each with its output), the power switch's tunings (0x0E: hold, boot timeout
and the two sense thresholds, four little-endian u16s) and its wake pin
(0x10: one GPIO byte, 0xFF = none) are binary, because the receiver does
read those: they are what it runs on its own.
Byte values and payload formats live in common/protocol.hpp, shared by host
and firmware. The receiver drops bad-checksum frames and rescans for sync, so a
desync recovers within a frame; if nothing valid arrives for
host_timeout_ms, it blanks the strip. Host and receiver must be updated
together when the protocol changes.