Skip to content

About

ESP8266 + NRF24 bridge putting a 2.4GHz MiLight/MiBoxer pool light on Home Assistant over MQTT

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

48 Commits

Folders and files

Repository files navigation

pool-lights

An ESP8266 + NRF24L01+ bridge that puts a 2.4 GHz MiLight / MiBoxer RF light — in this case a pool light with no smart interface of any kind — onto Home Assistant over MQTT.

The light is driven by a MiBoxer RF receiver paired to a handheld remote. It speaks neither WiFi nor Zigbee nor infrared, so Home Assistant cannot see it. This bridge can:

FUT088-style remote ──2.4 GHz RF──┐
                                  ├──> MiBoxer receiver ──> the light
   this bridge ───────────────────┘
        │
        └── MQTT discovery ──> Home Assistant

It is a second remote, not a gateway in front of the light. It transmits the same protocol with the same identity the physical remote uses, learned by listening to it, so both keep working and neither is aware of the other. It also keeps listening, so pressing the physical remote updates Home Assistant.

Everything except the radio protocol runs on a Wemos D1 R1: WiFi, OTA, a debug web UI at http://pool-lights.local/, and the MQTT integration.

What it exposes

One Home Assistant device, three entities:

  • Light — on/off, brightness, RGB colour, colour temperature, five effects
  • Effect faster / Effect slower — buttons, because those keys are momentary actions with no state

Plus a retained availability topic backed by an MQTT last will, so a crashed bridge shows as unavailable rather than as a light that has quietly stopped responding.

What it cannot know

The RF protocol is one-way. The receiver never reports anything — not its state, not an acknowledgement, not its presence. Everything Home Assistant shows is inferred from what the bridge sent plus what it overheard:

  • A command lost to interference leaves Home Assistant optimistic and wrong until the next command. Retransmission helps; certainty is not available.
  • If the light is switched at the wall, the bridge has no way to know.
  • The only proof a command landed is a person looking at the light.

Hardware

Part Notes
Wemos D1 R1 (ESP8266) PlatformIO board id d1. Any ESP8266 works; the pin map here is R1-specific
NRF24L01+ (PA+LNA variant) 3.3 V only. Wiring in docs/hardware.md
7 dupont wires CE, CSN, SCK, MOSI, MISO, VCC, GND. IRQ unused
10–100 µF + 100 nF caps Across the radio's VCC/GND. Not optional — without them this build received flawlessly and transmitted nothing at all

Quick start

# macOS, Apple Silicon: the xtensa compiler is x86_64-only
softwareupdate --install-rosetta --agree-to-license
brew install platformio

cp include/secrets.h.example include/secrets.h   # then fill it in
make test          # desktop unit tests, no board needed
make run           # build, flash, print the boot banner

secrets.h carries the WiFi and MQTT credentials, the OTA password, and the light's RF identity. Capture that identity with make sniff and press buttons on the physical remote; it prints the device id and group.

Once a board is running this firmware, reflash it over the air with make ota OTA_HOST=pool-lights.local. That only works while the running sketch handles OTA, so one bad flash puts you back on USB.

make help lists every target.

HTTP endpoints

Everything the board serves, on port 80.

Endpoint Purpose
GET / The web UI: state, controls, and a live console panel
GET /api/state State as JSON, plus IP, RSSI, uptime and heap. Polled twice a second by the page
POST /api/set Commands. Every parameter is optional and applied if present: on=1, off=1, white=1, hue=0..255, brightness=0..100, sat=0..100, effect=0..4
GET /status Snapshot: build stamp, reset reason, uptime, heap, WiFi, light state, how full both rings are, and how much of the remote's traffic is being heard. Plain text, or JSON with ?json=1
GET /log The console ring as plain text, oldest first
GET /errors Faults only, from a separate smaller ring

/api/state carries a version counter that advances only when the light state really changes, so a client can tell a change from a repeat without diffing.

curl http://pool-lights.local/status
curl "http://pool-lights.local/status?json=1" | jq .
curl -X POST "http://pool-lights.local/api/set?on=1&hue=85&brightness=60"

MQTT topics

Under MQTT_PREFIX from secrets.h, except discovery, which must live under whatever discovery_prefix the Home Assistant MQTT integration is configured with — HA_DISCOVERY_PREFIX, homeassistant/ by default. Get that one wrong and the bridge connects, publishes happily, and no entity ever appears.

Topic Direction
<prefix>light/set in — Home Assistant commands, JSON schema
<prefix>light/state out — state, retained
<prefix>status out — online / offline, retained, last will
<prefix>effect/faster in — button press
<prefix>effect/slower in — button press
<discovery>light/pool_lights/config out — discovery, retained
<discovery>button/pool_lights_{faster,slower}/config out — discovery, retained

How much of the remote is being heard

The remote advances a sequence byte once per press, so gaps in what we received are presses that happened and we missed. /status reports that as a percentage over roughly the last 64 transitions, weighted to recent behaviour so a bad hour is not diluted by a good week.

Two things it is not. It measures what reaches us; whether our own transmissions reach the light is unobservable, because the protocol is one-way — reception is a proxy for it, not a measurement of it. And it is silent when nobody touches the remote, which looks exactly like perfect, so it says "no presses observed yet" rather than 100%. Gaps larger than a few presses are treated as a separate session rather than a fault, since the remote gets used while the bridge is rebooting.

Consult it when something seems wrong. It is not an alarm.

The two rings

The console ring holds 80 lines, the fault ring 24, and faults are written to both. The main log fills with routine traffic — a health line every five minutes, every command sent and every remote press overheard — so a fault from hours ago would be long evicted by the time anyone went looking for it.

Lines are stamped with uptime, and consecutive repeats collapse to (xN) so one retry loop cannot evict everything else. The five-minute health line carries the fault count, so a problem is visible from the main log without opening /errors. Free heap is reported whenever it reaches a new low: a healthy board stays quiet, a leaking one shows a steady descent.

Both rings are RAM and start empty after a restart. /status exists for that reason — it is computed on request rather than remembered, so the build stamp and reset reason cannot scroll out of anything.

If a name lookup seems to hang

Use the IP. The ESP8266 mDNS responder answers A queries but ignores AAAA entirely, without even a negative reply, so a resolver asking for both waits out its full timeout — about five seconds — on the IPv6 half before using the IPv4 answer it already had.

Layout

include/        pure logic, header-only, unit tested — protocol codec, state, control
lib/PL1167/     PL1167 transceiver emulated on the NRF24
src/            peripherals and wiring: radio, WiFi/OTA, web UI, MQTT
                plus two standalone diagnostics with their own build envs
test/           desktop unit tests (make test)
tools/          bounded serial capture, privacy scan
docs/           hardware wiring, protocol notes, code standards
local/          gitignored: credentials, IPs, the sniffed device id

pio test does not build src/, which is why anything worth testing lives in a header under include/. That split is the reason the protocol codec, the state machine and the control layer are all verifiable without a radio attached.

Two diagnostics exist because the firmware cannot answer their questions: make radio proves the radio is wired and genuine before any protocol code runs, and make sniff is receive-only, so it can learn a remote's identity with no risk of transmitting a pairing command.

Contributing

docs/code-standards.md is binding: minimal code, no dead things, tests first for anything with a definable input and output, and never leave the repository dirty. Run make check before publishing anything.

A note on privacy

This repository is written to be publishable. Nothing in it identifies a particular home, network or installation: no IPs, no credentials, no MAC addresses, and no RF pairing IDs — those last ones are effectively the keys to someone's light. Anything site-specific lives in the gitignored local/ directory. See RULE 0 in CLAUDE.md.

Credits

The protocol constants — the V2 offset table, the xorKey derivation, the PL1167 register sequences — are ported from sidoh/esp8266_milight_hub (MIT), which took them in turn from henryk/openmili. Years of reverse engineering went into those and they cannot be re-derived. The code around them is this project's own.

Licence

MIT — see LICENSE.

Note for anyone redistributing a built binary: the RF24 library is GPL-2.0, so the combined firmware image carries GPL-2.0 obligations even though this source is MIT. Distributing the source alone does not.

About

ESP8266 + NRF24 bridge putting a 2.4GHz MiLight/MiBoxer pool light on Home Assistant over MQTT

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages