// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! The QMC5883L, a three-axis magnetometer on an I2C bus: six output bytes a driver polls, a status
//! register of three flags, a temperature word, two control registers and a chip ID.
//!
//! Written against QST's QMC5883L datasheet, version 1.0, released February 2016. Every section, table
//! and figure below is that document's.
//!
//! **Samples are derived from virtual time.** Nothing is scheduled. At each access, the samples that
//! fell due since the data rate or the mode last changed are counted from the rate the control register
//! gives. They land together, in the order the rules of 6.2.1 give, from the field as held at that
//! access. What a driver polling the status register can observe is the same. What it can't is the
//! field changing between two reads.
//!
//! **Which registers this model handles.** Every register of table 13 from `00H` to `0DH`. Nothing
//! falls to a register file, since the part has none. An address past `0DH` is not in the table. It
//! reads zero and drops a write, which the left-out table records.
//!
//! | Register or section | Behavior | Source | Test |
//! |---|---|---|---|
//! | The address | "The default I2C address is 0D: 0001101". The part acknowledges that address and no other | 5.4 | `it_answers_at_0x0d_and_nowhere_else` |
//! | Power-up | "all registers are reset by POR, then the device transits to the standby mode". The part is "ready for I2C commend" within `PORT`, 350 microseconds at most. It acknowledges nothing before that | 5.2, 5.3 table 7 | `nothing_answers_for_350_microseconds_after_power_up`, `the_registers_are_reset_at_power_up` |
//! | The write | Start with `W`, the register address byte, then the data byte, each acknowledged, then stop. Further data bytes are taken as the next registers | 8.2.3 table 11 | `a_write_lands_in_one_register_and_the_pointer_moves_on` |
//! | The read | A write start carrying the register address, a restart with the read bit, then a byte per master acknowledge, "The register address is automatically incremented and more than one byte can be sequentially read out", until the master's not-acknowledge and stop | 8.2.4 table 12 | `a_read_walks_the_registers_from_the_pointer` |
//! | The pointer | A new read starts "at the register address specified in the current I2C write command". It is kept across a stop, since the page doesn't say otherwise | 8.2.4 | `a_read_walks_the_registers_from_the_pointer` |
//! | `ROL_PNT` | "The I2C data pointer will automatically roll between 00H to 06H if I2C read begins at any position among 00H to 06H", enabled by `0AH[6]`. A read that begins elsewhere doesn't roll, and neither does one with the bit clear | 8.2.5, 9.2.4 table 19 | `the_pointer_rolls_over_between_00_and_06_when_rol_pnt_is_set` |
//! | `00H` to `05H` | The three axes, 16 bits two's complement, low byte first, in the order X, Y, Z. "The data stays the same, regardless of reading status through I2C, until new data replaces them". Read only | 9.2.1 table 14 | `the_words_are_the_field_at_the_selected_range`, `a_register_the_page_gives_no_write_to_takes_none` |
//! | The range | `RNG[1:0]` `00` is 2 G at 12000 LSB/G and `01` is 8 G at 3000 LSB/G. "The output data of each channel saturates at -32768 and 32767" | 9.2.1, 9.2.4 table 18, 2.1 table 2 | `the_words_are_the_field_at_the_selected_range` |
//! | `OVL` | Set when "any of the axis exceeds this range", the 16 bit one. "reset to 0 if next measurement goes back to the range", otherwise it "keeps as 1". Reading doesn't clear it | 9.2.2 | `ovl_sets_when_a_reading_leaves_the_range_and_clears_when_it_comes_back` |
//! | The data rate | `ODR[1:0]` `00`, `01`, `10`, `11` are 10, 50, 100 and 200 Hz. A sample lands each period of the rate, counted from the write that started continuous mode or changed the rate | 9.2.4 table 18, 6.2.1 | `continuous_mode_samples_at_each_data_rate`, `a_new_rate_restarts_the_sample_clock_and_the_same_one_does_not` |
//! | Standby | `MODE[1:0]` `00`, the default. "There is no magnetometer measurement in the Standby mode". Register values are held. "There is no any restriction in the transferring between the modes" | 6.2.2, 9.2.4, 6.1 figure 9 | `standby_samples_nothing_and_holds_what_it_has`, `a_reserved_mode_samples_nothing` |
//! | Continuous mode | `MODE[1:0]` `01`. "the magnetic sensor continuously makes measurements and places measured data in data output registers" | 6.2.1, 9.2.4 table 18 | `continuous_mode_samples_at_each_data_rate` |
//! | `DRDY` | Set when a sample lands. "reset to 0 by reading any data register (00H to 05H)". Reading the status register leaves it | 9.2.2, 6.2.1.1 figure 10 | `drdy_sets_with_a_sample_and_a_data_read_clears_it` |
//! | `DOR` | Set when a sample lands with the last one unread, and the sample "flushes" it. Cleared by "reading any data register (00H to 05H)", at the access that starts the read, and by the access of `06H` in the next data read | 9.2.2, 6.2.1.1, 6.2.1.3 figure 12 | `dor_sets_when_a_sample_lands_unread_and_a_data_read_clears_it`, `a_status_read_clears_dor_and_leaves_drdy`, `two_samples_between_accesses_set_dor` |
//! | Data protection | "if any of the six data register is accessed, data protection starts", and the registers "cannot be updated until the last bits 05H" are read. A sample landing in the period is lost, sets `DOR` and leaves `DRDY` as it was | 6.2.1.1, 6.2.1.4 figure 13 | `a_sample_landing_in_a_burst_read_is_lost_and_sets_dor`, `a_read_that_never_reaches_05h_holds_the_lock` |
//! | A read during a measurement | The registers keep the previous data and no `DRDY` is set for the measurement in progress. The model has no measurement time, so this is the lock and the case above | 6.2.1.2 figure 11 | `a_sample_landing_in_a_burst_read_is_lost_and_sets_dor` |
//! | `07H` and `08H` | Temperature, 16 bits two's complement, low byte first, "about 100 LSB/C", refreshed with each sample. Read only | 9.2.3 table 17, 2.1 table 2, 5.7 | `the_temperature_is_100_counts_a_degree_about_25_c` |
//! | `09H` | `OSR[1:0]`, `RNG[1:0]`, `ODR[1:0]`, `MODE[1:0]`, read and write, reset zero. `OSR` is kept | 9.2.4 table 18 | `the_control_registers_keep_what_is_written` |
//! | `0AH` | `SOFT_RST` `7`, `ROL_PNT` `6` and `INT_ENB` `0` are read and write. "Read only on blanks", so the other bits read zero and a write to them is dropped | 9.1 table 13, 9.2.4 table 19 | `the_control_registers_keep_what_is_written` |
//! | `SOFT_RST` | Writing `1` "restore default value of all registers", "at any time of any mode", and the part "immediately switches to standby mode". It reads back zero | 9.2.4, 6.1, 7.4 | `a_soft_reset_restores_the_defaults` |
//! | `0BH` | `SET/RESET Period FBR[7:0]`, read and write. The page gives no reset value, so it is zero. The part's own set and reset strap has no effect on a field a plant holds | 9.2.5 table 20, 5.1 | `the_control_registers_keep_what_is_written` |
//! | `0CH` | Reserved and read only. The page calls it a status register and gives no value. It reads `0x01`, the value the driver's identity check expects there | 9.1, 9.2.2 | `the_chip_id_is_0xff_and_register_0x0c_reads_0x01` |
//! | `0DH` | The chip ID, "It returns 0xFF" | 9.2.6 table 21 | `the_chip_id_is_0xff_and_register_0x0c_reads_0x01` |
//! | `0EH` and up | Not in the map. Zero, and the write is acknowledged and dropped | 9.1 table 13 | `a_register_the_page_gives_no_write_to_takes_none` |
//! | What it measures | A field in nanotesla on the chip's own axes, [`Qmc5883l::set_field`], and a temperature in hundredths of a degree, [`Qmc5883l::set_temperature`]. The part's data is already compensated, so the held field is the true one | 5.6, 3.1 | `the_words_are_the_field_at_the_selected_range` |
//! | A driver's start and polling | ArduPilot's `AP_Compass_QMC5883L`: the identity reads, the four writes, then the status poll every 10 ms and the six bytes read when bit 2 is set. Its identity check reads `00H`, which starts data protection, so the first sample is lost and the first read is of zeros | 6.2.1.1, 7.2, 9.2.2 | `ardupilot_starts_the_qmc5883l_and_reads_the_field_at_the_right_scale_and_sign`, `ardupilot_reads_every_second_sample_whatever_the_phase_of_its_poll` |
//! | Determinism | The same accesses at the same instants give the same bytes, and a chip restored from its saved state goes on as the original does | Not a datasheet behavior | `the_same_accesses_give_the_same_bytes_and_a_restored_chip_goes_on_alike` |
//!
//! # What the model leaves out, and why
//!
//! | Departure | Reason |
//! |---|---|
//! | The `DRDY` interrupt pin and `INT_ENB`'s effect | Pin 15 is "Data Ready, Interrupt Pin" and `INT_ENB` gates it. A chip on this seam has no line back to the controller or the board. `INT_ENB` is stored and the pin isn't driven. The driver polls the status register |
//! | The time a measurement takes | Figures 10 to 13 draw a measurement between samples and the page gives no duration. A sample lands at the instant its period ends, and the first one a full period after the write. The page gives no start-up delay in continuous mode, so none is added |
//! | `DOR` cleared by the status read, and by a data read only where it starts | 9.2.2 and 6.2.1.1 clear `DOR` on a read of a data register. 6.2.1.3, 6.2.1.4 and figures 12 and 13 draw it falling at the access of `06H`. They can't both be the whole rule, so the model clears it at either. A data read clears it at its first byte and not at the ones that follow, so the overrun of figure 13, which lands inside a read, stands after it. A driver that reads the status register and then the data sees the same either way |
//! | `OVL` when a field is out of range | Table 2 says the dynamic output range is 2 G or 8 G and 9.2.2 says `OVL` is "out of range" for the 16 bit word. At 3000 LSB/G the word saturates at about 10.9 G and not 8 G. The model follows 9.2.2, which is the one that defines the flag: `OVL` when a count is below -32768 or above 32767. 9.2.2 also writes the range it returns to as "(-32768, 32767)", which read as an open interval would flag both ends. The model reads it with the sentence before it, where the data "saturates at -32768 and 32767" and the flag is set when an axis "exceeds this range", so both ends are in range |
//! | `OVL` while the data is locked | A lost sample doesn't load the registers, so `OVL` keeps what the last loaded set gave |
//! | The temperature | The page gives the slope and says "its offset has not been compensated, only relative temperature value is accurate", so no count at any temperature. The model takes 25 degrees C as zero. The word is refreshed with each sample, which the page doesn't say, and it is not held by data protection, which covers "the six data register" |
//! | A reserved `MODE` code | Table 18 marks `10` and `11` "Reserve". The model stores them and samples nothing, as in standby |
//! | A reserved `RNG` code | Table 18 marks `10` and `11` "Reserve". The model stores them and decodes `RNG[0]` alone, so `11` is 8 G and `10` is 2 G |
//! | `OSR` | Stored and read back. 9.2.4 says it sets the bandwidth of an internal digital filter, which shows as in-band noise. The part's accuracy and noise are characteristics, and a plant that wants noise drives the field. Nothing in a held field depends on it |
//! | A multi-byte write | 8.2.3 shows one data byte. The model carries the pointer on, as the read does, and wraps it at `0xFF`. The page says neither |
//! | A read past `0DH` | The page doesn't say what the pointer does after the last register. The model keeps counting, 8 bits wide, and the registers it passes read zero |
//! | `0CH`'s value | The page gives none. It reads `0x01` because the driver's identity check requires it, which is the one place the model answers other than the page or zero |
//! | `0BH`'s reset value | The page gives none, and says POR resets "all registers". The model resets it to zero |
//! | `20H` and `21H` | The driver writes `0x40` to `20H` and `0x01` to `21H`. They aren't in the map. The writes are acknowledged and keep nothing, and the addresses read zero |
//! | A signal changing between two samples | A sample takes the field held at the access that finds it due, so a change inside the period shows at the next access and not at the instant the period ended. A driver reading at its own rate can't tell |
//! | Power states 2 and 3, the supply and I/O voltages | 5.2 table 6. The model has no supply |
//! | The `SETP`, `SETC` and `C1` pins | 5.1 and 4.4.3. The set and reset strap and its capacitors are the part's analog side |
//! | The I2C clock | 5.4 gives 100 kHz and 400 kHz and table 9 the timings. The seam carries bytes, not a clock |
use crate::chip::i2c::Peer;
use crate::chip::{Timeline, round, saturate};
use crate::snapshot::store;
use crate::time::VirtualNanos;
/// 5.4: the seven-bit address, `0001101`.
const I2C_ADDRESS: u8 = 0x0D;
/// 5.3 table 7: the part is ready for a command by `PORT`, 350 microseconds, from power-up.
const READY_AT: u64 = 350_000;
/// 9.1 table 13: the register map, by address.
const OUT_X_L: u8 = 0x00;
const OUT_Z_H: u8 = 0x05;
const STATUS: u8 = 0x06;
const TOUT_L: u8 = 0x07;
const TOUT_H: u8 = 0x08;
const CONTROL1: u8 = 0x09;
const CONTROL2: u8 = 0x0A;
const SET_RESET_PERIOD: u8 = 0x0B;
const RESERVED: u8 = 0x0C;
const CHIP_ID: u8 = 0x0D;
/// 8.2.5: the roll-over is between `00H` and `06H`.
const ROLL_END: u8 = STATUS;
/// 9.2.6 table 21: "It returns 0xFF".
const CHIP_ID_VALUE: u8 = 0xFF;
/// The driver's identity check expects this at `0CH`, which the page leaves without a value.
const RESERVED_VALUE: u8 = 0x01;
/// 9.2.2 table 15: the status register's bits.
const DRDY: u8 = 0x01;
const OVL: u8 = 0x02;
const DOR: u8 = 0x04;
/// 9.2.4 table 18: control register 1's fields.
const MODE: u8 = 0x03;
const CONTINUOUS: u8 = 0b01;
const ODR: u8 = 0x0C;
const ODR_SHIFT: u32 = 2;
const RNG_8G: u8 = 0x10;
/// 9.2.4 table 19: control register 2's bits.
const SOFT_RST: u8 = 0x80;
const ROL_PNT: u8 = 0x40;
const INT_ENB: u8 = 0x01;
/// 9.2.4 table 18: the sample period in nanoseconds at 10, 50, 100 and 200 Hz.
const SAMPLE_NANOS: [u64; 4] = [100_000_000, 20_000_000, 10_000_000, 5_000_000];
/// 2.1 table 2: LSB per gauss at 2 G and at 8 G.
const LSB_PER_GAUSS_2G: i64 = 12_000;
const LSB_PER_GAUSS_8G: i64 = 3_000;
const NANOTESLA_PER_GAUSS: i64 = 100_000;
/// 9.2.3: "about 100 LSB per degree", so one count is a hundredth of a degree, about a zero the page
/// doesn't give.
const TEMPERATURE_ZERO_CENTI: i32 = 2_500;
/// Where an I2C transaction is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
enum Phase {
/// Nothing is addressed to this part.
Idle,
/// The next byte written is the register address, 8.2.3.
Pointer,
/// The next bytes written are the registers' values.
Write,
/// The bytes read are the registers', 8.2.4.
Read,
}
/// One QMC5883L.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Qmc5883l {
data: [u8; 6],
temperature: [u8; 2],
status: u8,
control1: u8,
control2: u8,
set_reset_period: u8,
/// Whether a data register was read and `05H` hasn't been since, 6.2.1.1.
locked: bool,
/// The field it measures, in nanotesla on its own axes.
nanotesla: [i32; 3],
centi_celsius: i32,
/// Where the current rate began: the instant and the samples landed since.
epoch_at: VirtualNanos,
landed: u64,
pointer: u8,
/// Whether the read under way began where the roll-over applies, 8.2.5.
rolls: bool,
phase: Phase,
}
impl Default for Qmc5883l {
fn default() -> Self {
Self::new()
}
}
impl Qmc5883l {
/// A part at power-up: every register reset, in standby, measuring a plausible northern-hemisphere
/// field at 25 degrees C. It answers from 350 microseconds on the virtual clock.
#[must_use]
pub fn new() -> Self {
Self {
data: [0; 6],
temperature: [0; 2],
status: 0,
control1: 0,
control2: 0,
set_reset_period: 0,
locked: false,
nanotesla: [22_000, 0, -42_000],
centi_celsius: TEMPERATURE_ZERO_CENTI,
epoch_at: VirtualNanos(0),
landed: 0,
pointer: 0,
rolls: false,
phase: Phase::Idle,
}
}
/// The field the part measures from now on, in nanotesla on its own axes.
pub fn set_field(&mut self, x: i32, y: i32, z: i32) {
self.nanotesla = [x, y, z];
}
/// The temperature from now on, in hundredths of a degree Celsius.
pub fn set_temperature(&mut self, centi_celsius: i32) {
self.centi_celsius = centi_celsius;
}
// 9.2.4: every register at its default, in standby. The field, the temperature and the
// transaction under way aren't registers.
fn reset(&mut self, now: VirtualNanos) {
*self = Self {
nanotesla: self.nanotesla,
centi_celsius: self.centi_celsius,
epoch_at: now,
pointer: self.pointer,
phase: self.phase,
..Self::new()
};
}
// 9.2.4 table 18: the sample period, or none outside continuous mode.
fn sample_nanos(&self) -> Option<u64> {
(self.control1 & MODE == CONTINUOUS)
.then(|| SAMPLE_NANOS[usize::from((self.control1 & ODR) >> ODR_SHIFT)])
}
// 6.2.1: the samples that fell due by `now` land together, as the rules of 6.2.1.1 to 6.2.1.4 say
// each does in turn.
fn catch_up(&mut self, now: VirtualNanos) {
let Some(period) = self.sample_nanos() else {
return;
};
let due = now.0.saturating_sub(self.epoch_at.0) / period;
let new = due.saturating_sub(self.landed);
if new == 0 {
return;
}
self.landed = due;
self.temperature =
word(i64::from(self.centi_celsius) - i64::from(TEMPERATURE_ZERO_CENTI)).0;
if self.locked {
self.status |= DOR;
return;
}
let unread = self.status & DRDY != 0 || new > 1;
self.load();
self.status |= DRDY;
if unread {
self.status |= DOR;
}
}
// 9.2.1 and 9.2.2: the held field in counts at the range, saturated, with `OVL` set if any axis
// saturated and cleared if none did.
fn load(&mut self) {
let scale = if self.control1 & RNG_8G != 0 {
LSB_PER_GAUSS_8G
} else {
LSB_PER_GAUSS_2G
};
let mut overflow = false;
for (axis, &nanotesla) in self.nanotesla.iter().enumerate() {
let (bytes, saturated) = word(round(i64::from(nanotesla) * scale, NANOTESLA_PER_GAUSS));
overflow |= saturated;
self.data[2 * axis..2 * axis + 2].copy_from_slice(&bytes);
}
if overflow {
self.status |= OVL;
} else {
self.status &= !OVL;
}
}
// 9.1 table 13: a read of a register, with what the read does.
fn read_register(&mut self, register: u8) -> u8 {
match register {
OUT_X_L..=OUT_Z_H => {
// 6.2.1.1: the access that starts the protection clears `DRDY` and `DOR`, and `05H` ends
// it. Figure 13 draws an overrun that lands in the middle of a read standing after it, so
// the bytes that follow the first clear nothing.
if !self.locked {
self.status &= !(DRDY | DOR);
}
self.locked = register != OUT_Z_H;
self.data[usize::from(register)]
}
STATUS => {
// 6.2.1.3 and figures 12 and 13: the access of `06H` is where `DOR` falls.
let status = self.status;
self.status &= !DOR;
status
}
TOUT_L => self.temperature[0],
TOUT_H => self.temperature[1],
CONTROL1 => self.control1,
CONTROL2 => self.control2,
SET_RESET_PERIOD => self.set_reset_period,
RESERVED => RESERVED_VALUE,
CHIP_ID => CHIP_ID_VALUE,
_ => 0,
}
}
// 9.1 table 13: a write of a register, where the access is read and write.
fn write_register(&mut self, register: u8, byte: u8, now: VirtualNanos) {
match register {
CONTROL1 => {
let before = self.sample_nanos();
self.control1 = byte;
if self.sample_nanos() != before {
self.epoch_at = now;
self.landed = 0;
}
}
CONTROL2 if byte & SOFT_RST != 0 => self.reset(now),
CONTROL2 => self.control2 = byte & (ROL_PNT | INT_ENB),
SET_RESET_PERIOD => self.set_reset_period = byte,
_ => {}
}
}
// 8.2.4 and 8.2.5: the register after `register`, rolling if the read began where it applies.
fn next(&self, register: u8) -> u8 {
if self.rolls && register == ROLL_END {
OUT_X_L
} else {
register.wrapping_add(1)
}
}
}
// 9.2.1: a sixteen bit two's complement word, low byte first, held to its range, and whether it was.
fn word(counts: i64) -> ([u8; 2], bool) {
let held = saturate(counts);
(held.to_le_bytes(), i64::from(held) != counts)
}
impl Peer for Qmc5883l {
// 5.4 and 5.3: acknowledges its own address, once power-up has let it.
fn start(&mut self, address: u8, read: bool, context: &mut Timeline<'_, '_>) -> bool {
let now = context.now();
self.catch_up(now);
if address != I2C_ADDRESS || now.0 < READY_AT {
self.phase = Phase::Idle;
return false;
}
self.phase = if read { Phase::Read } else { Phase::Pointer };
if read {
self.rolls = self.control2 & ROL_PNT != 0 && self.pointer <= ROLL_END;
}
true
}
// 8.2.3: the register address, then each data byte, each acknowledged.
fn write(&mut self, byte: u8, context: &mut Timeline<'_, '_>) -> bool {
let now = context.now();
self.catch_up(now);
match self.phase {
Phase::Pointer => {
self.pointer = byte;
self.phase = Phase::Write;
}
Phase::Write => {
self.write_register(self.pointer, byte, now);
self.pointer = self.pointer.wrapping_add(1);
}
Phase::Read | Phase::Idle => return false,
}
true
}
// 8.2.4: a byte per master acknowledge, the pointer moving on.
fn read(&mut self, _last: bool, context: &mut Timeline<'_, '_>) -> u8 {
self.catch_up(context.now());
if self.phase != Phase::Read {
return 0;
}
let register = self.pointer;
self.pointer = self.next(register);
self.read_register(register)
}
fn stop(&mut self, _context: &mut Timeline<'_, '_>) {
self.phase = Phase::Idle;
}
}
store!(Qmc5883l);