PHY6222
The Phyplus PHY6222 is the only zigbee-rs platform with a 100% pure-Rust radio driver — no vendor SDK, no binary blobs, no C FFI. All radio hardware access uses direct register writes in Rust, derived from the open-source PHY6222 SDK.
Hardware Overview
The PHY6222 and PHY6252 are part of the PHY62x2 chip family from Phyplus Microelectronics. They share the same register layout, SDK, and flash tools — our driver works for both. Key differences:
| Spec | PHY6222 | PHY6252 |
|---|---|---|
| Core | ARM Cortex-M0, 48 MHz | ARM Cortex-M0, 48 MHz |
| Flash | 512 KB | 256 KB |
| SRAM | 64 KB | 64 KB |
| ROM | 96 KB | 96 KB |
| BLE | 5.0 | 5.2 |
| Radio | 2.4 GHz BLE + IEEE 802.15.4 | 2.4 GHz BLE + IEEE 802.15.4 |
| TX power | -20 dBm to +10 dBm | -20 dBm to +10 dBm |
| Target | thumbv6m-none-eabi | thumbv6m-none-eabi |
Note: PHY6252 specs are from the official Ai-Thinker PB-03/PB-03F datasheets (256 KB flash, 64 KB SRAM, 96 KB ROM). PHY6222 is reported as 512 KB flash in the pvvx/THB2 SDK (flash dump size
0x80000). Both chips use the same register map and are binary-compatible.
Why PHY6222?
- Ultra low cost — modules available for ~$1.50 (PB-03F with PHY6252)
- Widely deployed — used in Tuya THB2, TH05F, BTH01 sensor devices (PHY6222)
- Open-source SDK — register documentation available from community efforts
- Pure Rust — the only zigbee-rs backend with zero vendor dependencies
Common Boards and Modules
| Board | Form Factor | LED Pins | Button |
|---|---|---|---|
| Ai-Thinker PB-03F | Module, castellated (PHY6252) | P11 (R), P12 (G), P14 (B) | P15 (PROG) |
| Tuya THB2 | Sensor enclosure (PHY6222) | Varies | Varies |
| Tuya TH05F | Temp/humidity sensor (PHY6222) | Varies | Varies |
| BTH01 | BLE thermometer (PHY6222) | Varies | Varies |
Memory Map
FLASH : ORIGIN = 0x11001000, LENGTH = 508K ← 4 KB reserved for bootloader
RAM : ORIGIN = 0x1FFF0000, LENGTH = 64K
The PHY6222 maps flash at 0x1100_0000 and SRAM at 0x1FFF_0000. The first
4 KB of flash is reserved for the bootloader/OTA header.
Prerequisites
Rust Toolchain
rustup default nightly
rustup update nightly
# Add the Cortex-M0 target
rustup target add thumbv6m-none-eabi
# rust-src for build-std
rustup component add rust-src
No Vendor SDK Required!
Unlike every other embedded zigbee-rs platform, PHY6222 needs no vendor libraries, no SDK download, no environment variables. Everything is in Rust.
Flash Tool
The PHY6222 is typically flashed via UART bootloader. Community tools:
- PHY62xx_Flash — open-source serial flasher
- Ai-Thinker Flash Tool — GUI tool for PB-03F modules
- OpenOCD — via SWD debug interface (if exposed)
Building
Full Build (no stubs, no vendor SDK)
cd examples/phy6222-sensor
cargo build --release
That’s it. No --features stubs, no SDK_DIR environment variable, no
binary blobs. The firmware compiles entirely from Rust source.
CI Build Command
From .github/workflows/ci.yml:
# Toolchain: nightly with thumbv6m-none-eabi + rust-src + llvm-tools
cd examples/phy6222-sensor
cargo build --release
# Firmware artifact extraction
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
Build Script (build.rs)
The simplest build.rs of all platforms:
fn main() {
println!("cargo:rustc-link-arg=-Tlink.x");
}
No vendor library discovery, no conditional linking — just the linker script.
.cargo/config.toml
[build]
target = "thumbv6m-none-eabi"
[unstable]
build-std = ["core", "alloc"]
Release Profile
[profile.release]
opt-level = "s" # Optimize for size (256 KB flash on PHY6252)
lto = true # Link-Time Optimization
Flashing
UART Bootloader
Most PHY6222 boards have a UART bootloader accessible by holding the PROG button during power-on:
# Using community serial flasher
phy62xx_flash --port /dev/ttyUSB0 target/thumbv6m-none-eabi/release/phy6222-sensor.bin
SWD Debug (if available)
Some boards expose SWD pins for debug:
# With a CMSIS-DAP or J-Link probe
openocd -f interface/cmsis-dap.cfg -f target/phy6222.cfg \
-c "program phy6222-sensor.bin 0x11001000 verify reset exit"
OTA Update
For deployed Tuya devices, firmware can be updated over Zigbee OTA. The OTA cluster is defined in zigbee-rs but the actual upgrade flow is not yet implemented.
MAC Backend Notes
The PHY6222 MAC backend lives in zigbee-mac/src/phy6222/:
zigbee-mac/src/phy6222/
├── mod.rs # Phy6222Mac struct, MacDriver trait impl
└── driver.rs # Phy6222Driver — pure-Rust register-level radio driver
Feature Flag
zigbee-mac = { features = ["phy6222"] }
Architecture — 100% Rust
MacDriver trait methods
│
▼
Phy6222Mac (mod.rs)
├── PIB state (addresses, channel, config)
├── Frame construction
└── Phy6222Driver (driver.rs) — PURE RUST
├── RF PHY registers (0x40030000..0x40030110)
│ ├── rf_phy_bb_cfg() → baseband for Zigbee mode
│ ├── rf_phy_ana_cfg() → PLL, LNA, PA configuration
│ └── set_channel() → frequency synthesis
├── LL HW registers (0x40031000..0x40031060)
│ ├── ll_hw_set_stx() → single TX mode
│ ├── ll_hw_set_srx() → single RX mode
│ └── ll_hw_trigger() → start operation
├── TX FIFO (0x40031400) → write frame data
├── RX FIFO (0x40031C00) → read received frames
└── IRQ → Embassy Signal for async completion
Register Map Overview
The PHY6222 radio is controlled through three register regions:
| Region | Base Address | Purpose |
|---|---|---|
| RF PHY | 0x4003_0000 | Analog/baseband configuration (PLL, LNA, PA, modulation) |
| LL HW | 0x4003_1000 | Link-Layer hardware engine (TX/RX control, IRQ, CRC) |
| TX FIFO | 0x4003_1400 | Frame data write port |
| RX FIFO | 0x4003_1C00 | Received frame read port |
| CLK CTRL | 0x4000_F040 | Crystal/clock configuration |
| GPIO | 0x4000_8000 | GPIO control (LEDs, buttons) |
Key Register Operations
Channel setting (frequency synthesis):
#![allow(unused)]
fn main() {
// Channel 11–26 maps to 2405–2480 MHz
// PHY6222 uses BLE-style frequency register offset
let freq_offset = 2405 + (channel - 11) * 5;
sub_write_reg(BB_HW_BASE + 0x28, 23, 17, freq_offset);
}
TX operation:
#![allow(unused)]
fn main() {
fn ll_hw_set_stx() {
reg_write(LL_HW_BASE + 0x00, 0x05); // Single TX mode
}
fn ll_hw_trigger() {
reg_write(LL_HW_BASE + 0x04, 0x01); // Start operation
}
}
RX operation:
#![allow(unused)]
fn main() {
fn ll_hw_set_srx(timeout_us: u32) {
reg_write(LL_HW_BASE + 0x00, 0x06); // Single RX mode
reg_write(LL_HW_BASE + 0x0C, timeout_us); // RX window
}
}
IRQ handling:
#![allow(unused)]
fn main() {
// Interrupt status bits
const LIRQ_MD: u32 = 0x01; // Mode-done (TX/RX complete)
const LIRQ_COK: u32 = 0x02; // CRC OK on received packet
const LIRQ_CERR: u32 = 0x04; // CRC error
const LIRQ_RTO: u32 = 0x08; // RX timeout
}
Register Map Source
The register definitions are derived from the open-source PHY6222 SDK,
specifically rf_phy_driver.c and ll_hw_drv.c from the
pvvx/THB2 repository.
What Makes This Unique
| Aspect | PHY6222 | Other Platforms |
|---|---|---|
| Vendor SDK | Not needed | Required |
| Binary blobs | Zero | liblmac154.a, rcl.a, etc. |
| FFI calls | Zero | Dozens of extern "C" functions |
| Auditability | Full — all Rust | Opaque vendor libraries |
| Build deps | Just cargo | SDK downloads, env vars, ABI patches |
Example Walkthrough
The phy6222-sensor example implements a Zigbee 3.0 temperature & humidity
end device for PHY6222-based boards with full SED (Sleepy End Device)
architecture.
Features
- Flash NV storage — network state saved to last 2 sectors of 512KB flash
using shared
LogStructuredNv<FlashDriver>engine. Survives reboots — no re-pairing needed. - NWK Leave handler — if the coordinator sends a Leave command, the device auto-erases NV and does a fresh join.
- Default reporting — temperature and humidity report every 60–300s, battery every 300–3600s. Device reports data even before ZHA sends ConfigureReporting.
- Identify cluster (0x0003) — supports Identify, IdentifyQuery, and TriggerEffect commands. LED toggles during identify mode.
- Real battery ADC — reads battery voltage via the PHY6222 ADC peripheral.
- Simulated temp/humidity — placeholder values that cycle; replace with I²C sensor driver for real readings.
Initialization
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
// Initialize GPIO for LEDs (PB-03F: active low)
gpio_set_output(pins::LED_G); // P12
// Create MAC driver — pure Rust, no vendor SDK!
let mac = Phy6222Mac::new();
// Flash NV storage (last 2 sectors of 512KB flash)
let mut nv = flash_nv::create_nv();
Device Setup
#![allow(unused)]
fn main() {
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("Zigbee-RS")
.model("PHY6222-Sensor")
.sw_build("0.1.0")
.channels(zigbee_types::ChannelMask::ALL_2_4GHZ)
.endpoint(1, PROFILE_HOME_AUTOMATION, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0003) // Identify
.cluster_server(0x0001) // Power Configuration
.cluster_server(0x0402) // Temperature Measurement
.cluster_server(0x0405) // Relative Humidity
})
.build();
// Restore from NV or fresh join
if device.restore_state(&nv) {
device.user_action(UserAction::Rejoin);
} else {
device.user_action(UserAction::Join);
}
// Default reporting so device reports without ZHA interview
setup_default_reporting(&mut device);
}
Main Loop
The main loop handles button presses (PROG button on GPIO15), updates simulated sensor values, polls the parent for indirect frames, and handles NWK Leave commands:
#![allow(unused)]
fn main() {
loop {
let pressed = !gpio_read(pins::BTN); // Active low
if pressed && !button_was_pressed {
device.user_action(UserAction::Toggle);
}
button_was_pressed = pressed;
// Poll parent for indirect frames (SED core)
if device.is_joined() {
for _ in 0..4 {
match device.poll().await {
Ok(Some(ind)) => {
if let Some(ev) = device.process_incoming(&ind, &mut cls).await {
match &ev {
StackEvent::LeaveRequested => {
device.factory_reset(Some(&mut nv)).await;
device.user_action(UserAction::Join);
break;
}
_ => {}
}
}
}
_ => break,
}
}
}
// Update simulated sensor readings
temp_cluster.set_temperature(temp_hundredths);
hum_cluster.set_humidity(hum_hundredths);
// Identify LED toggle
identify_cluster.tick(tick_elapsed);
if identify_cluster.is_identifying() {
gpio_write(pins::LED_G, !gpio_read(pins::LED_G));
}
Timer::after(Duration::from_secs(30)).await;
}
}
Adding a Real I²C Sensor
PHY6222-based Tuya devices typically include one of these I²C sensors:
| Sensor | Measures | I²C Address |
|---|---|---|
| CHT8215 | Temp + Humidity | 0x40 |
| CHT8310 | Temp + Humidity | 0x40 |
| SHT30 | Temp + Humidity | 0x44 |
| AHT20 | Temp + Humidity | 0x38 |
A real firmware would initialize the PHY6222 I²C peripheral and use an
embedded-hal 1.0 compatible sensor driver.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
portable-atomic errors | Missing feature | Ensure features = ["unsafe-assume-single-core"] |
| Flash fails | Not in bootloader mode | Hold PROG button during power-on |
| No serial output | No logger registered | log::info!() is no-op on Cortex-M0+ without logger |
| Radio not working | TP calibration defaults | Production firmware needs proper PLL lock sequence |
| Wrong memory layout | OTA header offset | Ensure FLASH ORIGIN = 0x11001000 (after 4 KB bootloader) |
| 64 KB SRAM overflow | Large buffers | Optimize with opt-level = "s" and LTO; PHY6252 has only 256 KB flash |
Known Limitations
- Simplified TP calibration — the pure-Rust driver uses default calibration values. Production firmware would need a proper PLL lock sequence with per-chip calibration data.
- Simulated temperature/humidity — the example uses cycling placeholder values. Replace with an I²C sensor driver (CHT8215, SHT30, etc.) for real readings. Battery voltage IS read from the real ADC.
- No OTA flow — the OTA cluster is defined but no actual firmware upgrade path is implemented yet.
Power Management
The PHY6222 sensor implements a comprehensive two-tier sleep architecture that achieves ~3+ years battery life on 2×AAA. See the Power Management chapter for the full framework.
Two-Tier Sleep Architecture
| Tier | Phase | Sleep Mode | Current | Wake Source |
|---|---|---|---|---|
| 1 | Fast poll (250 ms, first 120 s) | Radio off + WFE | ~1.5 mA | Timer |
| 2 | Slow poll (30 s, steady state) | AON system sleep | ~3 µA | RTC |
Tier 1 — Light sleep (fast poll): After joining or button press, the device polls every 250 ms for 120 seconds. Between polls, the radio is powered down and the CPU enters WFE via Embassy’s timer:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep();
Timer::after(Duration::from_millis(poll_ms)).await;
device.mac_mut().radio_wake();
}
Tier 2 — AON system sleep (slow poll): In steady state, the device enters full system sleep between 30-second polls. The AON domain’s 32 kHz RC oscillator runs the RTC for timed wake:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep();
device.save_state(&mut nv);
phy6222_hal::gpio::prepare_for_sleep(1 << pins::BTN);
phy6222_hal::flash::enter_deep_sleep();
phy6222_hal::sleep::set_ram_retention(phy6222_hal::regs::RET_SRAM0);
phy6222_hal::sleep::config_rtc_wakeup(
phy6222_hal::sleep::ms_to_rtc_ticks(poll_ms as u32),
);
phy6222_hal::sleep::enter_system_sleep();
}
On wake, the firmware detects the system-sleep reset, restores flash from deep power-down, and performs a fast restore of Zigbee network state from NV.
Radio Sleep/Wake
The MAC driver provides radio_sleep() and radio_wake() methods that power
down the radio transceiver between polls, saving ~5–8 mA.
Flash Deep Power-Down
Before system sleep, flash is put into deep power-down mode using JEDEC commands (0xB9 enter, 0xAB release), reducing flash standby current from ~15 µA to ~1 µA.
GPIO Leak Prevention
Before entering system sleep, all unused GPIO pins are configured as inputs with pull-down resistors. Only essential pins (e.g., the button) retain their pull-up. This prevents floating-pin leakage current.
AON Sleep Module (phy6222-hal::sleep)
| Function | Purpose |
|---|---|
config_rtc_wakeup(ticks) | Set RTC compare channel 0 for timed wake |
set_ram_retention(banks) | Select SRAM banks to retain during sleep |
enter_system_sleep() | Enter AON system sleep (~3 µA, does not return) |
was_sleep_reset() | Check if current boot was a wake from system sleep |
clear_sleep_flag() | Clear the sleep-wake flag after detection |
ms_to_rtc_ticks(ms) | Convert milliseconds to 32 kHz RC ticks |
Reportable Change Thresholds
The sensor configures ZCL reportable change thresholds to suppress unnecessary TX events:
| Attribute | Min Interval | Max Interval | Change Threshold |
|---|---|---|---|
| Temperature | 60 s | 300 s | ±0.5 °C |
| Humidity | 60 s | 300 s | ±1% |
| Battery | 300 s | 3600 s | ±2% |
Battery Life Estimate (2×AAA, ~1200 mAh)
| State | Current | Duty Cycle |
|---|---|---|
| AON system sleep (radio/flash off) | ~3 µA | ~99.8% |
| Radio RX (poll every 30 s) | ~8 mA | ~0.03% |
| Radio TX (report every 60 s) | ~10 mA | ~0.005% |
| Average (steady state) | ~6–10 µA | |
| Estimated battery life | ~3+ years |
Why Pure Rust Matters
The PHY6222 backend demonstrates that vendor SDKs are not a fundamental requirement for Zigbee radio operation. With enough register documentation (open-source or reverse-engineered), a complete 802.15.4 radio driver can be written in safe, auditable Rust. This approach:
- Eliminates supply chain risk — no opaque binary blobs
- Simplifies the build — just
cargo build, no SDK downloads - Enables full auditing — every line of radio code is visible and reviewable
- Reduces binary size — no unused vendor code linked in
- Serves as a reference — shows the path for pure-Rust drivers on other chips