mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-14 00:23:34 -07:00
Add cell/signal info to system stats panel
Display LTE signal measurements (RSRP, RSRQ, RSSI, PCI, EARFCN) from DIAG ML1 Serving Cell Measurement messages in the web UI. - Add CellInfo struct with RwLock cache in gsmtap_parser - Add CellSignalInfo to SystemStats API response - Add Cell Signal row to SystemStatsTable with quality indicator - Support Orbic, Tplink, Tmobile, Wingtech devices (graceful degradation for others)
This commit is contained in:
committed by
Cooper Quintin
parent
ed2781a4be
commit
728fbe0f4d
+216
-1
@@ -222,6 +222,11 @@ pub enum LogBody {
|
||||
#[deku(count = "hdr_len")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
/// LTE ML1 Serving Cell Measurement Response (0xB193)
|
||||
/// Contains RSRP, RSRQ, and RSSI measurements for the serving cell.
|
||||
/// This is used to populate signal strength in GSMTAP headers.
|
||||
#[deku(id = "0xb193")]
|
||||
LteMl1ServingCellMeas { meas: LteMl1ServingCellMeasData },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
@@ -344,6 +349,132 @@ impl LteRrcOtaPacket {
|
||||
}
|
||||
}
|
||||
|
||||
/// LTE ML1 Serving Cell Measurement (0xB193) packet structure.
|
||||
/// Uses subpacket architecture per Mobile Insight / Qualcomm DIAG format.
|
||||
///
|
||||
/// Packet layout:
|
||||
/// - Main Header: version (1) + num_subpackets (1) + reserved (2) = 4 bytes
|
||||
/// - SubPacket Header: id (1) + version (1) + size (2) = 4 bytes
|
||||
/// - SubPacket Data: varies by subpacket version (v4, v7, v18, v19, v22, v24, v35, v36, v40)
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(endian = "little")]
|
||||
pub struct LteMl1ServingCellMeasData {
|
||||
pub main_version: u8,
|
||||
pub num_subpackets: u8,
|
||||
pub reserved: u16,
|
||||
// SubPacket header
|
||||
pub subpacket_id: u8,
|
||||
pub subpacket_version: u8,
|
||||
pub subpacket_size: u16,
|
||||
// SubPacket data - we read enough to get RSRP/RSRQ/RSSI
|
||||
// The actual layout depends on subpacket_version, but EARFCN and PCI are always first
|
||||
#[deku(count = "subpacket_size.saturating_sub(4).min(128)")]
|
||||
pub subpacket_data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LteMl1ServingCellMeasData {
|
||||
/// Helper to read a u16 from subpacket data at given offset
|
||||
fn read_u16(&self, offset: usize) -> Option<u16> {
|
||||
if offset + 2 <= self.subpacket_data.len() {
|
||||
Some(u16::from_le_bytes([
|
||||
self.subpacket_data[offset],
|
||||
self.subpacket_data[offset + 1],
|
||||
]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to read a u32 from subpacket data at given offset
|
||||
fn read_u32(&self, offset: usize) -> Option<u32> {
|
||||
if offset + 4 <= self.subpacket_data.len() {
|
||||
Some(u32::from_le_bytes([
|
||||
self.subpacket_data[offset],
|
||||
self.subpacket_data[offset + 1],
|
||||
self.subpacket_data[offset + 2],
|
||||
self.subpacket_data[offset + 3],
|
||||
]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the RSRP field offset based on subpacket version
|
||||
/// Returns (earfcn_offset, earfcn_size, rsrp_offset)
|
||||
fn get_offsets(&self) -> (usize, usize, usize) {
|
||||
match self.subpacket_version {
|
||||
// v4: EARFCN(2) + PCI(2) + SFN(2) + skip(6) = offset 12 for RSRP
|
||||
4 => (0, 2, 12),
|
||||
// v7: EARFCN(4) + PCI(2) + SFN(2) + skip(6) = offset 14 for RSRP
|
||||
7 => (0, 4, 14),
|
||||
// v18+: more complex, estimate based on structure
|
||||
// EARFCN(4) + PCI(2) + ... + skip = ~24-34 for RSRP
|
||||
18..=24 => (0, 4, 24),
|
||||
// v35+: 4-antenna support, larger structure
|
||||
35..=40 => (0, 4, 28),
|
||||
// Unknown version, try v7 offsets
|
||||
_ => (0, 4, 14),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Physical Cell ID from measurement
|
||||
pub fn get_pci(&self) -> Option<u16> {
|
||||
let (earfcn_offset, earfcn_size, _) = self.get_offsets();
|
||||
let pci_offset = earfcn_offset + earfcn_size;
|
||||
self.read_u16(pci_offset).map(|v| v & 0x1FF)
|
||||
}
|
||||
|
||||
/// Get EARFCN from measurement
|
||||
pub fn get_earfcn(&self) -> Option<u32> {
|
||||
let (earfcn_offset, earfcn_size, _) = self.get_offsets();
|
||||
if earfcn_size == 2 {
|
||||
self.read_u16(earfcn_offset).map(|v| v as u32)
|
||||
} else {
|
||||
self.read_u32(earfcn_offset)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get RSRP (Reference Signal Received Power) in dBm.
|
||||
/// Formula: -180 + raw_value * 0.0625
|
||||
pub fn get_rsrp_dbm(&self) -> Option<f32> {
|
||||
let (_, _, rsrp_offset) = self.get_offsets();
|
||||
self.read_u32(rsrp_offset).map(|raw| {
|
||||
let rsrp_raw = raw & 0xFFF;
|
||||
-180.0 + (rsrp_raw as f32) * 0.0625
|
||||
})
|
||||
}
|
||||
|
||||
/// Get RSSI (Received Signal Strength Indicator) in dBm.
|
||||
/// Formula: -110 + raw_value * 0.0625
|
||||
/// RSSI is typically 12 bytes after RSRP (RSRP + avg_RSRP + RSRQ)
|
||||
pub fn get_rssi_dbm(&self) -> Option<f32> {
|
||||
let (_, _, rsrp_offset) = self.get_offsets();
|
||||
let rssi_offset = rsrp_offset + 12; // Skip RSRP(4) + avg_RSRP(4) + RSRQ(4)
|
||||
self.read_u32(rssi_offset).map(|raw| {
|
||||
let rssi_raw = (raw >> 10) & 0x7FF;
|
||||
-110.0 + (rssi_raw as f32) * 0.0625
|
||||
})
|
||||
}
|
||||
|
||||
/// Get RSRQ (Reference Signal Received Quality) in dB.
|
||||
/// Formula: -30 + raw_value * 0.0625
|
||||
pub fn get_rsrq_db(&self) -> Option<f32> {
|
||||
let (_, _, rsrp_offset) = self.get_offsets();
|
||||
let rsrq_offset = rsrp_offset + 8; // Skip RSRP(4) + avg_RSRP(4)
|
||||
self.read_u32(rsrq_offset).map(|raw| {
|
||||
let rsrq_raw = raw & 0x3FF;
|
||||
-30.0 + (rsrq_raw as f32) * 0.0625
|
||||
})
|
||||
}
|
||||
|
||||
/// Get signal strength as i8 for GSMTAP header (clamped to valid range).
|
||||
/// Uses RSRP as the primary signal indicator.
|
||||
pub fn get_signal_dbm_i8(&self) -> Option<i8> {
|
||||
self.get_rsrp_dbm()
|
||||
.map(|rsrp| rsrp.clamp(-128.0, 127.0) as i8)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(endian = "little")]
|
||||
pub struct Timestamp {
|
||||
@@ -441,6 +572,10 @@ mod test {
|
||||
bitsize,
|
||||
&crate::diag_device::LOG_CODES_FOR_RAW_PACKET_LOGGING,
|
||||
);
|
||||
// Expected mask includes:
|
||||
// - 0xB0C0 (LTE RRC): byte 24 = 0x01
|
||||
// - 0xB0E2, 0xB0E3, 0xB0EC, 0xB0ED (NAS): bytes 28-29 = 0x0C, 0x30
|
||||
// - 0xB193 (ML1 Serving Cell Meas): byte 50 = 0x08 (bit 3 for code 0x193 = 403)
|
||||
assert_eq!(
|
||||
req,
|
||||
Request::LogConfig(LogConfigRequest::SetMask {
|
||||
@@ -450,7 +585,7 @@ mod test {
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0xc, 0x30, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x8, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0,
|
||||
],
|
||||
})
|
||||
@@ -697,4 +832,84 @@ mod test {
|
||||
// Verify we consumed the expected number of bytes
|
||||
assert_eq!(rest.len(), 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lte_ml1_serving_cell_meas_parsing() {
|
||||
// Test parsing of 0xB193 LTE ML1 Serving Cell Measurement log
|
||||
// with subpacket version 18 (common on Orbic RC400L)
|
||||
//
|
||||
// Structure:
|
||||
// - Log message header (type=16, log_type=0xB193)
|
||||
// - LteMl1ServingCellMeasData with v18 subpacket containing RSRP=-95dBm
|
||||
//
|
||||
// RSRP calculation: -180 + (raw & 0xFFF) * 0.0625
|
||||
// For -95 dBm: raw = (-95 + 180) / 0.0625 = 1360 = 0x550
|
||||
|
||||
let mut msg_bytes: Vec<u8> = vec![
|
||||
// Log message header
|
||||
0x10, // Message type: Log (16)
|
||||
0x00, // pending_msgs
|
||||
0x38, 0x00, // outer_length: 56
|
||||
0x34, 0x00, // inner_length: 52
|
||||
0x93, 0xB1, // log_type: 0xB193 (LTE ML1 Serving Cell Meas)
|
||||
// timestamp (8 bytes, arbitrary)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
// LteMl1ServingCellMeasData
|
||||
0x01, // main_version
|
||||
0x01, // num_subpackets
|
||||
0x00, 0x00, // reserved
|
||||
0x00, // subpacket_id
|
||||
0x12, // subpacket_version: 18
|
||||
0x28, 0x00, // subpacket_size: 40 bytes (including header)
|
||||
];
|
||||
|
||||
// Subpacket data (36 bytes = 40 - 4 for header)
|
||||
// For v18: EARFCN at offset 0 (4 bytes), PCI at offset 4 (2 bytes), RSRP at offset 24
|
||||
let mut subpacket_data = vec![0u8; 36];
|
||||
// EARFCN = 975 at offset 0 (u32 LE)
|
||||
subpacket_data[0..4].copy_from_slice(&975u32.to_le_bytes());
|
||||
// PCI = 446 at offset 4 (u16 LE, only lower 9 bits used)
|
||||
subpacket_data[4..6].copy_from_slice(&446u16.to_le_bytes());
|
||||
// RSRP raw = 1360 (0x550) at offset 24 (u32 LE)
|
||||
// This gives RSRP = -180 + 1360 * 0.0625 = -95 dBm
|
||||
subpacket_data[24..28].copy_from_slice(&1360u32.to_le_bytes());
|
||||
|
||||
msg_bytes.extend(subpacket_data);
|
||||
|
||||
let ((rest, _), msg) = Message::from_bytes((&msg_bytes, 0)).unwrap();
|
||||
|
||||
assert_eq!(rest.len(), 0, "Should consume all bytes");
|
||||
|
||||
if let Message::Log {
|
||||
log_type,
|
||||
body: LogBody::LteMl1ServingCellMeas { meas },
|
||||
..
|
||||
} = msg
|
||||
{
|
||||
assert_eq!(log_type, 0xB193);
|
||||
assert_eq!(meas.subpacket_version, 18);
|
||||
|
||||
// Verify RSRP extraction
|
||||
let rsrp = meas.get_rsrp_dbm().expect("Should extract RSRP");
|
||||
assert!(
|
||||
(rsrp - (-95.0)).abs() < 0.1,
|
||||
"RSRP should be -95 dBm, got {}",
|
||||
rsrp
|
||||
);
|
||||
|
||||
// Verify PCI extraction
|
||||
let pci = meas.get_pci().expect("Should extract PCI");
|
||||
assert_eq!(pci, 446);
|
||||
|
||||
// Verify EARFCN extraction
|
||||
let earfcn = meas.get_earfcn().expect("Should extract EARFCN");
|
||||
assert_eq!(earfcn, 975);
|
||||
|
||||
// Verify i8 conversion for GSMTAP header
|
||||
let signal_dbm = meas.get_signal_dbm_i8().expect("Should get signal_dbm");
|
||||
assert_eq!(signal_dbm, -95);
|
||||
} else {
|
||||
panic!("Expected LteMl1ServingCellMeas message, got {:?}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ pub enum DiagDeviceError {
|
||||
ParseMessagesContainerError(deku::DekuError),
|
||||
}
|
||||
|
||||
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
|
||||
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 12] = [
|
||||
// Layer 2:
|
||||
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
|
||||
// Layer 3:
|
||||
@@ -56,6 +56,8 @@ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
|
||||
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
|
||||
// User IP traffic:
|
||||
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
|
||||
// Signal strength measurements:
|
||||
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE, // 0xb193
|
||||
];
|
||||
|
||||
const BUFFER_LEN: usize = 1024 * 1024 * 10;
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
use crate::diag::*;
|
||||
use crate::gsmtap::*;
|
||||
|
||||
use log::error;
|
||||
use log::{debug, error};
|
||||
use serde::Serialize;
|
||||
use std::sync::RwLock;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Cached LTE cell information from ML1 measurements.
|
||||
/// Contains signal strength and cell identity information.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct CellInfo {
|
||||
/// Reference Signal Received Power in dBm (typical range: -140 to -44)
|
||||
pub rsrp_dbm: Option<f32>,
|
||||
/// Reference Signal Received Quality in dB (typical range: -20 to -3)
|
||||
pub rsrq_db: Option<f32>,
|
||||
/// Received Signal Strength Indicator in dBm
|
||||
pub rssi_dbm: Option<f32>,
|
||||
/// Physical Cell ID (0-503)
|
||||
pub pci: Option<u16>,
|
||||
/// E-UTRA Absolute Radio Frequency Channel Number
|
||||
pub earfcn: Option<u32>,
|
||||
}
|
||||
|
||||
/// Global cache for the most recent cell/signal measurement.
|
||||
/// This is populated by LteMl1ServingCellMeas messages and can be used
|
||||
/// to add signal strength to GSMTAP headers and display in the UI.
|
||||
///
|
||||
/// Uses RwLock for consistent multi-field updates. Reads >> writes so this is efficient.
|
||||
static CACHED_CELL_INFO: RwLock<CellInfo> = RwLock::new(CellInfo {
|
||||
rsrp_dbm: None,
|
||||
rsrq_db: None,
|
||||
rssi_dbm: None,
|
||||
pci: None,
|
||||
earfcn: None,
|
||||
});
|
||||
|
||||
/// Get the cached cell information.
|
||||
/// Returns a clone of the current cell info state.
|
||||
pub fn get_cached_cell_info() -> CellInfo {
|
||||
CACHED_CELL_INFO
|
||||
.read()
|
||||
.expect("cell info lock poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Get the cached signal strength (RSRP) in dBm as i8 for GSMTAP header compatibility.
|
||||
/// Returns 0 if no measurement has been received yet.
|
||||
pub fn get_cached_signal_dbm() -> i8 {
|
||||
CACHED_CELL_INFO
|
||||
.read()
|
||||
.expect("cell info lock poisoned")
|
||||
.rsrp_dbm
|
||||
.map(|rsrp| rsrp.clamp(-128.0, 127.0) as i8)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Update the cached cell info from a measurement.
|
||||
fn update_cell_info_cache(meas: &LteMl1ServingCellMeasData) {
|
||||
let mut cache = CACHED_CELL_INFO.write().expect("cell info lock poisoned");
|
||||
cache.rsrp_dbm = meas.get_rsrp_dbm();
|
||||
cache.rsrq_db = meas.get_rsrq_db();
|
||||
cache.rssi_dbm = meas.get_rssi_dbm();
|
||||
cache.pci = meas.get_pci();
|
||||
cache.earfcn = meas.get_earfcn();
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GsmtapParserError {
|
||||
#[error("Invalid LteRrcOtaMessage ext header version {0}")]
|
||||
@@ -138,6 +199,8 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
|
||||
header.arfcn = packet.get_earfcn().try_into().unwrap_or(0);
|
||||
header.frame_number = packet.get_sfn();
|
||||
header.subslot = packet.get_subfn();
|
||||
// Apply cached signal strength from ML1 measurements
|
||||
header.signal_dbm = get_cached_signal_dbm();
|
||||
Ok(Some(GsmtapMessage {
|
||||
header,
|
||||
payload: packet.take_payload(),
|
||||
@@ -152,6 +215,22 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
|
||||
payload: msg,
|
||||
}))
|
||||
}
|
||||
LogBody::LteMl1ServingCellMeas { meas, .. } => {
|
||||
// Update the cell info cache with measurement data
|
||||
update_cell_info_cache(&meas);
|
||||
debug!(
|
||||
"ML1 0xB193 v{}: RSRP={:?}dBm, RSRQ={:?}dB, RSSI={:?}dBm, PCI={:?}, EARFCN={:?}",
|
||||
meas.subpacket_version,
|
||||
meas.get_rsrp_dbm(),
|
||||
meas.get_rsrq_db(),
|
||||
meas.get_rssi_dbm(),
|
||||
meas.get_pci(),
|
||||
meas.get_earfcn()
|
||||
);
|
||||
// Measurement messages don't produce GSMTAP output themselves,
|
||||
// they just update the cell info cache for subsequent messages.
|
||||
Ok(None)
|
||||
}
|
||||
_ => {
|
||||
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
||||
Ok(None)
|
||||
|
||||
@@ -31,6 +31,9 @@ pub const LOG_NR_RRC_OTA_MSG_LOG_C: u32 = 0xb821;
|
||||
// These are 4G-related log types.
|
||||
|
||||
pub const LOG_LTE_RRC_OTA_MSG_LOG_C: u32 = 0xb0c0;
|
||||
|
||||
// LTE ML1 (Modem Layer 1) measurement logs - contain signal strength data
|
||||
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE: u32 = 0xb193;
|
||||
pub const LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C: u32 = 0xb0e2;
|
||||
pub const LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C: u32 = 0xb0e3;
|
||||
pub const LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C: u32 = 0xb0ec;
|
||||
|
||||
Reference in New Issue
Block a user