From a7185fc570991df3f621cf83997d1532ebcb6caf Mon Sep 17 00:00:00 2001 From: Bran Date: Thu, 15 Oct 2026 18:39:16 -0700 Subject: [PATCH 4/5] Parse the EP06's 0xB114 ServingCellTiming layout The EP06 emits version 101 of this log, where the existing struct asserts version 1, so every occurrence was discarded. Version 101 is a different shape: a 48-byte header, 44-byte 32-bit aligned records, and a final record cut short by the message boundary. There is no record count in the header, so the array reads to the end of the buffer. Derived from 205 messages and 2647 records captured on a live network. Every payload fit the 48/44/8 split. Word 0 of each record steps by exactly 30720, one LTE subframe in Ts; words 5, 6, 8 and 10 were always zero; words 7 and 9 each hold one u16 duplicated into both halves. The remaining fields are exposed as raw words rather than named, because naming them needs a connected device to compare against a real timing advance and the capture came from a modem in limited service. --- lib/src/diag/diaglog/ll1.rs | 210 +++++++++++++++++++++++++++++++++++- 1 file changed, 207 insertions(+), 3 deletions(-) diff --git a/lib/src/diag/diaglog/ll1.rs b/lib/src/diag/diaglog/ll1.rs index b2931a7..375d852 100644 --- a/lib/src/diag/diaglog/ll1.rs +++ b/lib/src/diag/diaglog/ll1.rs @@ -1,10 +1,47 @@ use deku::prelude::*; +/// LTE LL1 Serving Cell Timing, log code 0xB114. +/// +/// Two layouts are known, distinguished by the leading version byte. +/// +/// Version 1 is the bit-packed structure emitted by the devices in the +/// supported list. +/// +/// Version 101 is emitted by the Quectel EP06 (MDM9640, firmware +/// EP06ALAR02A03M4G) and is a different shape entirely: a 48-byte header, +/// then 32-bit-aligned 44-byte records, then a final record cut short by +/// the message boundary. There is no record count anywhere in the header, +/// so the array has to be read to the end of the buffer. +/// +/// The version 101 layout was derived empirically from 205 messages and +/// 2647 records captured on a live network. Every payload fit the +/// 48/44/8 split with no remainder. What is established: +/// +/// * word 0 of each record steps by exactly 30720 between consecutive +/// records, which is one LTE subframe in Ts units, and repeats when +/// no new measurement was taken +/// * words 5, 6, 8 and 10 were zero in all 2647 records +/// * words 7 and 9 each hold one u16 duplicated into both halves +/// * words 3 and 4 are a matched pair with the same range +/// * word 1 is signed and small, roughly -18432 to 16385 +/// +/// What is not established is which field is which. Naming them needs a +/// device in connected mode so the values can be compared against the +/// timing advance the network assigns, and the capture above came from a +/// modem in limited service with no SIM. Rather than guess, the records +/// are exposed as raw words with accessors for the parts that are known. +#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)] +#[deku(id_type = "u8")] +pub enum ServingCellTiming { + #[deku(id = 1)] + V1(ServingCellTimingV1), + #[deku(id = 101)] + V101(ServingCellTimingV101), +} + #[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)] #[deku(bit_order = "lsb")] -pub struct ServingCellTiming { - #[deku(assert_eq = "1")] - pub version: u8, +pub struct ServingCellTimingV1 { #[deku(bits = 5, assert = "(1..=20).contains(num_records)")] pub num_records: u8, #[deku(bits = 4, assert = "*starting_sub_fn <= 9")] @@ -42,3 +79,170 @@ pub struct TimingAdjustment { #[deku(assert = "(-128..=127).contains(timing_advance)")] pub timing_advance: i8, // in 16 Ts units } + +/// Bytes in one version 101 record. +pub const V101_RECORD_LEN: usize = 44; + +/// u32 words in one version 101 record. +pub const V101_RECORD_WORDS: usize = V101_RECORD_LEN / 4; + +/// Bytes of version 101 header following the version byte. +const V101_HEADER_REST: usize = 47; + +#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)] +pub struct ServingCellTimingV101 { + /// The rest of the 48-byte header. Not interpreted: of its twelve + /// u32 words, five were constant zero across every message sampled + /// and none tracked the record count. + #[deku(count = "V101_HEADER_REST")] + pub header: Vec, + + /// The record array, kept raw. Use [`records`] for the whole ones and + /// [`truncated_tail`] for the partial record the message ends on. + /// + /// [`records`]: ServingCellTimingV101::records + /// [`truncated_tail`]: ServingCellTimingV101::truncated_tail + #[deku(read_all)] + pub body: Vec, +} + +/// One 44-byte version 101 record, as eleven little-endian words. +/// +/// Stored as raw words deliberately. Only word 0 has a verified meaning, +/// and inventing names for the others would produce numbers that look +/// reasonable and are not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimingRecordV101 { + pub words: [u32; V101_RECORD_WORDS], +} + +impl TimingRecordV101 { + /// System time in Ts units (1 / 30.72 MHz). + /// + /// Verified: steps by exactly 30720 between consecutive records, one + /// subframe, and repeats unchanged when the radio produced no new + /// measurement for that subframe. + pub fn subframe_time_ts(&self) -> u32 { + self.words[0] + } + + /// Word 1 read as signed. Observed range about -18432 to 16385 over + /// 2647 records, which is the right shape for a timing correction. + /// + /// Unverified. Confirming it requires a connected device so this can + /// be compared against the network-assigned timing advance. + pub fn signed_adjustment(&self) -> i32 { + self.words[1] as i32 + } + + /// Words 3 and 4, a matched pair that held equal values in every + /// sample. In the version 1 layout the equivalent pair is the + /// downlink and uplink frame timing offsets, but which is which here + /// has not been established. + pub fn offset_pair(&self) -> (u32, u32) { + (self.words[3], self.words[4]) + } + + /// Words 7 and 9 each store one u16 duplicated into both halves. + /// Returns the single value from each, or `None` if the halves differ, + /// which did not happen in any sample and would mean the assumption + /// is wrong for this firmware. + pub fn duplicated_u16s(&self) -> (Option, Option) { + fn halves(w: u32) -> Option { + let lo = w as u16; + let hi = (w >> 16) as u16; + (lo == hi).then_some(lo) + } + (halves(self.words[7]), halves(self.words[9])) + } + + /// True if any word that was zero in every sampled record is now + /// non-zero, which would mean this firmware uses it for something and + /// the layout notes above need revisiting. + pub fn has_unexpected_data(&self) -> bool { + [5usize, 6, 8, 10].iter().any(|&i| self.words[i] != 0) + } +} + +impl ServingCellTimingV101 { + /// The whole records in this message, oldest first. + pub fn records(&self) -> impl Iterator + '_ { + self.body + .chunks_exact(V101_RECORD_LEN) + .map(|chunk| { + let mut words = [0u32; V101_RECORD_WORDS]; + for (i, word) in words.iter_mut().enumerate() { + let b = &chunk[i * 4..i * 4 + 4]; + *word = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); + } + TimingRecordV101 { words } + }) + } + + /// The partial record the message ends on, if any. Every message + /// sampled ended with 8 such bytes, whose first word repeated the + /// last whole record's subframe time. + pub fn truncated_tail(&self) -> &[u8] { + let rem = self.body.len() % V101_RECORD_LEN; + &self.body[self.body.len() - rem..] + } + + /// Number of whole records. + pub fn record_count(&self) -> usize { + self.body.len() / V101_RECORD_LEN + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real 0xB114 payload from a Quectel EP06 on a live Verizon cell, + /// truncated after the first two records for brevity. Full captures + /// ran to 892 bytes and nineteen records. + const EP06_SAMPLE: &[u8] = &[ + 0x65, 0xce, 0x00, 0x04, 0x61, 0x29, 0x02, 0x00, 0x61, 0x29, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // record 0 + 0x61, 0x61, 0xf4, 0x32, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0x07, 0x02, 0x00, 0xff, 0x07, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xfd, 0x21, 0xfd, 0x21, 0x00, 0x00, 0x00, 0x00, + 0x1d, 0x08, 0x1d, 0x08, 0x00, 0x00, 0x00, 0x00, + // record 1 + 0x61, 0xd9, 0xf4, 0x32, 0x03, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xe7, 0x03, 0x00, 0xff, 0xe7, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xfd, 0x59, 0xfd, 0x59, 0x00, 0x00, 0x00, 0x00, + 0x9d, 0x0f, 0x9d, 0x0f, 0x00, 0x00, 0x00, 0x00, + // truncated final record + 0x61, 0x51, 0xf5, 0x32, 0x00, 0x40, 0x00, 0x00, + ]; + + #[test] + fn parses_ep06_version_101() { + let (_, parsed) = ServingCellTiming::from_bytes((EP06_SAMPLE, 0)).unwrap(); + let ServingCellTiming::V101(v) = parsed else { + panic!("expected the version 101 layout"); + }; + assert_eq!(v.record_count(), 2); + assert_eq!(v.truncated_tail().len(), 8); + } + + #[test] + fn subframe_time_steps_by_one_subframe() { + let (_, parsed) = ServingCellTiming::from_bytes((EP06_SAMPLE, 0)).unwrap(); + let ServingCellTiming::V101(v) = parsed else { unreachable!() }; + let times: Vec = v.records().map(|r| r.subframe_time_ts()).collect(); + assert_eq!(times[1] - times[0], 30720); + } + + #[test] + fn paired_words_hold_one_duplicated_u16() { + let (_, parsed) = ServingCellTiming::from_bytes((EP06_SAMPLE, 0)).unwrap(); + let ServingCellTiming::V101(v) = parsed else { unreachable!() }; + let first = v.records().next().unwrap(); + assert_eq!(first.duplicated_u16s(), (Some(0x21fd), Some(0x081d))); + assert!(!first.has_unexpected_data()); + } +} -- 2.47.3