RF433 Hub — Part 2: Web Dashboard, Matter Deep-Dive & Custom Carrier Board

KiCad 3D render of Shutter Hub Prototype V2 green PCB

This is Part 2 of my series documenting the RF433 Shutter Hub project. Part 1 covered reverse-engineering the AC123 protocol, the hardware pivot from Si4463 to CC1101, and the decision to build on Matter. Here I’ll walk through everything that has changed since then: a proper web dashboard, a deeper Matter integration, and the move from breadboard to a custom carrier board.

Where We Left Off

At the end of Part 1, the hub worked — but only just. RF commands fired reliably after we cracked the AC123 timing (300 µs for a zero bit, 580 µs for a one), learned the UP/DOWN/STOP codes off a physical remote, and sorted out the portENTER_CRITICAL issue that had been causing transmission glitches under FreeRTOS scheduling pressure. The Matter stack was running, shutters showed up in Apple Home, and the thing lived on a breadboard held together with more optimism than solder.

Three things have happened since then that are each worth their own discussion:

  1. A full single-page web interface — authentication, shutter management, code learning, OTA updates, and Matter pairing — all served from flash with no filesystem partition.
  2. A much more complete Matter integration, including timed partial-position moves, debouncing, and move-queue coordination between shades.
  3. A custom KiCad carrier board that replaces the breadboard and eliminates the rats-nest of jumper wires.

The Web Dashboard

One of the things I was most keen to avoid was requiring a filesystem partition. The ESP32-S3’s flash is valuable, and SPIFFS/LittleFS adds complexity, especially around OTA updates where you have to be careful not to wipe the filesystem when flashing new firmware. The solution was to embed the entire single-page application in a C header as a PROGMEM string — roughly 1,100 lines of HTML/CSS/JS compiled directly into the firmware image. Which means every UI change requires a reflash. A small price to pay for not wrestling with SPIFFS.

The UI is served at the root of a lightweight ESPAsyncWebServer instance. No external fonts are fetched in AP mode (they’re declared as a @import with a system-font fallback), so the interface works even when the device hasn’t connected to a network yet.

Login & First-Boot Security

The auth model is deliberately minimal: single-user, session-token based. On first boot the device writes admin/admin into NVS, sets a needs_change flag, and the first login immediately bounces you to a password-change screen. The session token is a 128-bit random value generated from the ESP32’s hardware RNG and stored only in RAM — it’s gone on reboot, which means you’ll need to log in again after a power cycle. Given that the device is tucked behind a wall plate and rarely reboots, that’s not much of a hardship.

192.168.4.1
Shutter Hub
CC1101 · AC123 · Matter
Sign in
Enter your credentials to continue.
Username
admin
Password
••••••
Sign in
Default credentials on first boot:
admin / admin

Login page — served from PROGMEM, works in AP mode with no internet connection.

The Main Dashboard

Once in, the app is a two-tab layout. The Shutters tab lists every configured shade — RF controls, decoded AC123 details, code-learn buttons, and travel time calibration. The System tab handles firmware updates, syslog, TX power, and the danger-zone controls.

192.168.1.42 — Shutter Hub

Shutter Hub v1.3
Configure 433 MHz shades, learn remote codes, and manage the hub.

Shutters
System

Add Shade
Living Room Left
Add shutter

Shutters
Quick learn captures one remote frame and infers UP, DOWN, and STOP automatically.

Stencil Room Shade
Shade 1
Fully learned

Remote ID
A3527EA7
Channel
0100
Code Status
UP yes / DOWN yes / STOP yes

Full travel time (seconds)

17.0
Save

Sync position (%)

0–100
Set

0 = open · 100 = closed · use after physical remote

UP
DOWN
STOP

Quick learn
Learn UP
Learn DOWN
Learn STOP
Delete

The Shutters tab — each shade shows its remote ID, channel, and code status. Green pills for UP and DOWN, amber for STOP. Learn buttons sit in a separate row below, with Delete tucked safely to the right.

Code Learning: Quick-Learn vs. Manual vs. Generate

The most user-facing piece of engineering here is the code-learn flow. The AC123 protocol is well-structured enough that if you press any button on the physical remote, you can infer all three command codes from a single packet — the Remote ID and Channel are constant across UP, DOWN and STOP; only byte 6 (the command byte) differs. That’s the Quick Learn path: point the remote at the hub, press any button once, and all three codes are stored automatically.

If you’ve lost the original remote or want to add a new motor, there’s a Generate path. The hub uses the ESP32’s hardware RNG to synthesise a fresh Remote ID, picks a channel, and calculates all three code variants automatically. The catch: the motor doesn’t know this new remote exists yet, so you still have to teach it. It hasn’t been consulted on any of this. Put the motor into pairing mode (typically by holding the original remote’s STOP button near the motor head until it jogs), then send a command from the hub — the motor accepts the new virtual remote’s ID and stores it permanently. From that point on, the hub owns that motor. No physical remote needed at all going forward.

Behind the Quick Learn flow the decode logic lives entirely on the ESP32. A small parser dissects the 8-byte AC123 frame:

// AC123 frame format (8 bytes, MSB-first)
// Byte 0-3 : Remote ID  (unique per physical remote)
// Byte 4-5 : Channel    (bit-shifted, CH1=0x2000, CH2=0x1000, CH3=0x0800)
// Byte 6   : Command    (UP=0x0B  STOP=0x23  DOWN=0x43  RELEASE=0x24)
// Byte 7   : Checksum   (sum of bytes 1-6, masked to 8 bits)

// To regenerate UP/DOWN/STOP from any captured packet:
//   1. Extract bytes 0-5 (Remote ID + Channel) - identical across all commands
//   2. Substitute byte 6 with the desired command code
//   3. Recompute byte 7: sum(bytes[1..6]) & 0xFF

That’s all it takes to infer the full set from a single capture.

Settings Tab: Matter Pairing Code & OTA

The Settings tab pulls its data from GET /api/status, which returns device vitals alongside the Matter commissioning payload. The pairing code is rendered in large monospace text alongside a Reset Matter pairing button — useful during development when you’re frequently re-commissioning. Over-the-air firmware updates are handled through a .bin file upload; the ESP-IDF OTA framework validates the image and reboots automatically on success. Learned shutter codes live in a separate NVS namespace and survive firmware updates.

192.168.1.42 — Shutter Hub

Shutters
System

Status
Wi-Fi
192.168.1.42
Matter
commissioned
RF / CC1101
ready
Saved Shutters
2

RF TX Power
Controls the CC1101 transmit power. Set to the lowest level that gives reliable operation.
+2 dBm
Save

Firmware Update
Upload a .bin file to flash over-the-air. Hub reboots automatically on success.
Choose file
No file chosen
Flash firmware

Danger Zone
Reboot restarts with current settings. Matter reset removes all commissioning data.
Reboot hub
Matter factory reset

System tab — device status, RF TX power, OTA firmware update, and the danger zone. Everything you need, nothing you don’t.

RF TX Power Selection

One setting that deserves its own callout is the CC1101’s transmit power level. The CC1101 supports a range of output power settings via its PATABLE register, and it’s tempting to just set it to maximum and move on. The better approach is to set it to the lowest level that still produces reliable operation from wherever the hub is mounted.

There are a few good reasons for this. Higher TX power means more radiated energy bouncing around your home, which can increase noise for nearby 433 MHz receivers (including the hub’s own CC1101 when it switches back to RX mode). It also increases current draw during transmission, which matters if you’re running on battery backup. Perhaps most importantly, a strong signal that reflects off walls and furniture can actually cause decoding errors at close range — the motor’s receiver is designed for a remote held a few metres away, not a transmitter running full power a metre from its head.

In practice, the hub typically lives in a central location — a hallway cupboard or behind a wall plate — and the shutters are within 10–15 metres. A mid-range power setting (around −6 to 0 dBm on the CC1101 scale) is usually more than sufficient. My install actually works better at +2 dBm than at the +7 dBm default. Start low, test reliable command reception across all shutters, then stop there rather than winding it up further. More power is not always more better.

Matter Integration: Going Deeper

In Part 1 I described Matter as “the glue that makes everything talk to everything.” Here’s what that actually looks like in practice, and why it turned out to be more nuanced than a simple on/off switch.

What Matter Actually Provides

Matter is an application-layer protocol built on IPv6/Thread/WiFi with a mandatory cryptographic commissioning phase. Once commissioned, every device on a Matter fabric can be controlled by any compatible ecosystem — Apple Home, Google Home, Amazon Alexa, SmartThings — without any cloud dependency. All communication stays on the local network.

For shutters specifically, Matter defines the Window Covering cluster. This gives you a standardised API for position (0–100%), tilt, and operational mode. The Apple Home app renders this as a blinds tile with a position slider, a native open/close/stop control, and support for automations like “close all blinds at sunset.”

The Bridge Architecture

Rather than commissioning each shutter as an independent Matter device (which would require each to go through its own pairing ceremony and hold its own fabric credentials), the hub runs as a Matter Bridge. The bridge itself is the commissioned device; each shutter appears as a bridged endpoint within the bridge’s node. This is exactly how a Zigbee coordinator works when exposed to Matter.

The practical limit comes from the HomeKit Accessory Protocol spec: a bridge can expose at most 41 accessories. That maps directly to the MAX_SHUTTERS 41 constant in the firmware.

// Each shutter gets a bridged Window Covering endpoint on the Matter node.
// The bridge itself is endpoint 0; bridged accessories start at endpoint 1.
struct BridgedShutterDevice {
    esp_matter_bridge::device_t *device = nullptr;
    int shutterIndex = -1;   // maps back to NVS shutter slot
};

Partial Position & The Timed Move Problem

AC123 remotes have three commands: UP, DOWN, and STOP. There is no concept of “go to 60%” in the protocol — the motor runs until it hits a limit switch or until you send STOP. To expose a position slider in the Home app that actually does something useful, you have to implement timed-stop control in the hub firmware.

The approach: if the Home app requests 60% open, the firmware sends DOWN, waits for 40% of the configured travel time, then sends STOP. The position attribute is updated optimistically in the cluster store, then confirmed when the timer fires.

Shutter travel time is the key calibration value — the number of milliseconds it takes the motor to travel from fully closed to fully open. It’s configured per-shutter in the web UI and stored in NVS alongside the RF codes. Get it wrong and the hub will either overshoot (sending STOP too late, past the target position) or undershoot (stopping early). A good approach is to time several full open-to-close cycles with a stopwatch and average them; motors can be slightly inconsistent between runs due to mechanical load. The value doesn’t need to be perfect — the position-slider update loop and move-lock queue mean corrections are cheap — but the closer it is, the fewer corrections the Home app has to make.

Two non-obvious problems arise from this:

Debouncing slider input. The Home app sends a position-update attribute write for every pixel of slider drag — you can get 50 updates in a second. The solution is a 500 ms debounce timer per shutter: any new update restarts the timer, and only the final value triggers a physical move.

Move lock coordination. If you command a different position to the same shutter while a timed move is in progress, you need to abort the move, send STOP, and start a corrected move. The firmware uses a simple per-shutter queue to handle this without blocking the Matter task.

// Debounce: 500 ms wait after the last slider position before firing RF.
constexpr uint32_t kDebounceMs = 500;

// Move lock: tracks which shutter has an active timed move.
// Full-travel moves (0% or 100%) bypass the lock — they fire immediately.
static int s_lock_shutter = -1;    // -1 = no lock
static MoveParams s_move_queue[MAX_SHUTTERS] = {};   // one slot per shutter

Commissioning Flow

On first boot the hub starts in AP mode (ShutterHub-XXXXXX) and serves a captive portal for WiFi setup. Once credentials are saved it reboots into station mode and the Matter stack starts. The pairing code in the Settings tab can be entered manually in the Home app, or scanned as a QR code via GET /api/matter/status.

AP Mode
WiFi setup
WiFi STA
Matter starts
Commissioned
Home / Google / Alexa
Bridged
Up to 41 shades
✕ No cloud required

Commissioning flow: AP mode → WiFi → Matter fabric → bridged shutters. All local, no cloud.

One important detail: Matter fabric credentials and shutter NVS data live in separate namespaces. A factory reset wipes both; an OTA firmware update wipes neither. You can update firmware without losing your pairing or learned codes.

From Breadboard to Carrier Board

The breadboard was always temporary. It’s always temporary. And then six weeks pass. The CC1101 module’s 2mm pitch header doesn’t sit neatly in 2.54mm breadboard rails, the SPI lines picked up noise from adjacent wires, and the assembly had a habit of partially disconnecting when moved. The solution was a carrier board designed in KiCad 10.

What’s On the Board

  • ESP32-S3-DevKitC-1U-N8R8 (U1) — main compute module. The N8R8 variant gives 8MB of octal-SPI PSRAM alongside 8MB flash — comfortable headroom for the Matter stack.
  • CC1101 RF module (J10) — connected via SPI (MOSI, MISO, SCK, CSN) on GPIO pins chosen to avoid the ESP32-S3’s USB CDC lines. GDO0 and GDO2 are wired to GPIO inputs for interrupt-driven packet detection.
  • 14-pin and 20-pin expansion headers — breaks out 5V, 3v3, GND, other unused GPIO pins for future expansion.
Purple prototype PCB with ESP32-S3 DevKit and CC1101 RF module

Prototype V1 — ESP32-S3 DevKit + CC1101 module wired on a breadboard carrier. Purple solder mask, whip antenna out the top.

KiCad 3D render of Shutter Hub Prototype V2 green PCB

Carrier Board V2 — KiCad 3D render. Corrected SPI wiring, broken-out 14-pin GPIO expansion header (J1), much cleaner layout.

Design Decisions

Module-on-board rather than bare chip. Both the ESP32-S3-DevKitC and the CC1101 are used in module form — not bare silicon. No RF matching network, no crystal, no complex decoupling layout. The trade-off is board area, but for a hub that lives behind a panel that’s fine.

14-pin expansion header. Breaks out 5V, GND, the debug UART (GPIO43/44), and spare GPIOs. If I want to add a display or a second RF module later, I don’t need to spin a new board.

SPI pin selection. GPIO48 doubles as the FSPI/SPI3 CS when using the octal PSRAM, so the CC1101 is assigned to SPI2 with its own dedicated CS on a non-conflicting GPIO.

Build status: A small number of boards have been fabricated and are currently in active prototype testing. The design is still being iterated on — files will be published once I’m satisfied the layout is solid and reproducible. If you’re interested in getting a board before the files drop, feel free to reach out — I may have a few available. If you replicate this yourself, double-check the CC1101 module’s 2mm pitch footprint against your specific variant before ordering.

Chasing Reliability: The PHP Capture Script

For a while the hub worked well in controlled testing but would occasionally miss commands during real-world use — commands that should have fired didn’t, or a shade would respond intermittently. The timing constants in the firmware had been derived from a Saleae logic analyser capture of the original AllOne Pro hub, and while they were close, “close” in OOK PWM isn’t always close enough. The motor receivers are tuned to a specific pulse shape from the factory remote, and any meaningful deviation gives the decoder a reason to reject the frame.

The breakthrough came from setting up a proper RF capture pipeline using a Raspberry Pi with a second CC1101 module in receive mode. A PHP script drove the capture: it triggered the CC1101 into async OOK receive on GDO0, read edges via /dev/gpiochip0 at the GPIO level, and wrote timestamped rising/falling events to JSON. Running this while the hub transmitted gave us something we hadn’t had before — a precise measurement of what the hub was actually sending, not what we thought it was sending.

What the Captures Revealed

Three separate captures of the hub’s UP command were compared. Each edge in the JSON was timestamped in nanoseconds, so pulse widths could be computed exactly. The numbers told a clear story:

  • Bit-0 HIGH pulses averaged 285 µs across all three captures (target: 281 µs from the physical remote measurement — well within tolerance).
  • Bit-1 HIGH pulses averaged 606 µs (target: 603 µs — also fine).
  • SYNC HIGH averaged 5,016 µs (firmware value: 5,000 µs — minor, not a problem).
  • SYNC LOW averaged 643 µs across the three captures — but the firmware was generating it at 600 µs. That’s a 40+ µs shortfall on every single frame.

The SYNC LOW pulse is the gap that separates the sync preamble from the first data bit. It’s the timing marker the motor’s decoder uses to orient itself at the start of each frame. Getting it wrong by 7% on every transmission means the decoder is constantly trying to lock onto frames that are slightly malformed. Most of the time it tolerates it; occasionally, under marginal RF conditions or when the shade is near a wall, it doesn’t.

The fix was a one-line change: AC123_TX_SYNC_LOW from 600 to 632 µs (the value measured directly from the physical remote via the Saleae capture). The captures had also surfaced a FreeRTOS scheduling issue — the original code made separate tx_mark() and tx_space() calls for each pulse half, and the FreeRTOS tick ISR could fire between them, silently stretching a 300 µs pulse to over 1 ms and corrupting the frame. That was fixed by wrapping both halves of every pulse in a single portENTER_CRITICAL / portEXIT_CRITICAL block in a new tx_pulse() function, so the scheduler can’t interrupt mid-pulse.

// Before: two separate calls — FreeRTOS tick could stretch the HIGH half
tx_mark(AC123_TX_BIT0_HIGH);   // <-- tick ISR could fire here
tx_space(AC123_TX_BIT0_LOW);

// After: both halves in one atomic critical section
static inline void tx_pulse(uint32_t high_us, uint32_t low_us) {
    portENTER_CRITICAL(&s_rf_mux);
    gpio_set_level(PIN_GDO0, 1);
    esp_rom_delay_us(high_us);
    gpio_set_level(PIN_GDO0, 0);
    esp_rom_delay_us(low_us);
    portEXIT_CRITICAL(&s_rf_mux);
}

After both fixes — the corrected SYNC LOW timing and the critical-section pulse function — intermittent misses disappeared. The hub has been running daily since without a single dropped command. The capture script paid for itself in the first use: what had looked like an RF range or antenna problem turned out to be a 40 µs timing error hiding in plain sight. Forty microseconds. I’ve spent longer than that trying to find my keys.

What’s Next

  • Physical button inputs. The expansion header (J1) has spare GPIOs just waiting for tactile buttons — direct wall-mount control without needing the app, and a cleaner fallback during network outages.
  • Additional remote protocols. AC123 is the first, but the CC1101’s programmable modem makes it straightforward to add support for other 433 MHz motor protocols. Somfy RTS is the obvious next target.
  • Reliability enhancements. Timed-move calibration via the web UI (instead of hardcoded travel times), automatic RF retransmission with ACK detection, and more robust debounce handling for rapid Home app input.
  • PCB iteration. A few boards are already built and being tested. Refinements are ongoing — once the design is locked, full KiCad files and firmware will be published on GitHub. If you’re interested in a pre-built board in the meantime, get in touch.

Thanks for following along. The RF433 rabbit hole turned out deeper than expected, but the result is exactly what I wanted: local control, zero cloud, and compatibility with any modern home automation ecosystem.

#RF433
#Matter
#HomeKit
#ESP32-S3
#CC1101
#KiCad
#HomeAutomation
#EmbeddedSystems
#AC123
#OpenHardware

Leave a Reply

Discover more from [t] Engineering

Subscribe now to keep reading and get access to the full archive.

Continue reading