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}