hive_btle/power/
profile.rs

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