Home IoT: an ESPHome fleet on a Flint-bridged VLAN
How a home IoT segment ended up shaped the way it is: a GL.iNet Flint 2 broadcasting the IoT SSID, bridged untagged into a switch port with a port VLAN ID (PVID) of 40, onto a router-owned 10.40.0.0/24 where kea pins every device and nftables lets the segment talk to exactly one host. For anyone running ESPHome devices who has wondered whether the hub itself can double as the access point - measured answer: not this hub.
Provenance. Everything marked measured was observed on this rig, 2026-08-10 and 2026-08-11: a Raspberry Pi 4B (BCM43455 radio, Argon ONE aluminum case, NixOS) as the hub, a GL.iNet Flint 2 as the wireless access point, a managed switch, a mini PC running NixOS with kea + nftables as the edge router, and four Athom Smart Plug V3 (ESP32-C3, ESPHome) as the fleet. Claims that come from documentation rather than measurement are marked as such, and one mechanism is marked as inferred where the behavior was measured but the cause was not proven.
TL;DR:
- A Pi 4B’s onboard radio in a metal case could not hold four ESP32 plugs in the same room: associate, handshake, disassociate, repeat, forever. This failure mode is not exotic - the same connect-then-disconnect loop on the same chip is an open linux-kernel report, and ESP32 clients specifically trigger a brcmfmac firmware crash tracked by the Raspberry Pi kernel tree (in concurrent STA+AP mode, a harder configuration than the AP-only one used here).12
- The fix was moving the SSID to the Flint 2, whose only job here is radios, and bridging that SSID at layer 2 into a VLAN the router owns - a better Pi antenna would not have touched the firmware crash. DHCP, reservations, and firewall policy all live on the router; the Flint runs
proto='none'and forwards frames. - The adoption path that works is the device’s own fallback hotspot plus captive portal at
http://192.168.4.1- but the fallback never fires while the old AP is still beaconing badly, because every flap-loop association counts as a connection and resets the timer. Retire the old SSID first, then the escape hatch opens by itself.34 - Server-side DHCP reservations (kea), not ESPHome
manual_ip: the pin survives reflashing and factory resets, lives in git, and makesuse_addressstable. Same reason you do not bake the SSID’s future into firmware you cannot reach.
Topology
Section titled “Topology”The Flint owns no IP on br-iot, runs no DHCP, no NAT, no firewall for this segment. It is a media converter: RF to untagged Ethernet on a port the switch has already decided is VLAN 40.
Which hardware should host the IoT SSID
Section titled “Which hardware should host the IoT SSID”| Option | Cost | Measured outcome | Ops burden |
|---|---|---|---|
| Pi 4B onboard BCM43455 as AP | $0 (owned) | Flap loop; 45-290 ms pings with loss to a plug in the same room; tx failed: 0 but multi-second RTTs | Two NixOS module traps on day one (HT capabilities, dnsmasq bind timing); every rebuild risked the AP |
| USB mt76 dongle on the Pi (ALFA AWUS036ACM class) | ~2-4x normal price (scalper listings at decision time) | Not bought, so not measured | Second radio to own; still terminates on the hub |
| Flint 2 (already the home WAP) hosts the SSID | $0 (owned) | Plug signal -70 dBm through a wall; ping across the full path 0.734/1.018/1.460 ms min/avg/max, 0% loss | UCI is live state, not config-managed - see the gotcha |
The Flint 2’s radios are MediaTek MT7986. At the time (2026-08-11) the stock GL firmware drove them with MediaTek’s own mt_wifi/mtk_warp modules, not mainline mt76 - measured via lsmod on the Flint; since 2026-09-04 the same box runs stock mainline OpenWrt and mt76 (A bridged access point on mainline OpenWrt). The dongle would have been mainline mt76 on the Pi. The driver differs; the point does not: known-good silicon with real antennas, in a case that is not a Faraday cage, already on the wall.
Why the Pi access point failed
Section titled “Why the Pi access point failed”The Pi hosted pi-iot for one evening. The measured failure sequence, per device, in the hostapd journal:
wlan0: STA cc:8d:a2:00:00:01 IEEE 802.11: associatedwlan0: STA cc:8d:a2:00:00:01 WPA: pairwise key handshake completed (RSN)wlan0: STA cc:8d:a2:00:00:01 IEEE 802.11: disassociatedwlan0: AP-STA-DISCONNECTED cc:8d:a2:00:00:01then again, seconds later, indefinitely. Authentication succeeded; the link would not hold. This was not a passphrase or AKM problem - both WPA-PSK and WPA-PSK-SHA256 were offered, and a PMF-capable phone completed the full 4-way handshake, took a lease, and passed NAT’d traffic during the same window.
Two external data points say this was not a local misconfiguration. A linux-kernel report from April 2025 describes the same connect-then-disconnect loop on the same chip with current firmware, unresolved in the thread.1 A Raspberry Pi kernel issue tracks a BCM43455 firmware crash triggered specifically by ESP32-class clients that reconnect without clean deauth frames - exactly what a flapping plug does - though in concurrent STA+AP mode, a harder configuration than the AP-only one used here.2 The Argon ONE’s aluminum case over a PCB antenna did the rest: even when associated, the link budget left nothing.
The segment design
Section titled “The segment design”One SSID, one VLAN, one owner per job:
| Job | Owner | Detail |
|---|---|---|
| Radios | Flint 2 | SSID home-iot, WPA2-PSK (psk2), 2.4GHz, client isolation on; on mainline OpenWrt since 2026-09-04, disassoc_low_ack 0 so a lossy ACK never kicks a plug |
| L2 transport | Switch port 2 | Access port, PVID 40; the AP tags nothing |
| DHCP + pins | kea on the router | One reservation per device MAC, 10.40.0.20-23 |
| Policy | nftables on the router | Forward chain: pi-hub -> IoT for the API (6053), OTA (3232) and web UI (80); IoT -> pi-hub for NTP (udp/123) only - every other hub-device flow is hub-initiated and rides conntrack. No internet, no other segment. Since 2026-09-04 the router’s input chain also refuses DNS, mDNS and its own web services from the segment: the fleet talks to pi-hub by address and needs no resolver, so a compromised device keeps no covert channel. Dropped IoT packets are logged (rate-limited) for review |
| Hub | pi-hub, admin segment | Home Assistant + ESPHome at 10.10.0.7; the only host the fleet can reach |
The pins, live in kea as measured from /var/lib/kea/leases4.csv:
| Device | MAC | Pin |
|---|---|---|
| athom-plug-1 | cc:8d:a2:00:00:01 | 10.40.0.20 |
| athom-plug-2 | cc:8d:a2:00:00:02 | 10.40.0.21 |
| athom-plug-3 | cc:8d:a2:00:00:03 | 10.40.0.22 |
| athom-plug-4 | cc:8d:a2:00:00:04 | 10.40.0.23 |
Reservations over manual_ip, deliberately: an address baked into firmware needs a reflash to change and dies on a factory reset, while a server-side pin lives in git, survives reflashing, and keeps one list of who-is-who. The pin is also what makes ESPHome’s use_address stable - mDNS across segments cannot be relied on.
Fleet growth, 2026-08-16. The block scheme held: plugs kept .20-.31 (eight more units pre-allocated before the hardware shipped), and the first non-plug device class - an AirGradient ONE air monitor - opened a separate sensors block at .40 rather than interleaving with plugs. Its adoption validated the pre-allocation workflow end to end: commented reservation stub in git before the hardware arrived, MAC read from the first dynamic-pool lease under stock firmware, stub filled in, and the device re-DHCP’d straight onto its pin after the ESPHome flash. Device-class blocks beat one dense pool: each class has room to grow and the address alone tells you what a device is. The AirGradient unit’s own config work (frozen upstream packages, local PM batch correction) is the implementation guide AirGradient ONE on ESPHome, fully local.
The access point’s side of the segment
Section titled “The access point’s side of the segment”On mainline OpenWrt (since 2026-09-04) the access point’s part is three UCI stanzas, kept in a git repo and pushed by a deploy script (A bridged access point on mainline OpenWrt is the full build):
- a
br-iotbridge device containing exactly one Ethernet port, the one cabled to the switch’s VLAN 40 access port config interface 'iot'withoption proto 'none'on that bridge - no address, no routing, a media converterconfig dhcp 'iot'withoption ignore '1'(plusra/dhcpv6disabled) so the AP’s dnsmasq never competes with kea- the
home-iotwifi-ifacewithoption network 'iot',psk2,isolate 1
The vendor firmware needed the same shape expressed against its own IoT-network feature, which is a NAT’d 192.168.10.0/24 with an auto-enabled DHCP server.5 Measured live in August: network.iot.proto='none', dhcp.iot.ignore='1', lan1 moved into br-iot, and the SSID re-pointed at it. That configuration was UCI state edited on the box, and the vendor’s two wireless provisioning layers eventually lost it on reboot (Flint 2 VAP durability), which is why the box now runs mainline.
The L2 proof is the same on either firmware: a temporary address on the AP’s bridge (ip addr add 10.40.0.250/24 dev br-iot), ping the router at 10.40.0.1 (3/3, rtt min/avg/max 0.734/1.018/1.460 ms in August), ARP resolves to the router’s MAC, delete the address. That exercises the entire path - the AP port, the switch PVID, the trunk, the router’s VLAN interface - with no config left behind. Adding a policy-routing table for that temporary address (ip rule add from 10.40.0.250 table 100, a connected route and a default via 10.40.0.1 in that table) turns the same trick into a probe of the router’s forward policy from the segment’s own vantage point; that is how the 2026-09-04 rules above were verified.
The adoption path that works
Section titled “The adoption path that works”The plan was to OTA each plug with the new SSID while it sat on the old one. The flap loop made that untenable - an OTA needs minutes of stable link, and the plugs could not hold seconds. The path that worked needs no link to the old AP at all:
- Switch the old AP off. This is the step the docs do not spell out. ESPHome opens its fallback hotspot only after
ap_timeoutwith no successful connection - but a flap loop produces a successful association every few seconds, which resets the timer, so the fallback never fires while a sick AP keeps beaconing. Observed: over an hour of flapping with the old AP up produced no fallback SSID; once the old AP was off (and each plug had rebooted into the silence), the fallback SSIDs appeared within minutes. The timer-reset mechanism itself is inferred from the ESPHome source and docs, not proven on this rig.3 - Join the fallback hotspot from a phone, open
http://192.168.4.1, enter the new SSID and passphrase. The captive portal stores the credentials in flash and the device reboots onto the new network.4 - The pin does the rest. kea recognises the MAC, hands over the reserved address, and the device appears at exactly the
use_addressthe dashboard already points at. - OTA once, over the good link, to bake the new SSID into firmware. Portal-stored credentials survive reboots but a later serial flash would overwrite them; the bake keeps firmware and YAML saying the same thing.4
Two measured surprises on this path: the stock Athom firmware accepted the OTA of the replacement config with no password prompt, and the portal-set credentials survived every reboot without the bake - but the bake is still worth doing for the YAML-firmware match.
Reading the numbers
Section titled “Reading the numbers”| Claim in this doc | How it was checked |
|---|---|
| Flap loop: associate / handshake completed / disassociated, repeating | Measured 2026-08-10/11: hostapd journal on pi-hub, both plug MACs |
| 45-290 ms pings with loss to a same-room plug on the Pi AP | Measured 2026-08-10: ping from pi-hub over pi-iot |
| Pi AP verified working for a phone (handshake, lease, NAT flows) | Measured 2026-08-10: hostapd journal + dnsmasq leases + /proc/net/nf_conntrack ASSURED entries |
| New path latency 0.734/1.018/1.460 ms, 0% loss | Measured 2026-08-11: temporary address on br-iot, ping 10.40.0.1 |
| Plug signal -70 dBm on the Flint | Measured 2026-08-11: ESPHome log stream, athom-plug-1 WiFi Signal sensor |
| All four kea pins taken by the correct MACs | Measured 2026-08-11: /var/lib/kea/leases4.csv on the router |
| Pi -> plug :6053 and :3232 reachable through the forward rule | Measured 2026-08-11: TCP connect from pi-hub to 10.40.0.20/.21/.23 |
| Stock Athom OTA accepts the replacement config, no password | Measured 2026-08-11: dashboard Install to 10.40.0.22/.23 |
| Fallback hotspot never fires during the flap loop; fires once the old AP is off | Measured behavior 2026-08-11; timer-reset mechanism inferred from ESPHome docs and source |
| brcmfmac cannot maintain an AP on this chip | Documented: linux-kernel thread, same chip, same symptom shape; ESP32-triggered firmware crash in raspberrypi/linux#7092 (concurrent STA+AP mode there; AP-only here) |
Flint radios are MT7986; driven by MediaTek mt_wifi/mtk_warp under the vendor firmware, by mainline mt76 since 2026-09-04 | Measured 2026-08-11 (lsmod on the Flint) and 2026-09-04 (opkg list-installed: kmod-mt7915e) |
| Stock GL IoT network NATs 192.168.10.0/24 and auto-enables its DHCP server | Documented: GL.iNet IoT Network guide; dhcp config with force measured in the Flint’s UCI (2026-08-11, vendor firmware) |
| IoT segment reaches pi-hub and nothing else; no internet; wireless segment cannot reach IoT | Measured 2026-09-04: TCP/ICMP probes from a policy-routed temporary address on the AP’s br-iot, and from the AP’s own management address on the home segment |
| Switch port 2 is access PVID 40 | Documented in the infra repo’s switch migration record; live CLI re-verification was blocked by a switch login lockout at write time |
What generalises
Section titled “What generalises”- The hub is not the AP. A gateway radio in a metal case, an inch from a heatsink, running a driver with open AP-mode bugs, is three problems renting one antenna. Any dedicated WAP with its radios in the clear beats it, and if the WAP is already on the wall the marginal cost is four UCI statements.
- Bridge, don’t route, at the AP.
proto='none'plus a PVID’d switch port keeps DHCP, reservations, DNS, and policy in one place on the router. The AP becomes replaceable: any box that can bridge an SSID to a wire fits the slot. - Pins on the server, not the device. Every property baked into firmware is a property you change by reflashing. Every property on the DHCP server is a line in git.
- The escape hatch has a precondition. ESPHome’s fallback hotspot is the right adoption path for devices you cannot reach - but it only opens when the device stays disconnected. A sick AP that almost works is worse than no AP: it keeps the device connected just enough to never ask for help.
- A credential change is a fleet migration. ESPHome bakes the SSID and key at compile time, and a device without a fallback hotspot in its yaml cannot be re-provisioned over the air. The cutover that followed the mainline move is Moving an ESPHome fleet to a new SSID.
- Pre-allocate pins before the hardware arrives, one block per device class. A commented reservation stub costs nothing, the first dynamic lease supplies the MAC, and the address itself becomes documentation (plugs in one range, sensors in another). Validated 2026-08-16 with the first sensor adoption: stub in git, MAC from the lease table, device on its pin after one flash.
References
Section titled “References”-
KeithG, “Cannot maintain an ap with brcmfmac,” linux-kernel mailing list, April 2025. https://lists.openwall.net/linux-kernel/2025/04/20/201 ↩ ↩2
-
mshioji, “BCM43455 firmware crash with concurrent STA+AP mode on kernel 6.12,” raspberrypi/linux issue 7092, filed 2025-10-17. https://github.com/raspberrypi/linux/issues/7092 ↩ ↩2
-
ESPHome, “WiFi Component,” ESPHome Docs. https://esphome.io/components/wifi/ ↩ ↩2
-
ESPHome, “Captive Portal,” ESPHome Docs. https://esphome.io/components/captive_portal/ ↩ ↩2 ↩3
-
GL.iNet, “IoT Network,” GL.iNet Router Docs 4. https://docs.gl-inet.com/router/en/4/interface_guide/iot_network/ ↩