Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

SpecPHY6222PHY6252
CoreARM Cortex-M0, 48 MHzARM Cortex-M0, 48 MHz
Flash512 KB256 KB
SRAM64 KB64 KB
ROM96 KB96 KB
BLE5.05.2
Radio2.4 GHz BLE + IEEE 802.15.42.4 GHz BLE + IEEE 802.15.4
TX power-20 dBm to +10 dBm-20 dBm to +10 dBm
Targetthumbv6m-none-eabithumbv6m-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

BoardForm FactorLED PinsButton
Ai-Thinker PB-03FModule, castellated (PHY6252)P11 (R), P12 (G), P14 (B)P15 (PROG)
Tuya THB2Sensor enclosure (PHY6222)VariesVaries
Tuya TH05FTemp/humidity sensor (PHY6222)VariesVaries
BTH01BLE thermometer (PHY6222)VariesVaries

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:

RegionBase AddressPurpose
RF PHY0x4003_0000Analog/baseband configuration (PLL, LNA, PA, modulation)
LL HW0x4003_1000Link-Layer hardware engine (TX/RX control, IRQ, CRC)
TX FIFO0x4003_1400Frame data write port
RX FIFO0x4003_1C00Received frame read port
CLK CTRL0x4000_F040Crystal/clock configuration
GPIO0x4000_8000GPIO 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

AspectPHY6222Other Platforms
Vendor SDKNot neededRequired
Binary blobsZeroliblmac154.a, rcl.a, etc.
FFI callsZeroDozens of extern "C" functions
AuditabilityFull — all RustOpaque vendor libraries
Build depsJust cargoSDK 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:

SensorMeasuresI²C Address
CHT8215Temp + Humidity0x40
CHT8310Temp + Humidity0x40
SHT30Temp + Humidity0x44
AHT20Temp + Humidity0x38

A real firmware would initialize the PHY6222 I²C peripheral and use an embedded-hal 1.0 compatible sensor driver.

Troubleshooting

SymptomCauseFix
portable-atomic errorsMissing featureEnsure features = ["unsafe-assume-single-core"]
Flash failsNot in bootloader modeHold PROG button during power-on
No serial outputNo logger registeredlog::info!() is no-op on Cortex-M0+ without logger
Radio not workingTP calibration defaultsProduction firmware needs proper PLL lock sequence
Wrong memory layoutOTA header offsetEnsure FLASH ORIGIN = 0x11001000 (after 4 KB bootloader)
64 KB SRAM overflowLarge buffersOptimize 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

TierPhaseSleep ModeCurrentWake Source
1Fast poll (250 ms, first 120 s)Radio off + WFE~1.5 mATimer
2Slow poll (30 s, steady state)AON system sleep~3 µARTC

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)

FunctionPurpose
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:

AttributeMin IntervalMax IntervalChange Threshold
Temperature60 s300 s±0.5 °C
Humidity60 s300 s±1%
Battery300 s3600 s±2%

Battery Life Estimate (2×AAA, ~1200 mAh)

StateCurrentDuty 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:

  1. Eliminates supply chain risk — no opaque binary blobs
  2. Simplifies the build — just cargo build, no SDK downloads
  3. Enables full auditing — every line of radio code is visible and reviewable
  4. Reduces binary size — no unused vendor code linked in
  5. Serves as a reference — shows the path for pure-Rust drivers on other chips