hive_btle/power/
profile.rs

1//! Power Profiles for HIVE-Lite
2//!
3//! Defines power consumption profiles for different use cases,
4//! from aggressive (low latency) to low-power (maximum battery life).
5
6/// Radio timing parameters for a power profile
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct RadioTiming {
9    /// Scan interval in milliseconds (time between scan windows)
10    pub scan_interval_ms: u32,
11    /// Scan window duration in milliseconds
12    pub scan_window_ms: u32,
13    /// Advertising interval in milliseconds
14    pub adv_interval_ms: u32,
15    /// Connection interval in milliseconds
16    pub conn_interval_ms: u32,
17    /// Supervision timeout in milliseconds
18    pub supervision_timeout_ms: u32,
19    /// Slave latency (number of connection events to skip)
20    pub slave_latency: u16,
21}
22
23impl RadioTiming {
24    /// Calculate approximate radio duty cycle as percentage
25    pub fn duty_cycle_percent(&self) -> f32 {
26        // Scan duty cycle
27        let scan_duty = (self.scan_window_ms as f32 / self.scan_interval_ms as f32) * 100.0;
28
29        // Advertising is typically ~2ms per event
30        let adv_duration_ms = 2.0;
31        let adv_duty = (adv_duration_ms / self.adv_interval_ms as f32) * 100.0;
32
33        // Connection duty (simplified: ~2ms per connection event)
34        let conn_duration_ms = 2.0;
35        let effective_conn_interval =
36            self.conn_interval_ms as f32 * (1.0 + self.slave_latency as f32);
37        let conn_duty = (conn_duration_ms / effective_conn_interval) * 100.0;
38
39        // Combined (assuming activities don't overlap perfectly)
40        scan_duty + adv_duty + conn_duty
41    }
42
43    /// Estimate battery life in hours for a typical smartwatch (300mAh)
44    pub fn estimated_battery_hours(&self, battery_capacity_mah: u16) -> f32 {
45        // Typical BLE radio: ~15mA active, ~5µA sleep
46        let active_current_ma = 15.0;
47        let sleep_current_ma = 0.005;
48
49        let duty = self.duty_cycle_percent() / 100.0;
50        let average_current = (active_current_ma * duty) + (sleep_current_ma * (1.0 - duty));
51
52        // Add MCU overhead (~5mA average for basic processing)
53        let total_current = average_current + 5.0;
54
55        battery_capacity_mah as f32 / total_current
56    }
57}
58
59/// Power profile presets for different use cases
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum PowerProfile {
62    /// 20% duty cycle, ~6 hour watch battery
63    /// Use when low latency is critical (emergency response)
64    Aggressive,
65
66    /// 10% duty cycle, ~12 hour watch battery
67    /// Good balance between responsiveness and battery
68    Balanced,
69
70    /// 2% duty cycle, ~20+ hour watch battery
71    /// Default for HIVE-Lite, prioritizes battery life
72    #[default]
73    LowPower,
74
75    /// Custom profile with user-defined timing
76    Custom(RadioTiming),
77}
78
79impl PowerProfile {
80    /// Get the radio timing for this profile
81    pub fn timing(&self) -> RadioTiming {
82        match self {
83            PowerProfile::Aggressive => RadioTiming {
84                scan_interval_ms: 100,
85                scan_window_ms: 50,
86                adv_interval_ms: 100,
87                conn_interval_ms: 15,
88                supervision_timeout_ms: 4000,
89                slave_latency: 0,
90            },
91            PowerProfile::Balanced => RadioTiming {
92                scan_interval_ms: 500,
93                scan_window_ms: 50,
94                adv_interval_ms: 500,
95                conn_interval_ms: 30,
96                supervision_timeout_ms: 4000,
97                slave_latency: 2,
98            },
99            PowerProfile::LowPower => RadioTiming {
100                scan_interval_ms: 5000,
101                scan_window_ms: 100,
102                adv_interval_ms: 2000,
103                conn_interval_ms: 100,
104                supervision_timeout_ms: 6000,
105                slave_latency: 4,
106            },
107            PowerProfile::Custom(timing) => *timing,
108        }
109    }
110
111    /// Get the duty cycle for this profile
112    pub fn duty_cycle_percent(&self) -> f32 {
113        self.timing().duty_cycle_percent()
114    }
115
116    /// Get estimated battery life in hours
117    pub fn estimated_battery_hours(&self, battery_capacity_mah: u16) -> f32 {
118        self.timing().estimated_battery_hours(battery_capacity_mah)
119    }
120
121    /// Create a custom profile with specific timing
122    pub fn custom(timing: RadioTiming) -> Self {
123        PowerProfile::Custom(timing)
124    }
125
126    /// Get profile name as string
127    pub fn name(&self) -> &'static str {
128        match self {
129            PowerProfile::Aggressive => "aggressive",
130            PowerProfile::Balanced => "balanced",
131            PowerProfile::LowPower => "low_power",
132            PowerProfile::Custom(_) => "custom",
133        }
134    }
135}
136
137/// Battery state for adaptive profile adjustment
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct BatteryState {
140    /// Current battery level (0-100)
141    pub level_percent: u8,
142    /// Whether device is charging
143    pub is_charging: bool,
144    /// Low battery threshold
145    pub low_threshold: u8,
146    /// Critical battery threshold
147    pub critical_threshold: u8,
148}
149
150impl Default for BatteryState {
151    fn default() -> Self {
152        Self {
153            level_percent: 100,
154            is_charging: false,
155            low_threshold: 20,
156            critical_threshold: 10,
157        }
158    }
159}
160
161impl BatteryState {
162    /// Create a new battery state
163    pub fn new(level_percent: u8, is_charging: bool) -> Self {
164        Self {
165            level_percent: level_percent.min(100),
166            is_charging,
167            ..Default::default()
168        }
169    }
170
171    /// Check if battery is low
172    pub fn is_low(&self) -> bool {
173        !self.is_charging && self.level_percent <= self.low_threshold
174    }
175
176    /// Check if battery is critical
177    pub fn is_critical(&self) -> bool {
178        !self.is_charging && self.level_percent <= self.critical_threshold
179    }
180
181    /// Suggest a power profile based on battery state
182    pub fn suggested_profile(&self, current: PowerProfile) -> PowerProfile {
183        if self.is_charging {
184            // When charging, can use more aggressive profile
185            current
186        } else if self.is_critical() {
187            // Critical: force low power
188            PowerProfile::LowPower
189        } else if self.is_low() {
190            // Low: step down if not already at low power
191            match current {
192                PowerProfile::Aggressive => PowerProfile::Balanced,
193                _ => PowerProfile::LowPower,
194            }
195        } else {
196            current
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_profile_defaults() {
207        assert_eq!(PowerProfile::default(), PowerProfile::LowPower);
208    }
209
210    #[test]
211    fn test_aggressive_timing() {
212        let timing = PowerProfile::Aggressive.timing();
213        assert_eq!(timing.scan_interval_ms, 100);
214        assert_eq!(timing.scan_window_ms, 50);
215        assert_eq!(timing.adv_interval_ms, 100);
216        assert_eq!(timing.conn_interval_ms, 15);
217    }
218
219    #[test]
220    fn test_balanced_timing() {
221        let timing = PowerProfile::Balanced.timing();
222        assert_eq!(timing.scan_interval_ms, 500);
223        assert_eq!(timing.adv_interval_ms, 500);
224    }
225
226    #[test]
227    fn test_low_power_timing() {
228        let timing = PowerProfile::LowPower.timing();
229        assert_eq!(timing.scan_interval_ms, 5000);
230        assert_eq!(timing.scan_window_ms, 100);
231        assert_eq!(timing.adv_interval_ms, 2000);
232    }
233
234    #[test]
235    fn test_custom_profile() {
236        let custom_timing = RadioTiming {
237            scan_interval_ms: 1000,
238            scan_window_ms: 100,
239            adv_interval_ms: 1000,
240            conn_interval_ms: 50,
241            supervision_timeout_ms: 5000,
242            slave_latency: 3,
243        };
244        let profile = PowerProfile::custom(custom_timing);
245        assert_eq!(profile.timing(), custom_timing);
246        assert_eq!(profile.name(), "custom");
247    }
248
249    #[test]
250    fn test_duty_cycle_ordering() {
251        // Aggressive should have highest duty cycle
252        let aggressive = PowerProfile::Aggressive.duty_cycle_percent();
253        let balanced = PowerProfile::Balanced.duty_cycle_percent();
254        let low_power = PowerProfile::LowPower.duty_cycle_percent();
255
256        assert!(aggressive > balanced, "aggressive > balanced");
257        assert!(balanced > low_power, "balanced > low_power");
258    }
259
260    #[test]
261    fn test_low_power_duty_cycle() {
262        // Low power should be under 5%
263        let duty = PowerProfile::LowPower.duty_cycle_percent();
264        assert!(duty < 5.0, "LowPower duty cycle {} should be < 5%", duty);
265    }
266
267    #[test]
268    fn test_battery_life_ordering() {
269        let battery_mah = 300;
270
271        let aggressive = PowerProfile::Aggressive.estimated_battery_hours(battery_mah);
272        let balanced = PowerProfile::Balanced.estimated_battery_hours(battery_mah);
273        let low_power = PowerProfile::LowPower.estimated_battery_hours(battery_mah);
274
275        // Lower duty cycle = longer battery life
276        assert!(low_power > balanced, "low_power > balanced battery life");
277        assert!(balanced > aggressive, "balanced > aggressive battery life");
278    }
279
280    #[test]
281    fn test_battery_state_default() {
282        let state = BatteryState::default();
283        assert_eq!(state.level_percent, 100);
284        assert!(!state.is_charging);
285        assert!(!state.is_low());
286        assert!(!state.is_critical());
287    }
288
289    #[test]
290    fn test_battery_state_low() {
291        let state = BatteryState::new(20, false);
292        assert!(state.is_low());
293        assert!(!state.is_critical());
294    }
295
296    #[test]
297    fn test_battery_state_critical() {
298        let state = BatteryState::new(5, false);
299        assert!(state.is_low());
300        assert!(state.is_critical());
301    }
302
303    #[test]
304    fn test_battery_charging_not_low() {
305        let state = BatteryState::new(10, true);
306        assert!(!state.is_low(), "charging should not be considered low");
307        assert!(
308            !state.is_critical(),
309            "charging should not be considered critical"
310        );
311    }
312
313    #[test]
314    fn test_suggested_profile_critical() {
315        let state = BatteryState::new(5, false);
316        let suggested = state.suggested_profile(PowerProfile::Aggressive);
317        assert_eq!(suggested, PowerProfile::LowPower);
318    }
319
320    #[test]
321    fn test_suggested_profile_low() {
322        let state = BatteryState::new(15, false);
323        let suggested = state.suggested_profile(PowerProfile::Aggressive);
324        assert_eq!(suggested, PowerProfile::Balanced);
325    }
326
327    #[test]
328    fn test_suggested_profile_charging() {
329        let state = BatteryState::new(10, true);
330        let suggested = state.suggested_profile(PowerProfile::Aggressive);
331        assert_eq!(
332            suggested,
333            PowerProfile::Aggressive,
334            "charging keeps current profile"
335        );
336    }
337
338    #[test]
339    fn test_profile_names() {
340        assert_eq!(PowerProfile::Aggressive.name(), "aggressive");
341        assert_eq!(PowerProfile::Balanced.name(), "balanced");
342        assert_eq!(PowerProfile::LowPower.name(), "low_power");
343    }
344}