Skip to content

Repository files navigation

Serial LED controller

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

Hardware

  • An ESP32 or ESP32-C3 dev board over USB (/dev/ttyUSB0 or /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.

Getting started

Two things are needed on the host:

  • g++ and make to build the daemon. On CachyOS (or any Arch) they come in the base-devel package group.
  • esptool to flash the receiver (plus curl, which is preinstalled). You do not need an ESP32 toolchain — make flash downloads 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-controller

make 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.

The full BC-250 flash

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 flash

The 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 rainbow

This runs unprivileged once the serial-group membership added by make install is active — that takes effect on your next login; before then, prefix sudo.

How it works

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.

Flashing the receiver

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 latest

The 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 flash

Clearing 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 animations

The 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.

Receiver debug log

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

"serial": { "debug_log": true }

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).

Configuration

/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.

Checking a config

led --check /etc/led-controller/config.json   # or: make check

Would 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.

Live reload

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.

Tuning the colors

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.2 is 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:

  1. Everything too bright or dim for the room → brightness (0..1).
  2. 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.
  3. 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_balance

Look, 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

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.

Rules

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.

Conditions

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.

Effects

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.

Boot & shutdown animations

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       # another

Power switch

An 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_press command ("systemctl poweroff"); the machine then powers itself off and the sense wire's follow-down releases PS_ON#. This needs short_press set to a command — it ships null, 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_switch block 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": false does 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 pwrcfg partition first exists. make flash-pwr against an older installed firmware (or an older FW_RELEASE pinned) 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 the led pin, and the wake pin needs the firmware from Sep 2026 on.)
  • The block is validated before anything is written: tools/pwrcfg.py rejects pins the firmware would silently drop (nonexistent on target, SPI-flash or host-link pads, a non-ADC sense pin, a collision with the strip.pin LED 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: seconds

The 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.

Fans

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:

  1. 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.
  2. Boost ends; the daemon isn't up yet, so the radiator runs its fallback, 100 %.
  3. The daemon starts, reads the temperature and pushes the curve's 35 %. The radiator settles there.
  4. The temperature sensor stops reading (a driver unloaded, a thermistor unplugged): the radiator runs its fallback until it reads again.
  5. 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 %.
  6. 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 restart or stop doesn'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.

Host outputs

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_enable and pwmN values found are written to a claims record under the state dir (/var/lib/led-controller/pwm-claims, systemd's StateDirectory) and synced to disk — no record, no takeover;
  • it is handed back — pwmN restored first, then pwmN_enable, read back to check (lm-sensors' fancontrol order) — 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-fans hands 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 (then ExecStopPost runs), so a fan can't freeze at its last duty;
  • a hwmon driver reloaded while an output is driven comes back under another hwmonN with 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_enable something 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 aurora beside the service) from touching the outputs; --fan-status and --check never 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)

VRM and GDDR6 temperatures

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_memory kernel 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).

With the kernel drivers (bc250_vrm, bc250_memory)

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_vrm registered → 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_memory loaded → 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.

Getting the block onto the receiver

Two consumers read the same block:

  • make flash / make flash-source bake 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 a gpio input's pin and curve — into the receiver's fancfg flash partition, exactly as strip.pin is baked. So a box that never runs the daemon is configured by editing the block and running sudo make flash-fan (the fancfg partition alone, a couple of seconds); its fans run their fallback duties 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.json when it exists, else the repo's (CONFIG=path overrides).
  • 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-nvs also 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.

Editing fans from the phone

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 FAN5 fancfg, from the fan list on; older blobs are not read) and whose partition table has the fancfg entry (v1.13.0 or newer). make flash-fan against an older layout writes a sector that firmware never reads — a silent no-op, except the receiver's debug log says no fancfg partition at boot — and a firmware that knows only an older layout reads the new blob as "no config" and turns the fans off. make flash writes both halves together, so only flash-fan alone 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-source rewrites 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.

BLE remote

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_press set 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 dashboard

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_press setting 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 a gpio:N fan 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's power_switch block — the wake pin into pins.wake — then pushed to the receiver — saved means 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: boost for the power-on boost, fallback when nothing is driving it (the host off, the daemon not yet up), hold while the host powers down and the last live speed is kept, and for a host output read-only, no output or not driven when 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 the chip:label spec, 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 for gpio), 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 says saved only 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, Follows fallback and gpio — 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 strip block, 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 says saved when the config comes back. Scenes first: every rule whose condition is a bare file: 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's touch); a scene whose settings have a color gets a color picker, one with a level a 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 (the reverse switch). 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.

Previewing without hardware

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 device

The 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.

The daemon

make                     # build ./led
sudo make install        # /usr/local/bin/led + systemd unit + default config
sudo make uninstall      # remove everything, including the config

make 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

Adding an effect

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)

Wire protocol

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.

About

A small controller + receiver (esp32) for controlling a string of ws2812b leds. It installs a daemon that can switch between different effects based on some defined rules.

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages