A MiLight / LimitlessLED bulb driver in Rust, meant to run on a Raspberry Pi with an nRF24L01+ radio — talking to the bulbs directly over 2.4 GHz instead of going through a MiLight WiFi gateway.
Status: working on real hardware. Verified 2026-08-20 on a Raspberry Pi 3B+ with an nRF24L01+, driving three FUT092 bulbs: registered over HTTP, discovered by Home Assistant, and set to colour, temperature and brightness on command. Every layer — protocol, radio, database, MQTT, API — exercised end to end against real bulbs.
The MiLight WiFi gateways are the usual way to control these bulbs, but they're flaky, cloud-adjacent, and one more box to keep alive. The bulbs themselves listen on a plain 2.4 GHz protocol that an nRF24L01+ can speak — a £2 radio module on a Pi's SPI bus replaces the gateway entirely, with no cloud and no vendor app.
The on-air protocol is undocumented by the vendor and has been reverse-engineered by the community. docs/protocol.md is the full write-up: radio configuration, PL1167 framing over an nRF24, the V2 obfuscation scheme, the command tables for each bulb family, and verified test vectors.
The short version:
- Bulbs contain a PL1167 transceiver. An nRF24L01+ can imitate one by disabling its CRC and auto-ACK, and folding the PL1167 preamble/syncword/trailer into its 5-byte address.
- Two packet generations exist. V1 (RGBW/CCT/RGB, 6–7 bytes) is plaintext. V2 (RGB+CCT — FUT092, FUT089, FUT091 — 9 bytes) is obfuscated with a keyed XOR plus per-position additive offsets.
- A V2 packet is
[key, protocol_id, id_hi, id_lo, command, argument, sequence, group, checksum]. - Nothing is ever acknowledged. Commands are broadcast on three channels and repeated ~50 times.
pilight targets V2 / RGB+CCT.
use pilight_proto::{GroupId, Nrf24Transceiver, RgbCctController};
let mut lamp = RgbCctController::builder(Nrf24Transceiver::open()?)
.device_id(0xBEEF) // pick one and keep it; there is no registry
.group(GroupId::new(1, 4)?) // group 0 addresses all four at once
.build()?;
lamp.on()?;
lamp.set_brightness(60)?;
lamp.set_hue(200)?;
lamp.set_kelvin(80)?; // 0 = coolest, 100 = warmestThere's a CLI example that wraps the same API:
cargo run --example lamp -- --id 0xBEEF --group 1 on
cargo run --example lamp -- --id 0xBEEF --group 1 brightness 60
cargo run --example lamp -- --id 0xBEEF --group 1 hue 200Pairing: power-cycle the bulb, then run pair within about three seconds. It
adopts whichever (device_id, group) it hears first. unpair factory-resets it the
same way.
A Cargo workspace, split so the protocol code stays free of database, MQTT and async dependencies:
crates/
├── pilight-proto/ The driver. No DB, no async, no tokio.
│ ├── encoder.rs V2 obfuscation: keyed XOR + position offsets.
│ ├── packet.rs The 9-byte V2 packet.
│ ├── framing.rs PL1167 framing: length byte, CRC-16, bit reversal.
│ ├── remote.rs The bulb families and their parameters.
│ ├── radio/ Hopping, repetition, the nRF24 and null backends.
│ └── controller/ Intents, the multi-lamp transmitter, RgbCctController.
├── pilight-db/ Postgres persistence (diesel-async).
│ ├── domain/ What the app works with: u8s, enums, no Diesel.
│ ├── models/ Row-shaped mirrors and the narrowing conversions.
│ └── repository/ Storage traits and their Pg implementations.
├── pilight-service/ LampService: the one place radio meets database.
├── pilight-mqtt/ Home Assistant bridge: discovery, topics, payloads.
├── pilight-api/ HTTP API: routes, DTOs, status mapping, auth.
└── pilightd/ The daemon that runs all of it.
The seams are deliberate: Transceiver for hardware, the repository traits for
storage. Both have test doubles, so everything above them runs without a Pi.
LampService is the hinge. One radio serves many lamps, so it holds the
transmitter behind a mutex and, for each command, looks the lamp up, takes a
sequence number, transmits on a blocking thread (a burst is a few hundred
milliseconds — it must not sit on the async runtime), revises the stored state, and
records the attempt. The MQTT bridge and the HTTP API are both thin skins over it.
They stay in step through a broadcast channel rather than knowing about each other:
LampService emits LampEvents and the bridge reacts by announcing, retracting or
republishing. That is why a lamp registered over HTTP appears in Home Assistant, and
why a brightness change made with curl shows up on the dashboard.
Both interfaces also share StateChange, which turns "on, half brightness, blue"
into correctly ordered packets — one implementation, so they cannot drift apart on a
question the bulbs care about.
For anything that is not Home Assistant — a dashboard, a script, curl. It is also
the only way to register a lamp.
BASE=http://localhost:8080
# Register a lamp. device_id is yours to pick; there is no registry.
curl -s -X POST $BASE/api/v1/lamps -H 'content-type: application/json' -d '{
"name": "Couch", "room": "Living room",
"remote_type": "rgb_cct", "device_id": 48879, "group": 1
}'
# Drive it. Absent fields are left alone; several can change at once.
curl -s -X PUT $BASE/api/v1/lamps/$ID/state -H 'content-type: application/json' \
-d '{"power": true, "brightness": 60, "hue": 200, "saturation": 80}'| Method | Path | Does |
|---|---|---|
GET |
/health |
Liveness. No token needed. |
GET |
/api/v1/lamp-types |
Which bulb families exist, and which are drivable. |
GET |
/api/v1/lamps |
Every lamp with its state. Paginated. |
POST |
/api/v1/lamps |
Register a lamp. |
GET |
/api/v1/lamps/{id} |
One lamp. |
PATCH |
/api/v1/lamps/{id} |
Rename, or move room. |
DELETE |
/api/v1/lamps/{id} |
Forget a lamp. |
PUT |
/api/v1/lamps/{id}/state |
Change what it is doing. |
GET |
/api/v1/lamps/{id}/history |
What we sent, and whether it worked. |
POST |
/api/v1/lamps/{id}/pair |
Adopt a power-cycled bulb. |
POST |
/api/v1/lamps/{id}/unpair |
Factory-reset a power-cycled bulb. |
The full body for PUT .../state. Every field is optional; omitting one leaves that
setting alone.
| Field | Type | Means |
|---|---|---|
power |
bool | On or off. |
brightness |
0–100 | Percent. Survives a mode switch. |
hue |
0–359 | Degrees. Puts the bulb in colour mode. |
saturation |
0–100 | Percent. Only takes effect in colour mode. |
kelvin |
0–100 | 0 is coolest, 100 warmest. Drags the bulb into white mode. |
scene |
0–8 | One of the nine built-in scenes. Home Assistant sees these as effects. |
night_mode |
bool | The dimmest setting the bulb has. A mode, not a brightness. |
The two list endpoints — /lamps and /lamps/{id}/history — take ?limit= and
?offset=. limit defaults to 50 and is clamped to 1–500 rather than
trusted, so a client cannot ask for the whole command log by accident; a negative
offset is treated as zero. Both are echoed back in meta.
GET /health always answers 200, describing what is wrong in the body rather
than in the status code — a monitor that only sees a 503 cannot tell a dead
database from a dead process. status is ok or degraded:
{ "success": true, "error": null,
"data": { "status": "ok", "database": true, "lamps": 3, "version": "0.1.0" } }Every response has the same envelope, so a client can read success before deciding
what to do with the body:
{ "success": true, "data": { "…": "…" }, "error": null }
{ "success": true, "data": [], "error": null, "meta": { "total": 3, "limit": 50, "offset": 0 } }
{ "success": false, "data": null, "error": "no lamp with id …" }Status codes are chosen to point at whoever can fix the problem:
| Code | When |
|---|---|
400 |
The request asked for something impossible — 200% brightness, group 5 on a four-group family. Caught before anything reaches the air. |
404 |
No such lamp. |
409 |
A lamp is already registered at that (family, device_id, group). The body was fine; the world disagreed. |
501 |
The family is documented but has no command layer yet. |
502 |
The radio failed. It is upstream of us, so a 500 would send you looking in the wrong place. |
503 |
The database is unreachable. Retry. |
Set PILIGHT_API_TOKEN and every /api/v1 request needs
Authorization: Bearer <token>. /health never does, so a monitor can probe it
freely.
Leaving it unset means no authentication, and the daemon says so in a warning at
startup. That is a defensible default for a box on your own LAN and a bad one for
anything reachable from outside it — PILIGHT_API_ADDR defaults to 0.0.0.0:8080,
so bind it to 127.0.0.1 or set a token if the machine is exposed.
There is no rate limiting: this is a handful of lamps on a home network, and the radio is a far tighter bottleneck than the HTTP layer.
pilightd publishes MQTT discovery
messages, so the lamps appear in Home Assistant with no YAML.
docker compose up -d # Postgres + a local Mosquitto
export DATABASE_URL=postgres://pilight:pilight@localhost:55432/pilight
export PILIGHT_MQTT_HOST=homeassistant.local
cargo run --release -p pilightdRegister a lamp through the HTTP API and it shows up as a light with brightness, a colour wheel, a colour temperature slider and the nine scenes as effects. Renames, deletions and state changes made over HTTP propagate immediately; the bridge follows the service's event stream.
homeassistant/light/pilight_<uuid>/config discovery, retained
homeassistant/status HA's birth message — we listen
pilight/status our availability, retained + LWT
pilight/lamp/<uuid>/state lamp state, retained
pilight/lamp/<uuid>/set commands
Both prefixes are configurable. Three details that are easy to get wrong and are handled here:
- A last will marks the bridge offline if the daemon dies, so HA greys the lights out instead of showing state that has quietly stopped updating.
- HA's birth message triggers a full re-announce. Discovery messages are retained, but a Home Assistant that has forgotten an entity only gets it back if someone republishes.
optimisticis off andtransition/flashare disabled, because the bulbs can do none of those things.
| Home Assistant | Protocol |
|---|---|
| Brightness 0–255 | 0–100 percent |
| Colour temperature in Kelvin | 0–100, where 0 is coolest |
hs colour, hue 0–360, saturation 0–100 |
hue degrees, saturation percent |
Effect scene_0 … scene_8 |
scene 0–8 |
The Kelvin range defaults to 2700–6500 K. MiLight does not publish it — that is
the range these bulbs are sold as, and what other implementations assume. Override
with PILIGHT_MIN_KELVIN / PILIGHT_MAX_KELVIN if your bulbs differ.
One HA message can ask for several things at once ("on, half brightness, blue"), and the protocol cannot say that in one packet. The bridge expands it into intents and orders them deliberately: temperature before hue (a Kelvin command drags the bulb out of colour mode), hue before saturation (saturation only applies in colour mode), brightness last (it survives a mode switch). Getting that order wrong is why colours "don't stick".
| Variable | Default | Meaning |
|---|---|---|
DATABASE_URL |
— | Required. Postgres connection string. |
PILIGHT_RADIO |
nrf24 |
none discards every transmission — useful for testing the HA side on a laptop. It warns loudly. |
PILIGHT_MQTT_HOST / _PORT |
localhost / 1883 |
Broker. |
PILIGHT_MQTT_USERNAME / _PASSWORD |
unset | Broker credentials. |
PILIGHT_MQTT_CLIENT_ID |
pilight |
Client id on the broker. Two instances must differ. |
PILIGHT_MQTT_PREFIX |
pilight |
Our topic prefix. |
PILIGHT_MQTT_DISCOVERY_PREFIX |
homeassistant |
HA's discovery prefix. |
PILIGHT_MIN_KELVIN / PILIGHT_MAX_KELVIN |
2700 / 6500 |
Bulb range. |
PILIGHT_RADIO_REPEATS |
50 |
Bursts per command. |
PILIGHT_COMMAND_GAP_MS |
300 |
Pause between two different commands. See below. |
PILIGHT_API_ADDR |
0.0.0.0:8080 |
Where the HTTP API listens. |
PILIGHT_API_TOKEN |
unset | Bearer token for /api/v1. Unset means no auth. |
RUST_LOG |
info |
Log filter. |
PILIGHT_COMMAND_GAP_MS is the one worth understanding before you change it. It is
not the gap between repeats of a single command — that lives in the radio layer.
It is the pause between two distinct commands, and without it a bulb acts on the
first and silently ignores the rest, so "on, warm, 40%" appears to do nothing at
all. 300 ms is enough on FUT092 and 0 is definitely not; the exact threshold has
not been characterised. docs/protocol.md §2.5
has the full story.
TLS to the broker is behind the tls feature, off by default: rustls pulls in
aws-lc-sys, which needs a C toolchain and makes cross-compiling for a Pi
considerably more annoying. HA's Mosquitto add-on is plain 1883 on the LAN.
Lamps, the families they belong to, what we last told them, and an audit trail of what we sent:
| Table | Holds |
|---|---|
lamp_types |
One row per bulb family. Not hand-maintained — it's a projection of RemoteType::ALL, upserted at startup, so adding a family needs no migration. |
lamps |
A paired (family, device_id, group) with a name and room. That triple is unique: two lamps sharing it would be the same bulb. |
lamp_states |
Last known state per lamp, plus the next V2 sequence byte. |
lamp_commands |
Append-only log of what was transmitted, by whom, and whether it worked. |
lamp_states is a belief, not a reading. MiLight bulbs never acknowledge
anything and cannot be queried, so every column reflects the last command we sent.
It goes stale the moment someone picks up a physical remote. Optional columns are
NULL until the corresponding command has been sent at least once — a freshly
paired bulb has a colour, but we do not know what it is.
Persisting next_sequence closes a gap: the sequence byte used to live only in the
controller, so every restart began again at zero. Consecutive distinct commands are
supposed to carry distinct sequence numbers, and a restart made that stop being true.
(What a bulb actually does with a replayed number is not something I've been able to
verify — the protocol notes say only that the byte must stay fixed across a repeat
burst and change between commands.) take_sequence bumps it in a single
UPDATE ... RETURNING, so the HTTP API and an MQTT handler can send concurrently
without handing the same byte to one bulb.
use pilight_db::{Repositories, build_pool, run_migrations};
use pilight_db::repository::{LampRepository, LampTypeRepository};
use pilight_db::domain::NewLamp;
let pool = build_pool(&std::env::var("DATABASE_URL")?)?;
run_migrations(&pool).await?; // idempotent; safe every start
let repos = Repositories::new(pool);
repos.types.sync_from_driver().await?; // catalogue follows the driver
let lamp = repos.lamps.create(NewLamp {
name: "Couch".into(),
room: Some("Living room".into()),
remote_type: RemoteType::RgbCct,
device_id: 0xBEEF,
group: 1,
}).await?; // creates its state row tooEverything speaks diesel-async over AsyncPgConnection, including migrations
(via AsyncMigrationHarness). That means no libpq — nothing to install on
the Pi. It does mean migrations need the multi-threaded tokio runtime, because the
harness uses block_in_place; under current_thread, wrap them in
spawn_blocking.
docker compose up -d postgres
export DATABASE_URL=postgres://pilight:pilight@localhost:55432/pilight
export PILIGHT_TEST_DATABASE_URL=postgres://pilight:pilight@localhost:55432/pilight_testAfter changing a migration, regenerate Diesel's view of the schema:
cd crates/pilight-db && diesel print-schema > src/schema.rscargo test --workspace runs 235 tests. The count does not change when Postgres and
a broker come up: the integration tests skip from inside a passing test, so the
same 235 run either way — they just assert a great deal more when the services are
there. See Build for how to tell the two apart.
Protocol (all against a real capture or the reference implementation):
- A real captured packet round-trips.
1B D9 ED 64 52 DD B3 63 1Ddecodes to a well-formed RGB+CCT packet and re-encodes byte-for-byte. - The obfuscation survives all 256 key values, including the
[0x54, 0xD3]jump-start window that used to overflow. - CRC, bit reversal and framing: the full 12-byte nRF24 payload is asserted literally.
- Intent encoding and ordering, through a recording
Transceiver.
Persistence (against a real Postgres):
- Migrations are idempotent; the
lamp_typescatalogue tracksRemoteType::ALL. - Creating a lamp creates its state row in the same transaction; a rejected lamp leaves nothing behind; deleting one cascades.
- Sequence numbers are handed out in order, wrap at 255, and 32 concurrent tasks each get a distinct one.
- Narrowing conversions reject rather than truncate — a
device_idof -1 or 65536 is an error, not a wrappedu16.
Home Assistant (against a real Mosquitto, end to end):
- A lamp appears with a usable config, checked against the topics the bridge actually listens on.
- A command published the way HA publishes it reaches the radio, and state comes back.
- A combined command produces one packet per intent, per channel — which is what pins down the ordering.
- HA's birth message re-announces, including a lamp added after startup.
- Malformed JSON is ignored, not fatal; shutdown publishes
offline.
HTTP API (the real router, a real Postgres, a counting radio):
- Status codes are what they claim: 409 on a duplicate address, 400 on an impossible value, 404 everywhere a lamp is missing, 400 on a malformed UUID.
- An impossible value never reaches the air — asserted by packet count, not by reading the error.
PATCHtells an absent field from an explicitnull, so renaming a lamp does not silently clear its room.- The sequence byte does not leak into any response.
- A combined change is ordered so the colour survives the temperature change.
- Auth is enforced when a token is configured, and
/healthis exempt. - API changes reach the event stream, which is what makes MQTT follow them.
I also ran the daemon for real: registered a lamp with curl, changed its state, and
confirmed the retained discovery and state messages arrived on the broker.
The automated tests all run against NullTransceiver, which counts packets rather
than transmitting them — they prove everything up to the radio. The rest was
confirmed by hand on 2026-08-20, on a Pi 3B+ with an nRF24L01+ on SPI0:
radio-checkreturned a clean register dump — every value its documented power-on default,STATUS 0x0E,SETUP_AW 0x03, and the write/read-back passed.- Bulbs obeyed. Three lamps registered over HTTP appeared in Home Assistant from their retained discovery messages, and responded to hue, saturation, brightness, colour temperature and on/off.
- An independent decoder confirmed the packets. The esp8266_milight_hub already
on the network sniffed a transmission and republished it as
milight/states/0x2/rgb_cct/1 {"bulb_mode":"color","color":{"r":0,"g":255,"b":0}}— our device id, type, group and colour, decoded by someone else's implementation.
That single command confirms a lot of derived-from-reading work at once: the V2
obfuscation and checksum, the CRC-16 and PL1167 framing, the syncword-to-address
derivation, the +2 channel offset, and the hue/saturation encodings.
It also settles the chip-select decision — CSN driven by the Pi's hardware CE0 with a no-op pin handed to the driver. That was reasoning, not evidence, until the register dump and then the bulb proved it.
And it found a bug nothing else could have. Batched changes ("on, warm, 40%") did
nothing at all, because distinct commands were being sent back to back with no
pause and the bulb acted only on the first. Every packet was transmitted correctly
— the sniffer proved it — so no amount of testing against NullTransceiver would
have caught it. See docs/protocol.md §2.5; it is not documented anywhere else.
The daemon cross-compiles to a static aarch64 binary with no C toolchain, and runs
on the Pi with nothing installed alongside it.
Without the test services configured, the integration tests skip, and cargo captures
the skip notice — so the run looks green, and the test count is unchanged. That is
why the number above cannot tell you whether they really ran; -- --nocapture can.
All three harnesses turn the skip into a hard failure when CI is set, so a silent
pass can only happen on a laptop.
The V2 encoder was already algorithmically correct, but nothing had ever reached the air. Two bugs and one large gap:
- The encoder panicked in debug builds. Every operation in the V2 scheme is
mod-256, but the code used plain
+/-/+=, soencode_packetaborted on the first packet atsrc/encoder.rs:77(pre-split layout; nowcrates/pilight-proto/src/encoder.rs). In--releaseit wrapped and produced the right answer — which is exactly the kind of asymmetry that reads as "the protocol is wrong". Nowwrapping_add/wrapping_subthroughout, with a test that walks every key value. sequence_num += 1would panic after 255 commands. Now wrapping, with a test that sends 300.- There was no radio. No dependencies, no SPI, no framing, no channel hopping;
V2LampController::commandbuilt a packet, never encoded it, and dropped it.
Smaller: the unused V2Encoder<const PACKET_SIZE: u8> parameter is gone, the
Command/Argument marker traits are replaced by concrete enums, and the mutating
encode_packet(&mut [u8; 9]) API is now a value-returning V2Encoder::encode.
| Part | Notes |
|---|---|
| Raspberry Pi | Any model with the 40-pin header. rppal 0.22 handles the Pi 5's RP1 as well as older BCM models. |
| nRF24L01+ | The + matters. A module with an external antenna helps. |
| 10–100 µF capacitor | Across the module's VCC/GND. Not optional — these modules brown out on transmit, and the symptom looks exactly like a protocol bug. |
The nRF24 is a 3.3 V part. VCC must come from the Pi's 3.3 V rail, never 5 V. The logic pins are 5 V-tolerant; the supply is not.
| nRF24L01+ | Pi header pin | BCM | Signal |
|---|---|---|---|
| GND | 6 | — | GND |
| VCC | 1 | — | 3.3 V |
| CE | 22 | 25 | Chip enable |
| CSN | 24 | 8 | SPI0 CE0 |
| SCK | 23 | 11 | SCLK |
| MOSI | 19 | 10 | MOSI |
| MISO | 21 | 9 | MISO |
| IRQ | — | — | unused |
CSN is driven by the Pi's hardware chip-select rather than in software: rppal
asserts CE0 for exactly the duration of each SPI transfer, which is precisely the
framing an nRF24 command needs. --ce and --clock move the CE pin and the clock
if you need to.
- Enable SPI.
sudo raspi-config→ Interface Options → SPI, or adddtparam=spi=onto/boot/firmware/config.txt(that path, not the older/boot/config.txt). Reboot. You should then have/dev/spidev0.0. - Permissions. Add yourself to both groups and log back in:
Recent Raspberry Pi OS sets up udev rules so the
sudo usermod -aG spi,gpio $USERgpiogroup can reach/dev/gpiomemand/dev/gpiochipNwithout root. If you get permission errors, that usually means an older image — update it rather than reaching forsudo. - Build on your workstation, not on the Pi. See Cross-compiling — it takes seconds instead of the best part of an hour, and a Pi 3B+ has 1 GB of RAM to link in.
Before running the daemon, check the radio on its own:
cargo run --release -p pilight-proto --example radio-checkIt reads the nRF24's registers over raw SPI — deliberately not through the driver, so it can tell you whether the link works independently of whether the protocol does. Those are the two questions a set of unlit bulbs leaves you with, and they have completely different fixes.
Registers:
CONFIG 0x00 08
EN_AA 0x01 3F
SETUP_AW 0x03 03
STATUS 0x07 0E
TX_ADDR 0x10 E7 E7 E7 E7 E7
...
nRF24L01+ responding, registers read and write cleanly
All 00 means nothing is driving MISO — power, ground, or the MISO line. All FF
means MISO is floating. Either way the tool says so and lists what to check, in the
order it is usually wrong. It also writes a scratch value and reads it back, so a
dead MOSI is caught too, then restores it.
Once that passes, put something on the air and watch a bulb:
# Power-cycle the bulb first, then run this within a few seconds to pair it.
cargo run --release -p pilight-proto --example radio-check -- \
--transmit --id 0xBEEF --group 1It cycles ON → 50% → OFF every two seconds. If the register dump is clean but the
bulb does nothing, the problem is in the protocol or the pairing, not the wiring —
and docs/protocol.md is where to look.
cargo test --workspace # 235 tests; no hardware, no services
cargo clippy --workspace --all-targets
cargo build --releaseTo include the integration tests:
docker compose up -d
export PILIGHT_TEST_DATABASE_URL=postgres://pilight:pilight@localhost:55432/pilight_test
export PILIGHT_TEST_MQTT_HOST=localhost PILIGHT_TEST_MQTT_PORT=51883
cargo test --workspace # the same 235, now actually exercising the servicesTo run the whole thing on a machine with no radio:
DATABASE_URL=postgres://pilight:pilight@localhost:55432/pilight \
PILIGHT_RADIO=none PILIGHT_MQTT_PORT=51883 \
cargo run -p pilightd --no-default-featuresNothing in this workspace links C — no libpq, no OpenSSL, no ring. That makes
cross-compiling unusually easy: Rust's bundled rust-lld and the musl target's
self-contained libc are the whole toolchain, so there is no Docker image, no
cross, and no gcc-aarch64-linux-gnu to install.
rustup target add aarch64-unknown-linux-musl
cargo build --release --target aarch64-unknown-linux-musl.cargo/config.toml points that target at rust-lld already. The result is a
statically linked aarch64 binary with no runtime dependencies:
pilightd 7.3M ELF 64-bit, ARM aarch64, statically linked
radio-check 812K
scp it to the Pi and run it. Nothing to install on the other end — no Rust
toolchain, no shared libraries, not even a matching glibc.
For reference, the same build on a Pi 3B+ takes the better part of an hour and can run out of memory while linking. On a workstation it is under 30 seconds.
One musl caveat. musl has no NSS, so a static binary cannot resolve
.local(mDNS) hostnames. If you want to pointPILIGHT_MQTT_HOSTathomeassistant.localrather than an IP, either add an/etc/hostsentry on the Pi, or build against glibc instead —sudo pacman -S aarch64-linux-gnu-gcc, then--target aarch64-unknown-linux-gnu, which.cargo/config.tomlalso covers.
If you install the 32-bit Raspberry Pi OS image instead, the target is
armv7-unknown-linux-musleabihf.
Building natively on the Pi still works — cargo build --release — it is just slow.
All default-on features are for the Pi; turning them off is how you build for a machine that has no radio.
| Crate | Feature | Default | Does |
|---|---|---|---|
pilightd |
nrf24 |
on | Drive a real nRF24L01+. Without it the daemon only has the null radio, and needs PILIGHT_RADIO=none. |
pilightd |
tls |
off | TLS to the broker; re-exports pilight-mqtt/tls. |
pilight-proto |
nrf24 |
on | The rppal + embedded-nrf24l01 hardware backend. Off, the crate is pure protocol and builds anywhere. |
pilight-proto |
serde |
off | Serialize/Deserialize for RemoteType. The db and HTTP layers turn it on. |
pilight-mqtt |
tls |
off | rustls for the broker connection. Off by default because it pulls in aws-lc-sys, which needs a C toolchain and makes cross-compiling considerably more annoying. |
| Crate | Why |
|---|---|
rppal 0.22 |
Pi SPI and GPIO. Its hal feature provides the embedded-hal 0.2 impls the radio driver needs. |
embedded-nrf24l01 0.2 |
nRF24L01+ register driver. Chosen because it exposes set_crc, set_auto_ack and set_auto_retransmit — most Arduino-derived drivers hide exactly the knobs this protocol has to turn. |
embedded-hal 0.2 |
Renamed to embedded-hal-0-2. The trait the two above agree on. |
diesel 2.3 |
Query DSL and derives. postgres_backend only, not postgres — that would link libpq. |
diesel-async 0.9 |
AsyncPgConnection over pure-Rust tokio-postgres, deadpool pooling, and the async migration harness. |
rumqttc 0.25 |
MQTT client, default-features = false to keep TLS opt-in. |
axum 0.8 |
HTTP. tower-http adds request tracing and trailing-slash normalisation. |
async-trait |
Keeps the repository traits object-safe. |
Rust 1.85 or newer (edition 2024); developed against 1.96.
deploy/ has a systemd unit and a commented environment file. The short version:
# On the Pi, once.
sudo apt install postgresql
sudo -u postgres createuser --pwprompt pilight
sudo -u postgres createdb --owner=pilight pilight
sudo useradd --system --no-create-home --shell /usr/sbin/nologin pilight
sudo usermod -aG spi,gpio pilight
# From your workstation.
cargo build --release --target aarch64-unknown-linux-musl
scp target/aarch64-unknown-linux-musl/release/pilightd pi@raspberrypi:/tmp/
scp deploy/pilightd.{service,env.example} pi@raspberrypi:/tmp/
# Back on the Pi.
sudo install -m 0755 /tmp/pilightd /usr/local/bin/pilightd
sudo install -d -m 0750 /etc/pilight
sudo install -m 0640 /tmp/pilightd.env.example /etc/pilight/pilightd.env
sudo chown root:pilight /etc/pilight/pilightd.env
sudo $EDITOR /etc/pilight/pilightd.env # at minimum: DATABASE_URL, MQTT host
sudo install -m 0644 /tmp/pilightd.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now pilightd
journalctl -u pilightd -fThe daemon creates its own tables on first start, so there is no migration step. The database has to exist first; the schema does not.
Only DATABASE_URL has no default — without it the daemon exits and says so.
| Variable | Default | Set it when |
|---|---|---|
DATABASE_URL |
— | Always. Postgres connection string. |
PILIGHT_MQTT_HOST |
localhost |
The broker is elsewhere — usually your Home Assistant host. |
PILIGHT_MQTT_PORT |
1883 |
Non-standard port. |
PILIGHT_MQTT_USERNAME / _PASSWORD |
unset | The broker wants credentials. HA's Mosquitto add-on does. |
PILIGHT_MQTT_PREFIX |
pilight |
It collides with something. |
PILIGHT_MQTT_DISCOVERY_PREFIX |
homeassistant |
You changed HA's discovery prefix. |
PILIGHT_MQTT_CLIENT_ID |
pilight |
Two instances share a broker. |
PILIGHT_API_TOKEN |
unset | Read the note below. |
PILIGHT_API_ADDR |
0.0.0.0:8080 |
You want it bound to 127.0.0.1, or a different port. |
PILIGHT_RADIO |
nrf24 |
none to run without hardware. |
PILIGHT_RADIO_REPEATS |
50 |
Commands get missed (raise) or feel slow (lower). |
PILIGHT_COMMAND_GAP_MS |
300 |
Batched changes are being dropped (raise), or feel sluggish (lower). Do not set it to 0. |
PILIGHT_MIN_KELVIN / PILIGHT_MAX_KELVIN |
2700 / 6500 |
Your bulbs cover a different range. |
RUST_LOG |
info |
Debugging: pilightd=debug,pilight_mqtt=debug. |
The API listens on
0.0.0.0:8080with no authentication by default. That is fine behind a trusted LAN and wrong for anything else. SetPILIGHT_API_TOKEN(openssl rand -hex 32), bindPILIGHT_API_ADDRto127.0.0.1, or both. pilightd logs a warning at startup when no token is set.
Beyond the settings, three things have to be true on the Pi:
- SPI enabled —
dtparam=spi=onin/boot/firmware/config.txt, then reboot. You should have/dev/spidev0.0. - The service user is in
spiandgpio— the unit does this withSupplementaryGroups, but the groups must exist, which they do once SPI is on. - The radio actually answers — run
radio-checkbefore starting the daemon. It is the difference between a clear readout and an unexplained silence.
On a Pi 3B+ with 1 GB of RAM, Postgres' defaults are a little generous. If memory
gets tight, shared_buffers = 64MB and max_connections = 20 in
postgresql.conf are plenty — pilightd's pool only asks for 8 connections.
- A UI. Everything is
curlor Home Assistant. - Receive path — sniff physical remotes so stored state stops drifting.
CommandSource::Snifferis reserved for it. This is the single biggest gap: until it exists, state is a record of intent, not of reality. - The other V2 families: FUT089 (8 groups,
0x25) and FUT091 (0x21). Radio config, framing and the catalogue already know about them; only a command layer is missing, and both the service and the API refuse them explicitly rather than mis-driving them. - V1 families (RGBW, CCT, RGB, FUT020) — documented but not implemented
- Mode-restore around the overloaded commands. Setting Kelvin drops the bulb into
white mode, so hue/scene has to be re-sent.
StateChangeorders a single request correctly, but a bare{"kelvin": 40}still loses the colour;lamp_states.bulb_modenow knows enough to put it back. - Scheduling —
CommandSource::Scheduleexists and nothing emits it. - TLS for the HTTP API. Put it behind a reverse proxy for now.
- sidoh/esp8266_milight_hub — the reference implementation, C++ on an ESP8266. The protocol doc here is largely derived from reading it.
- henryk/openmili — the original PL1167-over-nRF24 emulation and V1 protocol work.
- Chris Mullins' V2 write-up — how the V2 obfuscation was cracked.
Not yet chosen. Cargo.toml has a commented-out license field ready for whatever
you pick; it needs filling in before cargo publish would accept the crate.