Klaudiusz ugotował

This commit is contained in:
2026-07-05 17:40:45 +02:00
parent d78926f440
commit ca83d1e549
10 changed files with 2312 additions and 144 deletions
+7 -4
View File
@@ -1,11 +1,14 @@
[build] [build]
target = "thumbv6m-none-eabi" target = "thumbv8m.main-none-eabihf"
# target = "thumbv6m-none-eabi"
[env] [env]
DEFMT_LOG = "trace" DEFMT_LOG = "debug"
[target.thumbv6m-none-eabi] # [target.thumbv6m-none-eabi]
runner = 'probe-rs run --chip STM32G0B1RE' [target.thumbv8m.main-none-eabihf]
# runner = 'probe-rs run --chip STM32G0B1RE'
runner = 'probe-rs run --chip STM32U585CI'
rustflags = [ rustflags = [
"-C", "link-arg=--nmagic", "-C", "link-arg=--nmagic",
"-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tlink.x",
+2 -1
View File
@@ -21,7 +21,8 @@ embassy-stm32 = {
"rt", "rt",
"defmt", "defmt",
"memory-x", "memory-x",
"stm32g0b1re", # "stm32g0b1re",
"stm32u585ci",
"time-driver-any", "time-driver-any",
"exti", "exti",
"unstable-pac", "unstable-pac",
+30 -5
View File
@@ -1,27 +1,52 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Render a sensor dump (one bracketed row of numbers per line) as a heatmap. """Render a sensor dump as a heatmap.
Usage: Usage:
python heatmap.py [input.txt] [-o out.png] [--cmap inferno] python heatmap.py [input.txt] [-o out.png] [--cmap inferno]
Each line is expected to look like: Accepts either:
- Raw device log lines as printed by the firmware, e.g.:
5.464019 [INFO ] row 0: [71.6, 75.2, ..., 82.1] (tts_test tts-test/src/main.rs:212)
Only the "row N: [...]" part of each line is used; everything else
(timestamps, log level, source location, other info! lines like the
ambient temperature/heatmap range) is ignored. You can paste the whole
console log as-is.
- Plain rows of numbers, one row per line, brackets/commas optional:
[55, 192, 131, ..., 138] [55, 192, 131, ..., 138]
Brackets/commas are optional, so plain whitespace-separated rows also work.
""" """
import argparse import argparse
import re import re
import sys import sys
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
_ROW_RE = re.compile(r"row\s*(\d+)\s*:\s*\[([^\]]*)\]", re.IGNORECASE)
def parse_grid(text): def parse_grid(text):
"""Parse lines of numbers into a 2D list of floats.""" """Parse a device log or a plain number dump into a 2D list of floats."""
text = _ANSI_RE.sub("", text)
# Prefer explicit "row N: [...]" entries (as printed by the firmware's
# info! logs) so that timestamps, log levels and source locations on the
# same line don't get mistaken for pixel data.
rows = {}
for match in _ROW_RE.finditer(text):
idx = int(match.group(1))
nums = re.findall(r"-?\d+(?:\.\d+)?", match.group(2))
if nums:
rows[idx] = [float(n) for n in nums]
if rows:
grid = [rows[i] for i in sorted(rows)]
else:
# fall back: one row of numbers per line
grid = [] grid = []
for line in text.splitlines(): for line in text.splitlines():
# strip brackets and split on commas/whitespace
nums = re.findall(r"-?\d+(?:\.\d+)?", line) nums = re.findall(r"-?\d+(?:\.\d+)?", line)
if nums: if nums:
grid.append([float(n) for n in nums]) grid.append([float(n) for n in nums])
if not grid: if not grid:
raise ValueError("No numeric data found in input") raise ValueError("No numeric data found in input")
width = len(grid[0]) width = len(grid[0])
+172
View File
@@ -0,0 +1,172 @@
//! Reads and stores every calibration constant the sensor needs: both the
//! trim-register values used to reproduce the factory calibration, and the
//! per-pixel compensation data used to turn raw ADC counts into a real
//! temperature (datasheet section 12).
use crate::eeprom::{read_f32, read_word};
use crate::Bus;
/// The sensor's pixel array is 16x16.
pub const GRID: usize = 16;
/// A full 16x16 per-pixel calibration grid (`ThGrad`, `ThOffset`, `Pij`).
pub type Grid16<T> = [[T; GRID]; GRID];
/// A per-row-profile calibration grid used for electrical offset and VDD
/// compensation. Only 8 row profiles are calibrated; each is reused for two
/// of the array's 16 physical rows, see [`crate::thermal::row_profile`].
pub type Grid8 = [[i16; GRID]; 8];
/// All calibration data read from the sensor's EEPROM at startup.
pub struct Calibration {
/// Trim register values used during factory calibration; must be
/// re-applied so the sensor's analog behaviour matches the calibration
/// data (datasheet section 11.3).
pub mbit: u8,
pub bias: u8,
pub clk: u8,
pub bpa: u8,
pub pu: u8,
/// Ambient temperature from PTAT (datasheet 12.1).
pub ptat_gradient: f32,
pub ptat_offset: f32,
/// Thermal offset compensation (datasheet 12.2).
pub gradient_scale_div: i32, // 2^gradScale
pub thermal_gradient: Grid16<i16>, // ThGrad
pub thermal_offset: Grid16<i16>, // ThOffset
/// VDD compensation (datasheet 12.4).
pub vdd_comp_gradient: Grid8,
pub vdd_comp_offset: Grid8,
pub vdd_scale_gradient_div: i32, // 2^VddScGrad
pub vdd_scale_offset_div: i32, // 2^VddScOff
pub vdd_at_calibration: [i32; 2], // VddTh1, VddTh2
pub ptat_at_calibration: [i32; 2], // PtatTh1, PtatTh2
/// Sensitivity / object temperature (datasheet 12.5).
pub pixc_min: f32,
pub pixc_max: f32,
pub epsilon: f32,
pub global_gain: f32,
pub sensitivity: Grid16<u16>, // Pij
/// Identifies which calibration table (see [`crate::lookup_table`])
/// this sensor was factory-calibrated against.
pub table_number: u16,
}
impl Calibration {
/// Read the full calibration set from the sensor's EEPROM.
pub async fn read(i2c: &mut Bus) -> Self {
let mbit = read_word(i2c, 0x001A).await as u8;
let bias = read_word(i2c, 0x001B).await as u8;
let clk = read_word(i2c, 0x001C).await as u8;
let bpa = read_word(i2c, 0x001D).await as u8;
let pu = read_word(i2c, 0x001E).await as u8;
let ptat_gradient = read_f32(i2c, 0x0034, 0x0035).await;
let ptat_offset = read_f32(i2c, 0x0036, 0x0037).await;
let gradient_scale_div = 1i32 << read_word(i2c, 0x0008).await as u8;
let thermal_gradient = read_reversed_grid16_signed(i2c, 0x0100).await;
let thermal_offset = read_reversed_grid16_signed(i2c, 0x0200).await;
let vdd_comp_gradient = read_packed_grid8(i2c, 0x0040).await;
let vdd_comp_offset = read_packed_grid8(i2c, 0x00A0).await;
let vdd_scale_gradient_div = 1i32 << read_word(i2c, 0x003E).await as u8;
let vdd_scale_offset_div = 1i32 << read_word(i2c, 0x003F).await as u8;
let vdd_at_calibration = [
read_word(i2c, 0x0025).await as i32,
read_word(i2c, 0x0026).await as i32,
];
let ptat_at_calibration = [
read_word(i2c, 0x002C).await as i32,
read_word(i2c, 0x002D).await as i32,
];
let pixc_min = read_f32(i2c, 0x0000, 0x0001).await;
let pixc_max = read_f32(i2c, 0x0002, 0x0003).await;
let global_gain = read_word(i2c, 0x0009).await as f32;
let epsilon = read_word(i2c, 0x000D).await as f32;
let sensitivity = read_reversed_grid16_raw(i2c, 0x0300).await;
let table_number = read_word(i2c, 0x000C).await;
Calibration {
mbit,
bias,
clk,
bpa,
pu,
ptat_gradient,
ptat_offset,
gradient_scale_div,
thermal_gradient,
thermal_offset,
vdd_comp_gradient,
vdd_comp_offset,
vdd_scale_gradient_div,
vdd_scale_offset_div,
vdd_at_calibration,
ptat_at_calibration,
pixc_min,
pixc_max,
epsilon,
global_gain,
sensitivity,
table_number,
}
}
}
/// Datasheet Fig. 11 layout shared by `ThGrad`, `ThOffset` and `Pij`: 256
/// consecutive words, stored as two 8-row halves where the second half is
/// stored *row-reversed* (its first stored row is the array's last row).
async fn read_reversed_grid16_raw(i2c: &mut Bus, base: u16) -> Grid16<u16> {
let mut grid: Grid16<u16> = [[0u16; GRID]; GRID];
for row in 0..8 {
for col in 0..GRID {
grid[row][col] = read_word(i2c, base + (col + row * GRID) as u16).await;
}
}
for row in 0..8 {
for col in 0..GRID {
grid[GRID - 1 - row][col] = read_word(i2c, base + 128 + (col + row * GRID) as u16).await;
}
}
grid
}
/// [`read_reversed_grid16_raw`], reinterpreted as signed values (`ThGrad`,
/// `ThOffset` are stored as 16-bit signed integers).
async fn read_reversed_grid16_signed(i2c: &mut Bus, base: u16) -> Grid16<i16> {
read_reversed_grid16_raw(i2c, base)
.await
.map(|row| row.map(|v| v as i16))
}
/// `VddCompGrad`/`VddCompOff` (datasheet 12.4): 128 values (8 row profiles x
/// 16 columns) packed as signed 12-bit values with an `0x800` bias, 4 values
/// per 3 EEPROM words.
async fn read_packed_grid8(i2c: &mut Bus, base: u16) -> Grid8 {
let mut grid: Grid8 = [[0i16; GRID]; 8];
let mut index = 0usize;
for group in 0..(GRID * 8 / 4) {
let w0 = read_word(i2c, base + (group * 3) as u16).await;
let w1 = read_word(i2c, base + (group * 3 + 1) as u16).await;
let w2 = read_word(i2c, base + (group * 3 + 2) as u16).await;
let packed_values = [
w0 & 0x0FFF,
(w0 >> 12) | ((w1 & 0x00FF) << 4),
(w1 >> 8) | ((w2 & 0x000F) << 8),
w2 >> 4,
];
for value in packed_values {
grid[index / GRID][index % GRID] = value as i16 - 0x800;
index += 1;
}
}
grid
}
+36
View File
@@ -0,0 +1,36 @@
//! Low-level helpers for reading the sensor's calibration EEPROM over I2C.
//!
//! The EEPROM is addressed in 16-bit words. Every read follows the same
//! read-only sequence described in the datasheet (section 11.4):
//! SET_ADDRESS -> NORMAL_READ -> GET_DATA -> ACTIVE. Nothing in this module
//! ever writes to the calibration area itself.
use crate::Bus;
const EEPROM_ADDR: u8 = 0x1B;
const SET_ADDRESS: u8 = 0x09;
const NORMAL_READ: u8 = 0x06;
const GET_DATA: u8 = 0x0B;
const ACTIVE: u8 = 0x01;
/// Read a single 16-bit word from the EEPROM at `addr`.
pub async fn read_word(i2c: &mut Bus, addr: u16) -> u16 {
i2c.write(EEPROM_ADDR, &[SET_ADDRESS, (addr >> 8) as u8, addr as u8])
.await
.unwrap();
i2c.write(EEPROM_ADDR, &[NORMAL_READ]).await.unwrap();
let mut bytes = [0u8; 2];
i2c.write_read(EEPROM_ADDR, &[GET_DATA], &mut bytes).await.unwrap();
i2c.write(EEPROM_ADDR, &[ACTIVE]).await.unwrap();
u16::from_le_bytes(bytes)
}
/// Reconstruct a little-endian `f32` stored across two consecutive EEPROM
/// words (low word first, matching how `PTATGrad`/`PTATOff`/`PixCmin`/
/// `PixCmax` etc. are stored).
pub async fn read_f32(i2c: &mut Bus, low_addr: u16, high_addr: u16) -> f32 {
let low = read_word(i2c, low_addr).await as u32;
let high = read_word(i2c, high_addr).await as u32;
f32::from_bits(high << 16 | low)
}
+84
View File
@@ -0,0 +1,84 @@
//! Generic bilinear-interpolation lookup against a Heimann "Table.c"-style
//! object-temperature calibration table (datasheet section 12.5).
//!
//! Heimann calibrates each sensor/optics/gain combination with its own
//! table, selected via a "table number" stored in the sensor's EEPROM. Only
//! tables using an equidistant signal axis (`EQUIADTABLE` in Heimann's
//! reference code) are supported here, which turns the row lookup into a
//! plain shift instead of a per-row search. All of the reference tables we
//! have for the 16x16 sensor use this layout.
//!
//! Both lookups below clamp out-of-range inputs to the nearest edge of the
//! table (linear extrapolation) instead of failing, so a pixel briefly
//! outside the calibrated range just gets a best-effort estimate rather than
//! an error.
/// The ambient-temperature column to use for a whole frame, located once via
/// [`LookupTable::locate_ambient_column`] since it doesn't vary pixel to
/// pixel.
pub struct AmbientColumn {
index: usize,
delta_dk: i32,
}
pub struct LookupTable<const ROWS: usize, const COLS: usize> {
/// Heimann's table number (EEPROM `TN`) that this table was made for.
pub table_number: u16,
/// `values[row][column]` is the object temperature in deci-Kelvin.
/// `0` marks an entry outside the sensor's factory-calibrated range.
pub values: &'static [[u16; COLS]; ROWS],
/// Ambient temperature (deci-Kelvin) of each column.
pub ta_columns_dk: [u16; COLS],
/// Scale used to turn a sensitivity-compensated pixel signal into
/// "digits" comparable to this table (Heimann's `PCSCALEVAL`).
pub pixel_signal_scale: f32,
/// Signal digits between two adjacent rows (`ADEQUIDISTANCE`).
pub row_step_digits: i32,
/// `log2(row_step_digits)`; turns a signal into a row index with a shift
/// instead of a division (`ADEXPBITS`).
pub row_exp_bits: u32,
/// Added to a signal before locating its row, so that signals near zero
/// still map to a valid (non-negative) row index (`TABLEOFFSET`).
pub row_offset_digits: i32,
/// Deci-Kelvin between two adjacent columns (`TAEQUIDISTANCE`).
pub column_step_dk: i32,
}
impl<const ROWS: usize, const COLS: usize> LookupTable<ROWS, COLS> {
/// Locate the ambient-temperature column for `ambient_dk`, clamped to
/// the table's calibrated range.
pub fn locate_ambient_column(&self, ambient_dk: i32) -> AmbientColumn {
let last_selectable_column = COLS - 2; // `index + 1` must stay in bounds
let index = (0..=last_selectable_column)
.rev()
.find(|&i| self.ta_columns_dk[i] as i32 <= ambient_dk)
.unwrap_or(0);
AmbientColumn {
index,
delta_dk: ambient_dk - self.ta_columns_dk[index] as i32,
}
}
/// Bi-linear interpolation of the object temperature (deci-Kelvin) for
/// one pixel's sensitivity-compensated signal (in digits).
pub fn lookup(&self, column: &AmbientColumn, signal_digits: i32) -> i32 {
let shifted_signal = (signal_digits + self.row_offset_digits) >> self.row_exp_bits;
let row = shifted_signal.clamp(0, (ROWS - 2) as i32) as usize;
let c = column.index;
let top_left = self.values[row][c] as i32;
let top_right = self.values[row][c + 1] as i32;
let bottom_left = self.values[row + 1][c] as i32;
let bottom_right = self.values[row + 1][c + 1] as i32;
// interpolate along the ambient-temperature axis, at this row and the next
let at_row = (top_right - top_left) * column.delta_dk / self.column_step_dk + top_left;
let at_next_row =
(bottom_right - bottom_left) * column.delta_dk / self.column_step_dk + bottom_left;
// interpolate along the signal axis, between those two results
let row_start_digits = row as i32 * self.row_step_digits;
let position_in_row = (signal_digits + self.row_offset_digits) - row_start_digits;
(at_next_row - at_row) * position_in_row / self.row_step_digits + at_row
}
}
+62 -128
View File
@@ -1,156 +1,90 @@
#![no_main] #![no_main]
#![no_std] #![no_std]
mod calibration;
mod eeprom;
mod lookup_table;
mod sensor;
mod table_tn114;
mod thermal;
use defmt::*; use defmt::*;
use embassy_executor::Spawner; use embassy_executor::Spawner;
use embassy_stm32::dma;
use embassy_stm32::mode::Async; use embassy_stm32::mode::Async;
use embassy_stm32::{Config, bind_interrupts, dma::InterruptHandler, i2c::{self, ErrorInterruptHandler, EventInterruptHandler, I2c, Master}, peripherals::{DMA1_CH1, DMA1_CH2, I2C2}}; use embassy_stm32::peripherals::{GPDMA1_CH0, GPDMA1_CH1, I2C2};
use embassy_time::Timer; use embassy_stm32::{bind_interrupts, i2c, rcc, time, Config};
use defmt_rtt as _; use defmt_rtt as _;
use panic_probe as _; use panic_probe as _;
bind_interrupts!(struct Irqs { bind_interrupts!(struct Irqs {
DMA1_CHANNEL2_3 => InterruptHandler<DMA1_CH2>; GPDMA1_CHANNEL0 => dma::InterruptHandler<GPDMA1_CH0>;
DMA1_CHANNEL1 => InterruptHandler<DMA1_CH1>; GPDMA1_CHANNEL1 => dma::InterruptHandler<GPDMA1_CH1>;
I2C2_3 => EventInterruptHandler<I2C2>, ErrorInterruptHandler<I2C2>; I2C2_EV => i2c::EventInterruptHandler<I2C2>;
I2C2_ER => i2c::ErrorInterruptHandler<I2C2>;
}); });
const SENSOR: u8 = 0x1A; /// Shared I2C bus type used by every module that talks to the sensor.
const EEPROM: u8 = 0x1B; pub type Bus = i2c::I2c<'static, Async, i2c::Master>;
type Bus = I2c<'static, Async, Master>;
/// Read a 16-bit word from the sensor EEPROM (read-only sequence, never writes).
async fn eeprom_word(i2c: &mut Bus, addr: u16) -> u16 {
i2c.write(EEPROM, &[0x09, (addr >> 8) as u8, addr as u8]).await.unwrap(); // SET_ADDRESS
i2c.write(EEPROM, &[0x06]).await.unwrap(); // NORMAL_READ
let mut b = [0u8; 2];
i2c.write_read(EEPROM, &[0x0B], &mut b).await.unwrap(); // GET_DATA
i2c.write(EEPROM, &[0x01]).await.unwrap(); // ACTIVE
(b[1] as u16) << 8 | b[0] as u16
}
/// Reconstruct a little-endian float stored across two EEPROM words.
async fn eeprom_f32(i2c: &mut Bus, lo_addr: u16, hi_addr: u16) -> f32 {
let lo = eeprom_word(i2c, lo_addr).await as u32;
let hi = eeprom_word(i2c, hi_addr).await as u32;
f32::from_bits(hi << 16 | lo)
}
/// Run one conversion with the given configuration byte and read both halves.
async fn read_block(i2c: &mut Bus, config: u8) -> ([u8; 130], [u8; 130]) {
i2c.write(SENSOR, &[0x01, config]).await.unwrap();
let mut status = [0u8; 1];
loop {
i2c.write_read(SENSOR, &[0x02], &mut status).await.unwrap();
if status[0] & 0x01 != 0 {
break;
}
}
let mut top = [0u8; 130];
let mut bottom = [0u8; 130];
i2c.write_read(SENSOR, &[0x0A], &mut top).await.unwrap();
i2c.write_read(SENSOR, &[0x0B], &mut bottom).await.unwrap();
(top, bottom)
}
/// Pixel `j` (0..63) of a half-block buffer: word0 is PTAT/VDD, pixels start at byte 2.
fn px(buf: &[u8; 130], j: usize) -> u16 {
(buf[2 * j + 2] as u16) << 8 | buf[2 * j + 3] as u16
}
#[embassy_executor::main] #[embassy_executor::main]
async fn main(_spawner: Spawner) { async fn main(_spawner: Spawner) {
let p = embassy_stm32::init(Config::default()); let mut config = Config::default();
configure_clocks(&mut config);
let p = embassy_stm32::init(config);
let mut i2c_cfg = i2c::Config::default(); let mut i2c_cfg = i2c::Config::default();
i2c_cfg.sda_pullup = false; i2c_cfg.sda_pullup = false;
i2c_cfg.scl_pullup = false; i2c_cfg.scl_pullup = false;
let mut i2c = I2c::new(p.I2C2, p.PA7, p.PA6, p.DMA1_CH1, p.DMA1_CH2, Irqs, i2c_cfg); let mut i2c = i2c::I2c::new(p.I2C2, p.PB13, p.PB14, p.GPDMA1_CH0, p.GPDMA1_CH1, Irqs, i2c_cfg);
// --- read calibration data from EEPROM (read-only) --- let cal = calibration::Calibration::read(&mut i2c).await;
let mbit = eeprom_word(&mut i2c, 0x001A).await as u8; info!("sensor table number (TN): {}", cal.table_number);
let bias = eeprom_word(&mut i2c, 0x001B).await as u8; if cal.table_number != table_tn114::TABLE.table_number {
let clk = eeprom_word(&mut i2c, 0x001C).await as u8; warn!(
let bpa = eeprom_word(&mut i2c, 0x001D).await as u8; "sensor's TableNumber ({}) does not match the embedded calibration table ({}); \
let pu = eeprom_word(&mut i2c, 0x001E).await as u8; object temperatures will be inaccurate",
let gradscale = eeprom_word(&mut i2c, 0x0008).await as u8; cal.table_number,
let ptatgr = eeprom_f32(&mut i2c, 0x0034, 0x0035).await; table_tn114::TABLE.table_number
let ptatoff = eeprom_f32(&mut i2c, 0x0036, 0x0037).await; );
}
// ThGrad and ThOffset (16x16 signed), bottom half is stored row-reversed. sensor::wake_up_and_load_trim(&mut i2c, &cal).await;
let mut thgrad = [[0i16; 16]; 16];
let mut thoffset = [[0i16; 16]; 16]; let frame = sensor::Frame::capture(&mut i2c).await;
for (base, dst) in [(0x0100u16, &mut thgrad), (0x0200u16, &mut thoffset)] { let heatmap = thermal::process_frame(&cal, &frame, &table_tn114::TABLE);
for m in 0..8 {
for n in 0..16 { info!("ambient temperature: {} C", heatmap.ambient_c);
dst[m][n] = eeprom_word(&mut i2c, base + (n + m * 16) as u16).await as i16; info!("heatmap range: {}..{} C", heatmap.min_c, heatmap.max_c);
} for row in 0..calibration::GRID {
} info!("row {}: {}", row, heatmap.temperature_c[row]);
for k in 0..8 {
for n in 0..16 {
dst[15 - k][n] = eeprom_word(&mut i2c, base + 128 + (n + k * 16) as u16).await as i16;
}
} }
} }
// --- wake up sensor and load calibration into trim registers --- /// Configure the system clock to run at its maximum from the board's 25 MHz
i2c.write(SENSOR, &[0x01, 0x01]).await.unwrap(); /// HSE: PLL1 = (25 MHz / 5) * 64 / 2 = 160 MHz, used as SYSCLK. Also enables
Timer::after_millis(30).await; /// the board's 32.768 kHz LSE for the RTC.
for (reg, val) in [(0x03, mbit), (0x04, bias), (0x05, bias), (0x06, clk), (0x07, bpa), (0x08, bpa), (0x09, pu)] { fn configure_clocks(config: &mut Config) {
i2c.write(SENSOR, &[reg, val]).await.unwrap(); config.rcc.hse = Some(rcc::Hse {
Timer::after_millis(5).await; freq: time::Hertz(25_000_000),
} mode: rcc::HseMode::Oscillator,
});
// --- read both pixel blocks (each carries a PTAT value + its pixels) --- config.rcc.pll1 = Some(rcc::Pll {
let (top0, bot0) = read_block(&mut i2c, 0x09).await; // block 0, PTAT source: rcc::PllSource::HSE,
let (top1, bot1) = read_block(&mut i2c, 0x19).await; // block 1, PTAT prediv: rcc::PllPreDiv::DIV5,
// --- electrical offset (blind measurement) --- mul: rcc::PllMul::MUL64,
let (eo_top, eo_bot) = read_block(&mut i2c, 0x0B).await; divp: Some(rcc::PllDiv::DIV2),
divq: Some(rcc::PllDiv::DIV2),
divr: Some(rcc::PllDiv::DIV2), // 160 MHz
});
config.rcc.sys = rcc::Sysclk::PLL1_R;
// ambient temperature from averaged PTAT (datasheet 11.1) config.rcc.ls.lse = Some(rcc::LseConfig {
let ptat = |b: &[u8; 130]| (b[0] as u32) << 8 | b[1] as u32; frequency: time::Hertz(32_768),
let ptat_av = ((ptat(&top0) + ptat(&top1) + ptat(&bot0) + ptat(&bot1)) / 4) as f32; mode: rcc::LseMode::Oscillator(rcc::LseDrive::MediumHigh),
let ambient_dk = ptat_av * ptatgr + ptatoff; peripherals_clocked: false,
let ambient_c = ambient_dk / 10.0 - 273.15; });
config.rcc.ls.rtc = rcc::RtcClockSource::LSE;
// sort raw pixels into a 16x16 image (datasheet ordering)
let mut image = [[0i32; 16]; 16];
let gradscale_div = 1i32 << gradscale;
for n in 0..16 {
let raw = [
px(&top0, n), px(&top0, n + 16), px(&top0, n + 32), px(&top0, n + 48),
px(&top1, n), px(&top1, n + 16), px(&top1, n + 32), px(&top1, n + 48),
px(&bot1, n + 48), px(&bot1, n + 32), px(&bot1, n + 16), px(&bot1, n),
px(&bot0, n + 48), px(&bot0, n + 32), px(&bot0, n + 16), px(&bot0, n),
];
let eo = [
px(&eo_top, n), px(&eo_top, n + 16), px(&eo_top, n + 32), px(&eo_top, n + 48),
px(&eo_bot, n + 48), px(&eo_bot, n + 32), px(&eo_bot, n + 16), px(&eo_bot, n),
];
for m in 0..16 {
// thermal-offset compensation (11.2) + electrical-offset compensation (11.3)
let comp = raw[m] as i32
- (thgrad[m][n] as i32 * ptat_av as i32) / gradscale_div
- thoffset[m][n] as i32;
let eo_row = if m < 8 { m % 4 } else { m % 4 + 4 };
image[m][n] = comp - eo[eo_row] as i32;
}
}
let mut min = i32::MAX;
let mut max = i32::MIN;
for row in &image {
for &v in row {
min = min.min(v);
max = max.max(v);
}
}
info!("ambient temperature: {} C (PTAT_av={})", ambient_c, ptat_av);
info!("compensated image range: {}..{}", min, max);
for m in 0..16 {
info!("row {}: {}", m, image[m]);
}
} }
+117
View File
@@ -0,0 +1,117 @@
//! Talks to the sensor's pixel array (as opposed to its calibration
//! EEPROM, see [`crate::eeprom`]): waking it up, loading its trim
//! registers, and reading raw pixel/PTAT/VDD data.
use embassy_time::Timer;
use crate::calibration::Calibration;
use crate::Bus;
const SENSOR_ADDR: u8 = 0x1A;
const CONFIGURATION_REGISTER: u8 = 0x01;
const STATUS_REGISTER: u8 = 0x02;
const TOP_HALF: u8 = 0x0A;
const BOTTOM_HALF: u8 = 0x0B;
const END_OF_CONVERSION: u8 = 0x01;
/// Configuration register bits (datasheet Table 6).
mod config_bit {
pub const WAKEUP: u8 = 1 << 0;
pub const BLIND: u8 = 1 << 1; // sample electrical offsets instead of the pixels
pub const VDD_MEAS: u8 = 1 << 2; // measure VDD instead of PTAT
pub const START: u8 = 1 << 3;
pub const BLOCK1: u8 = 1 << 4; // select block 1 instead of block 0
}
/// One raw half-array conversion result: a 2-byte header word (PTAT or VDD,
/// depending on which config bits were used) followed by 64 pixel words.
pub type RawHalf = [u8; 130];
/// Wake the sensor up and load the trim registers used during its factory
/// calibration (datasheet section 11.3). Must be called before capturing any
/// frames.
pub async fn wake_up_and_load_trim(i2c: &mut Bus, cal: &Calibration) {
i2c.write(SENSOR_ADDR, &[CONFIGURATION_REGISTER, config_bit::WAKEUP])
.await
.unwrap();
Timer::after_millis(30).await;
let trim_registers = [
(0x03, cal.mbit),
(0x04, cal.bias),
(0x05, cal.bias),
(0x06, cal.clk),
(0x07, cal.bpa),
(0x08, cal.bpa),
(0x09, cal.pu),
];
for (register, value) in trim_registers {
i2c.write(SENSOR_ADDR, &[register, value]).await.unwrap();
Timer::after_millis(5).await;
}
}
/// Everything read from the sensor for one temperature calculation: the two
/// pixel/PTAT blocks, the two VDD blocks, and the electrical-offset (blind)
/// block.
pub struct Frame {
pub pixel_top: [RawHalf; 2],
pub pixel_bottom: [RawHalf; 2],
pub electrical_offset: (RawHalf, RawHalf),
pub vdd_top: [RawHalf; 2],
pub vdd_bottom: [RawHalf; 2],
}
impl Frame {
/// Trigger the five conversions needed for one temperature calculation
/// and read all of their results back.
pub async fn capture(i2c: &mut Bus) -> Self {
use config_bit::*;
let (top0, bottom0) = read_halves(i2c, WAKEUP | START).await;
let (top1, bottom1) = read_halves(i2c, WAKEUP | START | BLOCK1).await;
let electrical_offset = read_halves(i2c, WAKEUP | START | BLIND).await;
let (vdd_top0, vdd_bottom0) = read_halves(i2c, WAKEUP | START | VDD_MEAS).await;
let (vdd_top1, vdd_bottom1) = read_halves(i2c, WAKEUP | START | VDD_MEAS | BLOCK1).await;
Frame {
pixel_top: [top0, top1],
pixel_bottom: [bottom0, bottom1],
electrical_offset,
vdd_top: [vdd_top0, vdd_top1],
vdd_bottom: [vdd_bottom0, vdd_bottom1],
}
}
}
/// Trigger one conversion with the given configuration bits and read back
/// both halves of the array once it completes.
async fn read_halves(i2c: &mut Bus, config: u8) -> (RawHalf, RawHalf) {
i2c.write(SENSOR_ADDR, &[CONFIGURATION_REGISTER, config]).await.unwrap();
let mut status = [0u8; 1];
loop {
i2c.write_read(SENSOR_ADDR, &[STATUS_REGISTER], &mut status).await.unwrap();
if status[0] & END_OF_CONVERSION != 0 {
break;
}
}
let mut top = [0u8; 130];
let mut bottom = [0u8; 130];
i2c.write_read(SENSOR_ADDR, &[TOP_HALF], &mut top).await.unwrap();
i2c.write_read(SENSOR_ADDR, &[BOTTOM_HALF], &mut bottom).await.unwrap();
(top, bottom)
}
/// Header word (bytes 0/1) of a half-array buffer: PTAT or VDD, depending on
/// which config bits were used for the conversion that produced it.
pub fn header_word(buf: &RawHalf) -> u32 {
((buf[0] as u32) << 8) | buf[1] as u32
}
/// Pixel `index` (0..63) of a half-array buffer.
pub fn pixel(buf: &RawHalf, index: usize) -> u16 {
(buf[2 * index + 2] as u16) << 8 | buf[2 * index + 3] as u16
}
+1625
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
//! Turns one raw sensor [`Frame`] plus [`Calibration`] data into a 16x16
//! object-temperature heatmap, following the compensation pipeline described
//! in the HTPA16x16dR2 datasheet, section 12:
//!
//! 1. ambient temperature, from PTAT (12.1)
//! 2. thermal offset compensation (12.2)
//! 3. electrical offset compensation (12.3)
//! 4. VDD compensation (12.4)
//! 5. sensitivity (PixC) compensation + lookup table (12.5)
use crate::calibration::{Calibration, GRID};
use crate::lookup_table::LookupTable;
use crate::sensor::{header_word, pixel, Frame, RawHalf};
/// The computed heatmap for one frame, plus a couple of handy summary stats.
pub struct Heatmap {
pub ambient_c: f32,
pub min_c: f32,
pub max_c: f32,
pub temperature_c: [[f32; GRID]; GRID],
}
/// Compensate one raw frame into a full object-temperature heatmap.
pub fn process_frame<const ROWS: usize, const COLS: usize>(
cal: &Calibration,
frame: &Frame,
table: &LookupTable<ROWS, COLS>,
) -> Heatmap {
let ptat_av = average_header_word(&frame.pixel_top, &frame.pixel_bottom);
let vdd_av = average_header_word(&frame.vdd_top, &frame.vdd_bottom);
let ambient_dk = ptat_av as f32 * cal.ptat_gradient + cal.ptat_offset;
let ambient_c = dk_to_c(ambient_dk);
let ambient_column = table.locate_ambient_column(ambient_dk as i32);
let mut temperature_c = [[0f32; GRID]; GRID];
let mut min_c = f32::MAX;
let mut max_c = f32::MIN;
for col in 0..GRID {
let raw_pixels = assemble_raw_column(frame, col);
let electrical_offsets = electrical_offset_column(&frame.electrical_offset, col);
for row in 0..GRID {
let profile = row_profile(row);
let thermal_compensated =
raw_pixels[row] - thermal_offset_correction(cal, row, col, ptat_av);
let electrical_compensated =
thermal_compensated - electrical_offsets[profile] as i32;
let vdd_compensated = electrical_compensated
- vdd_compensation(cal, profile, col, ptat_av, vdd_av);
let pixc = sensitivity_coefficient(cal, row, col);
let signal_digits = (vdd_compensated as f32 * table.pixel_signal_scale / pixc) as i32;
let object_dk = table.lookup(&ambient_column, signal_digits);
let object_c = dk_to_c(object_dk as f32);
temperature_c[row][col] = object_c;
min_c = min_c.min(object_c);
max_c = max_c.max(object_c);
}
}
Heatmap { ambient_c, min_c, max_c, temperature_c }
}
/// Convert deci-Kelvin (as used throughout the datasheet's calibration data)
/// to degrees Celsius.
fn dk_to_c(dk: f32) -> f32 {
dk / 10.0 - 273.15
}
/// Which of the 8 stored calibration row-profiles a physical row uses.
/// Electrical offset and VDD compensation are only calibrated for 8 rows;
/// each profile is reused for two of the array's 16 physical rows (the
/// bottom half is wired up in reverse row order, see the datasheet's
/// "Readout Order" figure).
fn row_profile(row: usize) -> usize {
if row < 8 {
row % 4
} else {
row % 4 + 4
}
}
/// Average of the header word (PTAT or VDD) across a block-0/block-1 pair of
/// top/bottom half buffers (datasheet 12.1/12.4 both recommend averaging all
/// four samples for a more stable reading).
fn average_header_word(top: &[RawHalf; 2], bottom: &[RawHalf; 2]) -> i32 {
let sum = header_word(&top[0]) + header_word(&top[1]) + header_word(&bottom[0]) + header_word(&bottom[1]);
(sum / 4) as i32
}
/// Reconstruct one column (16 rows) of raw pixel digits from the pixel
/// blocks, in the physical row order described in the datasheet's "Readout
/// Order" figure: block 0 and block 1 each contribute 8 rows, with the
/// bottom half read out column-major and row-reversed.
fn assemble_raw_column(frame: &Frame, col: usize) -> [i32; GRID] {
let top0 = &frame.pixel_top[0];
let top1 = &frame.pixel_top[1];
let bottom1 = &frame.pixel_bottom[1];
let bottom0 = &frame.pixel_bottom[0];
[
pixel(top0, col) as i32,
pixel(top0, col + 16) as i32,
pixel(top0, col + 32) as i32,
pixel(top0, col + 48) as i32,
pixel(top1, col) as i32,
pixel(top1, col + 16) as i32,
pixel(top1, col + 32) as i32,
pixel(top1, col + 48) as i32,
pixel(bottom1, col + 48) as i32,
pixel(bottom1, col + 32) as i32,
pixel(bottom1, col + 16) as i32,
pixel(bottom1, col) as i32,
pixel(bottom0, col + 48) as i32,
pixel(bottom0, col + 32) as i32,
pixel(bottom0, col + 16) as i32,
pixel(bottom0, col) as i32,
]
}
/// One column of the 8 electrical-offset row profiles (datasheet 12.3).
fn electrical_offset_column(halves: &(RawHalf, RawHalf), col: usize) -> [u16; 8] {
let (top, bottom) = halves;
[
pixel(top, col),
pixel(top, col + 16),
pixel(top, col + 32),
pixel(top, col + 48),
pixel(bottom, col + 48),
pixel(bottom, col + 32),
pixel(bottom, col + 16),
pixel(bottom, col),
]
}
/// Thermal offset correction to subtract from one pixel (datasheet 12.2):
/// `ThGrad * Ta / 2^gradScale + ThOffset`.
fn thermal_offset_correction(cal: &Calibration, row: usize, col: usize, ptat_av: i32) -> i32 {
(cal.thermal_gradient[row][col] as i32 * ptat_av) / cal.gradient_scale_div
+ cal.thermal_offset[row][col] as i32
}
/// VDD compensation to subtract from one pixel (datasheet 12.4). Compares
/// the measured VDD against what it should be at the current ambient
/// temperature (interpolated between the two calibration points), scaled by
/// this pixel's VDD sensitivity.
fn vdd_compensation(cal: &Calibration, profile: usize, col: usize, ptat_av: i32, vdd_av: i32) -> i32 {
let gradient = cal.vdd_comp_gradient[profile][col] as i32;
let offset = cal.vdd_comp_offset[profile][col] as i32;
let [vdd_at_cal1, vdd_at_cal2] = cal.vdd_at_calibration;
let [ptat_at_cal1, ptat_at_cal2] = cal.ptat_at_calibration;
let expected_vdd = vdd_at_cal1
+ (vdd_at_cal2 - vdd_at_cal1) * (ptat_av - ptat_at_cal1) / (ptat_at_cal2 - ptat_at_cal1);
let vdd_delta = vdd_av - expected_vdd;
let scaled_gradient = gradient * ptat_av / cal.vdd_scale_gradient_div + offset;
scaled_gradient * vdd_delta / cal.vdd_scale_offset_div
}
/// Per-pixel sensitivity coefficient, PixC (datasheet 12.5):
/// `(Pij * (PixCmax - PixCmin) / 65535 + PixCmin) * epsilon/100 * GlobalGain/10000`.
fn sensitivity_coefficient(cal: &Calibration, row: usize, col: usize) -> f32 {
let scaled = cal.sensitivity[row][col] as f32 * (cal.pixc_max - cal.pixc_min) / 65535.0 + cal.pixc_min;
scaled * (cal.epsilon / 100.0) * (cal.global_gain / 10000.0)
}