Skip to content

radio_esp32::calibration

RF calibration data, persisted across reboots.

Calibrating the PHY takes real time at boot — esp-idf measures a full calibration in the hundreds of milliseconds — so the result is kept and reused. That is the whole point of this module, and it is also 3.6’s acceptance criterion: calibration data persists across a reboot.

What is stored, and why the checks are what they are

Section titled “What is stored, and why the checks are what they are”

esp-idf keeps three things (esp_phy_init.c, load_cal_data_from_nvs_handle): the calibration blob, the PHY’s own calibration-format version, and the station MAC. It rejects the blob unless both of the latter match, and this does the same, for the same reasons:

  • Version. The blob is opaque to us and its layout belongs to the PHY. Feeding a stale layout to a newer PHY is not a decode error, it is a radio calibrated with someone else’s numbers.
  • MAC. Calibration is per-part. A blob copied onto another board — or restored from a backup taken on one — is worse than no calibration, because it looks valid.

Rejecting costs one recalibration. Accepting a wrong blob costs range, and presents as a bad antenna rather than as anything pointing here.

esp_phy_calibration_data_t is 1904 bytes. kvstore’s [MAX_VALUE_LEN] is 128, so the blob is split across [CHUNKS] records rather than raising that limit — kvstore sizes stack buffers from it, and a 2 KB buffer on a 4 KiB task stack is a worse problem than a loop.

The split is what makes the checksum load-bearing. kvstore is an append-only log: a save that is interrupted part-way leaves some chunks new and some chunks old, every one of them individually well-formed. No per-record integrity check can see that, because nothing is corrupt — the records simply do not belong to the same calibration.

So the checksum covers the whole blob and is written last, which makes it the commit point. Interrupted before it, the stored checksum still describes the previous calibration and cannot match the mixture that is now on flash, so the load is rejected and the radio recalibrates. That is the correct outcome and it is reached by construction rather than by luck.

enum CalibrationMode {
Partial,
None,
Full,
}

esp_phy_calibration_mode_t, from the same header.

What the caller asks register_chipv7_phy to do. Full is what a rejected or missing stored calibration means; Partial is the normal path once one has been loaded.

Implements: Clone, Copy, Debug, Eq, PartialEq, StructuralPartialEq

enum Invalid {
Missing,
VersionMismatch { stored: u32, expected: u32 },
MacMismatch,
ChecksumMismatch,
Truncated,
Storage(kvstore::Error),
}

Why a stored calibration was not usable.

Every one of these means “recalibrate”, and none of them is an error in the sense of something being broken — a first boot reports Missing. They are distinguished so the reason can be logged, because “the radio recalibrated again” with no reason attached is unactionable.

Implements: Clone, Copy, Debug, Eq, PartialEq, StructuralPartialEq

References: Error

fn load<S>(store: &kvstore::Store<S>, expected_version: u32, expected_mac: &[u8; 6], out: &mut [u8; 1904]) -> Result<(), Invalid>

Load a previously stored calibration into out.

expected_version is the PHY’s current calibration-format version, which on hardware comes from the blob’s own phy_get_rf_cal_version(). expected_mac is this part’s station MAC.

The version and MAC are checked before the blob is read back, so a mismatch costs four small reads rather than 1904 bytes.

References: Invalid, Store

fn save<S>(store: &mut kvstore::Store<S>, version: u32, mac: &[u8; 6], data: &[u8; 1904]) -> Result<(), kvstore::Error>

Store a calibration, replacing whatever was there.

Order is load-bearing. The chunks go down first, then the version and MAC, and the checksum last of all. That makes the checksum the commit point: an interruption anywhere before it leaves the previous checksum on flash, which cannot describe the new mixture, so [load] rejects it and the radio recalibrates. Reordering these lines silently converts a recalibration into a radio running on half of one calibration and half of another.

References: Error, Store

fn save_or_reset<S>(store: &mut kvstore::Store<S>, version: u32, mac: &[u8; 6], data: &[u8; 1904]) -> Result<(), kvstore::Error>

[save], erasing the store and retrying once if it is full.

kvstore is append-only with no compaction, so repeated saves eventually return [KvError::Full]. Calibration is the one kind of stored data where erasing everything is a reasonable answer to that: it is regenerable — the cost is one recalibration — and a radio that stops being able to save its calibration degrades quietly, every boot, forever.

It erases the whole store, not just these keys, so anything else sharing this partition goes too. That is why it is a separate function rather than the behaviour of save: the caller has to decide it is acceptable, and today the nvs partition holds nothing else.

References: Error, Store

const CAL_DATA_LEN: usize;

sizeof(esp_phy_calibration_data_t) for the ESP32.

typedef struct {
uint8_t version[4];
uint8_t mac[6];
uint8_t opaque[1894];
} esp_phy_calibration_data_t;

The version and MAC inside the struct are the PHY’s business. The copies this module stores separately are ours, and exist so a mismatch can be found without reading 1904 bytes back first.

Verified against esp-idf tag v4.4, the revision tools/fetch-blobs.sh pins the archives to — components/esp_phy/include/esp_phy_init.h, read rather than remembered. 4 + 6 + 1894 = 1904.

Worth having checked: nothing in the fetched archives states this size. register_chipv7_phy takes a pointer and the length is not in the symbol table, so a wrong value here would be silent in the direction that matters — too small, and the PHY writes past the end of a buffer sized from it. If the pinned IDF revision ever moves, this is one of the constants to re-read rather than assume.

const CHUNKS: usize;

How many records the blob occupies.

const CHUNK_LEN: usize;

Bytes per stored chunk. The largest value kvstore takes.

const PHY_INIT_DATA_LEN: usize;

sizeof(esp_phy_init_data_t) — uint8_t params[128], same header, same tag. Recorded here because it is the other half of 3.6 and it is a pleasing accident that it fits one kvstore record exactly.