umsh_bsp_t1000e/
light.rs

1//! Ambient light sensing for the T1000-E.
2//!
3//! The sensor sits on P0.29 (AIN5) behind two enables: the shared sensor
4//! 3.3 V rail (P1.06) and a sensor-enable line (P0.04), both active-high.
5//! It is a phototransistor loaded to ground, so the node voltage rises
6//! with illuminance from a small dark offset and rails once the load
7//! resistor saturates — the transfer function is a straight line between
8//! those two ends.
9//!
10//! The SAADC and the sensor rail are owned by
11//! [`run_battery_monitor`](crate::power::run_battery_monitor); this module
12//! is the request side of that ownership, plus the pure conversion, which
13//! is host-testable and therefore deliberately not gated on the target.
14//!
15//! The light channel runs at **14-bit** resolution with hardware
16//! oversampling, which is why the counts here are four times the
17//! 12-bit-scale numbers the vendor driver works in. Full scale is 3.6 V
18//! (`Gain1_6` against the 0.6 V internal reference), so one count is
19//! `3600 / 16384 ≈ 0.2197` mV.
20
21// ─── Calibration ─────────────────────────────────────────────────────
22//
23// Bench measurements, 2026-08-06, against a reference lux meter. Readings
24// are averaged raw counts at 14-bit (see [`crate::power`] for how they
25// are taken):
26//
27// | Raw counts | Meter      | Note            |
28// |------------|------------|-----------------|
29// | 0.2        | darkness   | the dark offset |
30// | 8965.086   | 185 lux    | the fit below   |
31// | 13478.391  | flashlight | the hard rail   |
32//
33// The response is linear across the usable range — verified against the
34// meter after fitting — so one slope through the origin describes it.
35//
36// The substantive correction to what was inherited from Seeed: **this
37// sensor has essentially no dark current.** 0.2 counts is 44 µV. Seeed's
38// 80 mV floor — 364 counts at this resolution — is a software noise
39// guard, not a property of the part, and subtracting it was discarding
40// the entire bottom of the range, which is precisely the region an
41// indicator-brightness policy works in.
42
43/// Raw count at zero illuminance, subtracted before scaling.
44///
45/// Measured: full darkness reads 0.2 counts, so there is nothing to
46/// subtract. Left at zero rather than rounded to 1 so the bottom of the
47/// range is not clipped; a covered sensor reports about 4 mlux, which is
48/// an honest noise floor and far below anything meaningful.
49pub const DARK_OFFSET_RAW: u32 = 0;
50
51/// Raw count above which the reading is clamped: 12288, which is 2.7 V
52/// and three-quarters of full scale.
53///
54/// A judgement call rather than a measurement, and deliberately not
55/// either of the two numbers it sits between:
56///
57/// - A bright flashlight drives the node to **13478** counts (2.96 V of a
58///   3.3 V rail) and no further. That is the hard rail, where the reading
59///   stops responding to light altogether; sitting the clamp right on it
60///   leaves no margin for part-to-part or temperature variation in where
61///   it lands.
62/// - Seeed clamps at **11287** (2.48 V), which is arbitrary and throws
63///   away range the part demonstrably has.
64///
65/// Between them, 2.7 V keeps a margin below the rail while retaining most
66/// of the range. Everything above it reports the same clamped maximum,
67/// which is the honest answer for a sensor that can no longer tell those
68/// levels apart.
69pub const SATURATION_RAW: u32 = 12_288;
70
71/// Millilux per count above [`DARK_OFFSET_RAW`], as the fraction
72/// `SLOPE_MLUX_NUM / SLOPE_MLUX_DEN` — **20.636 mlux per count**.
73///
74/// Fitted through the origin from the 185 lux point:
75/// `185000 / 8965.086 = 20.636`.
76///
77/// A fraction rather than a whole number of millilux because rounding the
78/// slope to an integer would discard a percent or two of the answer —
79/// more than the sub-count resolution the sampling in [`crate::power`]
80/// exists to buy.
81pub const SLOPE_MLUX_NUM: u32 = 20_636;
82pub const SLOPE_MLUX_DEN: u32 = 1_000;
83
84/// Convert an accumulated run of SAADC counts on AIN5 to millilux.
85///
86/// Takes the **sum** and the number of conversions in it rather than a
87/// pre-computed mean, because the mean of a run of counts is fractional
88/// and a mean rounded to whole counts throws that away. Both divisions —
89/// by the conversion count and by the slope's denominator — are therefore
90/// done last, against the scaled sum.
91///
92/// Clamped at both ends: below the dark offset the sensor is reporting
93/// its own leakage, above saturation it is reporting the load resistor.
94pub fn millilux_from_sum(sum: u32, conversions: u32) -> u32 {
95    if conversions == 0 {
96        return 0;
97    }
98    let conversions = u64::from(conversions);
99    let floor = conversions * u64::from(DARK_OFFSET_RAW);
100    let ceiling = conversions * u64::from(SATURATION_RAW);
101    let sum = u64::from(sum).min(ceiling);
102    if sum <= floor {
103        return 0;
104    }
105    let scaled =
106        (sum - floor) * u64::from(SLOPE_MLUX_NUM) / (u64::from(SLOPE_MLUX_DEN) * conversions);
107    scaled.min(u64::from(u32::MAX)) as u32
108}
109
110/// The largest value this board can report — the reading at
111/// [`SATURATION_RAW`], about 253 lux.
112///
113/// A ceiling, not a measurement: everything from a bright room to direct
114/// sunlight lands on it. The part is a dark-end instrument, and at the
115/// dark end it is a good one — one count is 21 mlux, so full moonlight
116/// (~300 mlux) sits about 15 counts up with a 4 mlux noise floor beneath
117/// it. Anything wanting a daylight figure needs a different sensor.
118pub const MAX_REPORTABLE_MLUX: u32 =
119    (SATURATION_RAW - DARK_OFFSET_RAW) * SLOPE_MLUX_NUM / SLOPE_MLUX_DEN;
120
121/// Convert one SAADC count on AIN5 to millilux — [`millilux_from_sum`]
122/// for a single conversion.
123pub fn raw_to_millilux(raw: u16) -> u32 {
124    millilux_from_sum(u32::from(raw), 1)
125}
126
127#[cfg(target_os = "none")]
128mod sampling {
129    use core::sync::atomic::{AtomicU32, Ordering};
130
131    use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
132    use embassy_sync::signal::Signal;
133
134    /// Wakes the battery monitor to take a light measurement now (see
135    /// [`sample_illuminance`]).
136    pub(crate) static LIGHT_SAMPLE_REQUEST: Signal<ThreadModeRawMutex, ()> = Signal::new();
137    pub(crate) static LIGHT_SAMPLE_REPLY: Signal<ThreadModeRawMutex, u32> = Signal::new();
138
139    /// The most recent measurement, however it was triggered, in
140    /// millilux. `u32::MAX` until one exists — that cannot be a reading,
141    /// because the conversion tops out at
142    /// [`MAX_REPORTABLE_MLUX`](super::MAX_REPORTABLE_MLUX).
143    static AMBIENT_MILLILUX: AtomicU32 = AtomicU32::new(u32::MAX);
144
145    /// The most recent illuminance measurement in millilux, whoever asked
146    /// for it; `None` until the first one completes.
147    ///
148    /// This is the consumer side of [`request_sample`], but every
149    /// measurement lands here — an on-demand [`sample_illuminance`] for a
150    /// protocol read refreshes it too.
151    pub fn ambient_millilux() -> Option<u32> {
152        match AMBIENT_MILLILUX.load(Ordering::Acquire) {
153            u32::MAX => None,
154            millilux => Some(millilux),
155        }
156    }
157
158    /// Record a completed measurement. Called by the sampler in
159    /// [`crate::power`] for every measurement it takes.
160    pub(crate) fn publish_ambient(millilux: u32) {
161        AMBIENT_MILLILUX.store(millilux, Ordering::Release);
162    }
163
164    /// Ask for a measurement without waiting for it: the result appears
165    /// in [`ambient_millilux`] once taken. For callers that must not
166    /// block on the monitor — the LED task requests from inside the
167    /// select loop that also answers the sampler's blanking handshake.
168    ///
169    /// The request latches, so duplicates coalesce, and the reply signal
170    /// is left alone: [`sample_illuminance`] resets it before waiting, so
171    /// an unconsumed reply from this path cannot satisfy a later
172    /// on-demand read.
173    pub fn request_sample() {
174        LIGHT_SAMPLE_REQUEST.signal(());
175    }
176
177    /// Ask [`run_battery_monitor`](crate::power::run_battery_monitor) —
178    /// the sole SAADC and sensor-rail owner — for a fresh illuminance
179    /// measurement in millilux and wait for it.
180    ///
181    /// Single-consumer, like the monitor itself. Never completes once the
182    /// monitor has exited for critical-battery shutdown, so callers must
183    /// apply their own timeout.
184    pub async fn sample_illuminance() -> u32 {
185        LIGHT_SAMPLE_REPLY.reset();
186        LIGHT_SAMPLE_REQUEST.signal(());
187        LIGHT_SAMPLE_REPLY.wait().await
188    }
189}
190
191#[cfg(target_os = "none")]
192pub(crate) use sampling::{LIGHT_SAMPLE_REPLY, LIGHT_SAMPLE_REQUEST, publish_ambient};
193#[cfg(target_os = "none")]
194pub use sampling::{ambient_millilux, request_sample, sample_illuminance};
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// Millilux for a whole number of counts above dark, whatever the
201    /// constants currently are.
202    fn at_counts(counts: u32) -> u32 {
203        counts * SLOPE_MLUX_NUM / SLOPE_MLUX_DEN
204    }
205
206    #[test]
207    fn saturates_at_the_ceiling() {
208        let ceiling = raw_to_millilux(SATURATION_RAW as u16);
209        assert_eq!(raw_to_millilux(SATURATION_RAW as u16 + 1), ceiling);
210        assert_eq!(raw_to_millilux(u16::MAX), ceiling);
211        assert_eq!(ceiling, MAX_REPORTABLE_MLUX);
212        // The clamp survives averaging: a run entirely past saturation
213        // reports the ceiling, not an extrapolation.
214        assert_eq!(millilux_from_sum(u32::MAX, 23), ceiling);
215    }
216
217    /// Whatever the constants are set to, the conversion must never
218    /// overflow or wrap — the sum of a full run at full scale is the
219    /// worst case the sampler can hand it.
220    #[test]
221    fn a_full_scale_run_does_not_overflow() {
222        let full_run = 23 * 16_383;
223        let millilux = millilux_from_sum(full_run, 23);
224        assert_eq!(millilux, MAX_REPORTABLE_MLUX);
225        assert!(millilux < u32::MAX);
226    }
227
228    /// The whole point of summing rather than pre-averaging: a run whose
229    /// mean falls between two counts must land between the two millilux
230    /// values, not on one of them.
231    #[test]
232    fn the_average_keeps_resolution_below_one_count() {
233        let base = DARK_OFFSET_RAW + 100;
234        // Twenty conversions at `base`, three at `base + 1` — a mean of
235        // 100.13 counts above dark, which whole counts cannot express.
236        let sum = 20 * base + 3 * (base + 1);
237        let averaged = millilux_from_sum(sum, 23);
238        assert!(averaged > at_counts(100));
239        assert!(averaged < at_counts(101));
240    }
241
242    /// A conversion count of zero is a caller bug, not a panic.
243    #[test]
244    fn an_empty_run_reads_zero() {
245        assert_eq!(millilux_from_sum(10_000, 0), 0);
246    }
247}