Skip to main content

elk_led_controller/
device.rs

1use btleplug::api::{
2    Central, Characteristic, Manager as _, Peripheral as _, ScanFilter, WriteType,
3};
4use btleplug::platform::{Adapter, Manager, Peripheral};
5use chrono::{self, Datelike, Timelike};
6use std::sync::Arc;
7use std::time::Duration;
8use tokio::sync::{Mutex, Semaphore};
9use tokio::time;
10use tracing::{debug, error, info, instrument, trace, warn};
11use uuid::Uuid;
12
13// Import our custom error type
14use crate::{Error, Result};
15
16// Re-export schedule and effects modules
17pub use crate::effects::{Effects, EFFECTS};
18pub use crate::schedule::{Days, WEEK_DAYS};
19
20/// Gets the default Bluetooth adapter
21#[instrument(skip(manager))]
22async fn get_central(manager: &Manager) -> Result<Adapter> {
23    debug!("Getting default Bluetooth adapter");
24    let adapters = manager.adapters().await?;
25    if adapters.is_empty() {
26        error!("No Bluetooth adapters found");
27        return Err(Error::NoBluetoothAdapters);
28    }
29
30    let adapter = adapters.into_iter().next().unwrap();
31    debug!("Using Bluetooth adapter");
32    Ok(adapter)
33}
34
35/// Supported device types for LED control
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DeviceType {
38    /// ELK-BLE device type
39    ElkBle,
40    /// LEDBLE device type
41    LedBle,
42    /// MELK device type
43    Melk,
44    /// ELK-BULB device type
45    ElkBulb,
46    /// ELK-LAMPL device type
47    ElkLampl,
48    /// Unknown device type
49    Unknown,
50}
51
52/// Configuration for different device types
53#[derive(Debug, Clone)]
54pub struct DeviceConfig {
55    /// UUID for write characteristic
56    pub write_uuid: Uuid,
57    /// UUID for read characteristic
58    pub read_uuid: Uuid,
59    /// Command to turn the device on
60    pub turn_on_cmd: [u8; 9],
61    /// Command to turn the device off
62    pub turn_off_cmd: [u8; 9],
63    /// Minimum supported color temperature in Kelvin
64    pub min_color_temp_k: u32,
65    /// Maximum supported color temperature in Kelvin
66    pub max_color_temp_k: u32,
67    /// Command processing time in milliseconds
68    pub command_delay: u64,
69}
70
71/// Command queue to manage Bluetooth commands with rate limiting
72struct CommandQueue {
73    /// Semaphore to limit command concurrency
74    semaphore: Semaphore,
75    /// Minimum delay between commands
76    min_delay: Duration,
77    /// Last command timestamp
78    last_command: Mutex<std::time::Instant>,
79}
80
81impl CommandQueue {
82    fn new(min_delay_ms: u64) -> Self {
83        Self {
84            semaphore: Semaphore::new(1), // Only allow one command at a time
85            min_delay: Duration::from_millis(min_delay_ms),
86            last_command: Mutex::new(std::time::Instant::now() - Duration::from_secs(1)),
87        }
88    }
89
90    async fn execute<T, F>(&self, future: F) -> T
91    where
92        F: std::future::Future<Output = T> + Send + 'static,
93        T: Send + 'static,
94    {
95        // Acquire permit to ensure only one command executes at a time
96        let _permit = self.semaphore.acquire().await.unwrap();
97
98        // Check if we need to wait before executing
99        let mut last_cmd = self.last_command.lock().await;
100        let elapsed = last_cmd.elapsed();
101        if elapsed < self.min_delay {
102            let wait_time = self.min_delay - elapsed;
103            trace!("Rate limiting: waiting {:?} before next command", wait_time);
104            tokio::time::sleep(wait_time).await;
105        }
106
107        // Execute the command
108        let result = future.await;
109
110        // Update last command time
111        *last_cmd = std::time::Instant::now();
112
113        result
114    }
115}
116
117/// Main struct for controlling an LED strip via Bluetooth LE
118pub struct BleLedDevice {
119    /// The connected Bluetooth peripheral
120    peripheral: Peripheral,
121    /// Characteristic used for sending commands
122    write_characteristic: Characteristic,
123    /// Optional characteristic for reading device state
124    /// This is currently stored for future implementation of device status reading,
125    /// but not yet used in the current version.
126    #[allow(dead_code)]
127    read_characteristic: Option<Characteristic>,
128    /// Type of the connected device
129    device_type: DeviceType,
130    /// Device-specific configuration
131    config: DeviceConfig,
132    /// Command queue for rate limiting
133    command_queue: Arc<CommandQueue>,
134    /// Current power state
135    pub is_on: bool,
136    /// Current RGB color (red, green, blue)
137    pub rgb_color: (u8, u8, u8),
138    /// Current brightness (0-100)
139    pub brightness: u8,
140    /// Current effect mode if active
141    pub effect: Option<u8>,
142    /// Current effect speed if an effect is active
143    pub effect_speed: Option<u8>,
144    /// Current color temperature in Kelvin if using white mode
145    pub color_temp_kelvin: Option<u32>,
146}
147
148impl BleLedDevice {
149    /// Creates a new instance by scanning for and connecting to a compatible LED strip
150    /// and automatically powers it on
151    #[instrument]
152    pub async fn new() -> Result<BleLedDevice> {
153        let mut device = Self::new_without_power().await?;
154
155        // Power on by default
156        info!("Powering on device");
157        device.power_on().await?;
158
159        info!(
160            "Successfully connected to {} device",
161            device.get_device_type_name()
162        );
163
164        Ok(device)
165    }
166
167    /// Creates a new instance by scanning for and connecting to a compatible LED strip
168    /// without automatically powering it on
169    #[instrument]
170    pub async fn new_without_power() -> Result<BleLedDevice> {
171        info!("Initializing BLE LED controller");
172        let manager = Manager::new().await?;
173        let central = get_central(&manager).await?;
174
175        info!("Scanning for compatible BLE devices...");
176        central.start_scan(ScanFilter::default()).await?;
177
178        // Maximum time to wait for device discovery (10 seconds)
179        let max_discovery_time = Duration::from_secs(10);
180        let start_time = std::time::Instant::now();
181        let mut found_device = false;
182        let mut device: Option<(Peripheral, DeviceType)> = None;
183
184        // Poll for devices until we find a compatible one or timeout
185        while start_time.elapsed() < max_discovery_time && !found_device {
186            // Poll for new devices
187            let peripherals = central.peripherals().await?;
188            debug!("Found {} BLE peripherals so far", peripherals.len());
189
190            if !peripherals.is_empty() {
191                info!(
192                    "Checking {} BLE devices for compatibility...",
193                    peripherals.len()
194                );
195
196                // Check each peripheral for compatibility
197                for p in peripherals {
198                    if let Ok(Some(props)) = p.properties().await {
199                        if let Some(name) = props.local_name {
200                            debug!("Found device: {}", name);
201                            let device_type = if name.starts_with("ELK-BLE") {
202                                DeviceType::ElkBle
203                            } else if name.starts_with("LEDBLE") {
204                                DeviceType::LedBle
205                            } else if name.starts_with("MELK") {
206                                DeviceType::Melk
207                            } else if name.starts_with("ELK-BULB") {
208                                DeviceType::ElkBulb
209                            } else if name.starts_with("ELK-LAMPL") {
210                                DeviceType::ElkLampl
211                            } else {
212                                DeviceType::Unknown
213                            };
214
215                            if device_type != DeviceType::Unknown {
216                                info!(
217                                    "Found compatible device: {} (type: {:?})",
218                                    name, device_type
219                                );
220                                device = Some((p, device_type));
221                                found_device = true;
222                                break;
223                            }
224                        }
225                    }
226                }
227            }
228
229            if !found_device {
230                // Report scanning progress
231                let elapsed = start_time.elapsed().as_secs();
232                let remaining = max_discovery_time.as_secs() - elapsed;
233                info!(
234                    "Still scanning for compatible devices... ({} seconds remaining)",
235                    remaining
236                );
237                // Wait a moment before polling again
238                time::sleep(Duration::from_millis(500)).await;
239            }
240        }
241
242        // If we've timed out without finding a device, report and error
243        if !found_device {
244            central.stop_scan().await?;
245            error!(
246                "No compatible LED device found within {} seconds",
247                max_discovery_time.as_secs()
248            );
249            return Err(Error::NoCompatibleDevice);
250        }
251
252        if let Some((peripheral, device_type)) = device {
253            // Connection and fetching of characteristics
254            info!("Connecting to device...");
255            if !peripheral.is_connected().await? {
256                peripheral.connect().await?;
257            }
258
259            central.stop_scan().await?;
260            debug!("Discovering services...");
261            peripheral.discover_services().await?;
262
263            // Get configuration for this device type
264            let config = Self::get_device_config(device_type);
265            debug!("Using config for device type: {:?}", device_type);
266
267            // Create command queue with device-specific delay
268            let command_queue = Arc::new(CommandQueue::new(config.command_delay));
269
270            // Find write characteristic
271            let write_char = peripheral
272                .characteristics()
273                .into_iter()
274                .find(|c| c.uuid == config.write_uuid)
275                .ok_or(Error::CharacteristicNotFound(config.write_uuid.to_string()))?;
276
277            debug!("Found write characteristic: {}", write_char.uuid);
278
279            // Find read characteristic (may not be needed for all devices)
280            let read_char = peripheral
281                .characteristics()
282                .into_iter()
283                .find(|c| c.uuid == config.read_uuid);
284
285            if let Some(ref char) = read_char {
286                debug!("Found read characteristic: {}", char.uuid);
287            } else {
288                debug!("Read characteristic not found, but this is optional");
289            }
290
291            let device = BleLedDevice {
292                peripheral,
293                write_characteristic: write_char,
294                read_characteristic: read_char,
295                device_type,
296                config,
297                command_queue,
298                is_on: false,
299                rgb_color: (255, 255, 255),
300                brightness: 100,
301                effect: None,
302                effect_speed: None,
303                color_temp_kelvin: Some(5000),
304            };
305
306            // Sync time for devices that support it
307            if device_type == DeviceType::ElkBle
308                || device_type == DeviceType::ElkBulb
309                || device_type == DeviceType::ElkLampl
310            {
311                debug!("Synchronizing device time");
312                device.sync_time().await?;
313            }
314
315            info!(
316                "Successfully connected to {} device (without powering on)",
317                device.get_device_type_name()
318            );
319            Ok(device)
320        } else {
321            error!("No compatible LED device found");
322            Err(Error::NoCompatibleDevice)
323        }
324    }
325
326    /// Get configuration based on device type
327    fn get_device_config(device_type: DeviceType) -> DeviceConfig {
328        match device_type {
329            DeviceType::ElkBle => DeviceConfig {
330                write_uuid: Uuid::parse_str("0000fff3-0000-1000-8000-00805f9b34fb").unwrap(),
331                read_uuid: Uuid::parse_str("0000fff4-0000-1000-8000-00805f9b34fb").unwrap(),
332                turn_on_cmd: [0x7e, 0x00, 0x04, 0xf0, 0x00, 0x01, 0xff, 0x00, 0xef],
333                turn_off_cmd: [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef],
334                min_color_temp_k: 2700,
335                max_color_temp_k: 6500,
336                command_delay: 15, // 15 seems to be the lowest value supported
337            },
338            DeviceType::LedBle => DeviceConfig {
339                write_uuid: Uuid::parse_str("0000ffe1-0000-1000-8000-00805f9b34fb").unwrap(),
340                read_uuid: Uuid::parse_str("0000ffe2-0000-1000-8000-00805f9b34fb").unwrap(),
341                turn_on_cmd: [0x7e, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef],
342                turn_off_cmd: [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef],
343                min_color_temp_k: 2700,
344                max_color_temp_k: 6500,
345                command_delay: 15,
346            },
347            DeviceType::Melk => DeviceConfig {
348                write_uuid: Uuid::parse_str("0000fff3-0000-1000-8000-00805f9b34fb").unwrap(),
349                read_uuid: Uuid::parse_str("0000fff4-0000-1000-8000-00805f9b34fb").unwrap(),
350                turn_on_cmd: [0x7e, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef],
351                turn_off_cmd: [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef],
352                min_color_temp_k: 2700,
353                max_color_temp_k: 6500,
354                command_delay: 15,
355            },
356            DeviceType::ElkBulb | DeviceType::ElkLampl | DeviceType::Unknown => DeviceConfig {
357                write_uuid: Uuid::parse_str("0000fff3-0000-1000-8000-00805f9b34fb").unwrap(),
358                read_uuid: Uuid::parse_str("0000fff4-0000-1000-8000-00805f9b34fb").unwrap(),
359                turn_on_cmd: [0x7e, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef],
360                turn_off_cmd: [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef],
361                min_color_temp_k: 2700,
362                max_color_temp_k: 6500,
363                command_delay: 15,
364            },
365        }
366    }
367
368    /// Get the device type name as string
369    pub fn get_device_type_name(&self) -> &'static str {
370        match self.device_type {
371            DeviceType::ElkBle => "ELK-BLE",
372            DeviceType::LedBle => "LEDBLE",
373            DeviceType::Melk => "MELK",
374            DeviceType::ElkBulb => "ELK-BULB",
375            DeviceType::ElkLampl => "ELK-LAMPL",
376            DeviceType::Unknown => "Unknown",
377        }
378    }
379
380    /// Synchronizes the device's internal clock with the system time
381    #[instrument(skip(self))]
382    async fn sync_time(&self) -> Result<()> {
383        let system_time = chrono::Local::now();
384        debug!(
385            "Syncing device time to {}:{}:{} day:{}",
386            system_time.hour(),
387            system_time.minute(),
388            system_time.second(),
389            system_time.weekday().number_from_monday()
390        );
391
392        self.send_command(&[
393            0x7e,
394            0x00,
395            0x83,
396            system_time.hour() as u8,
397            system_time.minute() as u8,
398            system_time.second() as u8,
399            system_time.weekday().number_from_monday() as u8,
400            0x00,
401            0xef,
402        ])
403        .await?;
404
405        debug!("Time synchronization complete");
406        Ok(())
407    }
408
409    /// Sets a custom time on the device
410    ///
411    /// # Arguments
412    ///
413    /// * `hour` - Hour (0-23)
414    /// * `minute` - Minute (0-59)
415    /// * `second` - Second (0-59)
416    /// * `day_of_week` - Day of week (1-7, where 1 is Monday)
417    #[instrument(skip(self))]
418    pub async fn set_custom_time(
419        &self,
420        hour: u8,
421        minute: u8,
422        second: u8,
423        day_of_week: u8,
424    ) -> Result<()> {
425        let hour = hour.min(23);
426        let minute = minute.min(59);
427        let second = second.min(59);
428        let day_of_week = day_of_week.clamp(1, 7);
429
430        debug!(
431            "Setting custom time to {}:{}:{} day:{}",
432            hour, minute, second, day_of_week
433        );
434
435        self.send_command(&[
436            0x7e,
437            0x00,
438            0x83,
439            hour,
440            minute,
441            second,
442            day_of_week,
443            0x00,
444            0xef,
445        ])
446        .await?;
447
448        debug!("Custom time set successfully");
449        Ok(())
450    }
451
452    /// Turns the LED strip on
453    #[instrument(skip(self))]
454    pub async fn power_on(&mut self) -> Result<()> {
455        debug!("Turning LED strip on");
456        self.send_command(&self.config.turn_on_cmd).await?;
457        self.is_on = true;
458
459        // Add a small delay to ensure the command has been processed
460        time::sleep(Duration::from_millis(200)).await;
461        info!("LED strip powered on");
462        Ok(())
463    }
464
465    /// Turns the LED strip off
466    #[instrument(skip(self))]
467    pub async fn power_off(&mut self) -> Result<()> {
468        debug!("Turning LED strip off");
469        self.send_command(&self.config.turn_off_cmd).await?;
470        self.is_on = false;
471
472        // Add a small delay to ensure the command has been processed
473        time::sleep(Duration::from_millis(200)).await;
474        info!("LED strip powered off");
475        Ok(())
476    }
477
478    /// Sets the RGB color of the LED strip
479    ///
480    /// # Arguments
481    ///
482    /// * `red_value` - Red component (0-255)
483    /// * `green_value` - Green component (0-255)
484    /// * `blue_value` - Blue component (0-255)
485    #[instrument(skip(self))]
486    pub async fn set_color(
487        &mut self,
488        red_value: u8,
489        green_value: u8,
490        blue_value: u8,
491    ) -> Result<()> {
492        debug!(
493            "Setting color to RGB({}, {}, {})",
494            red_value, green_value, blue_value
495        );
496
497        // First, ensure we're in RGB mode (not an effect)
498        if self.effect.is_some() {
499            debug!("Disabling active effect before setting color");
500            // Send a pre-command to disable effects mode
501            self.send_command(&[0x7e, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef])
502                .await?;
503            // Add a small delay after disabling effect
504            time::sleep(Duration::from_millis(200)).await;
505        }
506
507        // Now set the RGB color
508        trace!("Sending RGB color command");
509        self.send_command(&[
510            0x7e,
511            0x00,
512            0x05,
513            0x03,
514            red_value,
515            green_value,
516            blue_value,
517            0x00,
518            0xef,
519        ])
520        .await?;
521
522        // Update the state
523        self.rgb_color = (red_value, green_value, blue_value);
524        self.effect = None; // Setting a static color disables any active effect
525
526        // Add a small delay to ensure the command has been processed
527        time::sleep(Duration::from_millis(200)).await;
528        info!(
529            "Color set to RGB({}, {}, {})",
530            red_value, green_value, blue_value
531        );
532        Ok(())
533    }
534
535    /// Sets the brightness level
536    ///
537    /// # Arguments
538    ///
539    /// * `value` - Brightness level (0-100)
540    #[instrument(skip(self))]
541    pub async fn set_brightness(&mut self, value: u8) -> Result<()> {
542        let limited_value = value.min(100);
543        if value > 100 {
544            warn!(
545                "Brightness value {} out of range (0-100), limiting to 100",
546                value
547            );
548        }
549
550        debug!("Setting brightness to {}%", limited_value);
551        self.send_command(&[
552            0x7e,
553            0x00,
554            0x01,
555            limited_value,
556            0x00,
557            0x00,
558            0x00,
559            0x00,
560            0xef,
561        ])
562        .await?;
563
564        self.brightness = limited_value;
565
566        info!("Brightness set to {}%", limited_value);
567        Ok(())
568    }
569
570    /// Sets a light effect mode
571    ///
572    /// # Arguments
573    ///
574    /// * `value` - Effect code (use the EFFECTS constant)
575    #[instrument(skip(self))]
576    pub async fn set_effect(&mut self, value: u8) -> Result<()> {
577        debug!("Setting effect mode to code: {:#04x}", value);
578
579        // Send the effect command with retries
580        self.send_command(&[0x7e, 0x00, 0x03, value, 0x03, 0x00, 0x00, 0x00, 0xef])
581            .await?;
582
583        self.effect = Some(value);
584
585        // Add a small delay to ensure the command has been processed
586        time::sleep(Duration::from_millis(200)).await;
587        info!("Effect mode set successfully");
588        Ok(())
589    }
590
591    /// Sets the speed of the current effect
592    ///
593    /// # Arguments
594    ///
595    /// * `value` - Effect speed (0-100)
596    #[instrument(skip(self))]
597    pub async fn set_effect_speed(&mut self, value: u8) -> Result<()> {
598        let limited_value = value.min(100);
599        if value > 100 {
600            warn!(
601                "Effect speed {} out of range (0-100), limiting to 100",
602                value
603            );
604        }
605
606        if self.effect.is_none() {
607            warn!("Setting effect speed without an active effect. This may not have any effect.");
608        }
609
610        debug!("Setting effect speed to {}", limited_value);
611        // Send the effect speed command with retries
612        self.send_command(&[
613            0x7e,
614            0x00,
615            0x02,
616            limited_value,
617            0x00,
618            0x00,
619            0x00,
620            0x00,
621            0xef,
622        ])
623        .await?;
624
625        self.effect_speed = Some(limited_value);
626
627        // Add a small delay to ensure the command has been processed
628        time::sleep(Duration::from_millis(200)).await;
629        info!("Effect speed set to {}", limited_value);
630        Ok(())
631    }
632
633    /// Sets the color temperature in Kelvin for white light
634    ///
635    /// # Arguments
636    ///
637    /// * `value` - Color temperature in Kelvin (typically 2700-6500)
638    #[instrument(skip(self))]
639    pub async fn set_color_temp_kelvin(&mut self, value: u32) -> Result<()> {
640        // Ensure value is within range
641        let temp = value
642            .max(self.config.min_color_temp_k)
643            .min(self.config.max_color_temp_k);
644
645        if value < self.config.min_color_temp_k || value > self.config.max_color_temp_k {
646            warn!(
647                "Color temperature {} out of range ({}-{}), adjusting to {}",
648                value, self.config.min_color_temp_k, self.config.max_color_temp_k, temp
649            );
650        }
651
652        debug!("Setting color temperature to {}K", temp);
653
654        // Calculate color temp percent (0-100) from kelvin value
655        let color_temp_percent = ((temp - self.config.min_color_temp_k) * 100
656            / (self.config.max_color_temp_k - self.config.min_color_temp_k))
657            as u8;
658
659        // Set warm/cold values
660        let warm = color_temp_percent;
661        let cold = 100 - color_temp_percent;
662
663        // First, ensure we're in white mode (not an effect)
664        if self.effect.is_some() {
665            debug!("Disabling active effect before setting color temperature");
666            // Send a pre-command to disable effects mode
667            self.send_command(&[0x7e, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef])
668                .await?;
669            // Add a small delay after disabling effect
670            time::sleep(Duration::from_millis(200)).await;
671        }
672
673        // Now set the color temperature
674        trace!(
675            "Sending color temperature command: warm={}, cold={}",
676            warm,
677            cold
678        );
679        self.send_command(&[0x7e, 0x00, 0x05, 0x02, warm, cold, 0x00, 0x00, 0xef])
680            .await?;
681
682        self.color_temp_kelvin = Some(temp);
683        self.effect = None; // Setting color temp disables any active effect
684
685        // Add a small delay to ensure the command has been processed
686        time::sleep(Duration::from_millis(200)).await;
687        info!("Color temperature set to {}K", temp);
688        Ok(())
689    }
690
691    /// Sets a schedule to turn on the device
692    ///
693    /// # Arguments
694    ///
695    /// * `days` - Bitmask of days (use the WEEK_DAYS constants)
696    /// * `hours` - Hour to turn on (0-23)
697    /// * `minutes` - Minute to turn on (0-59)
698    /// * `enabled` - Whether to enable or disable this schedule
699    #[instrument(skip(self))]
700    pub async fn set_schedule_on(
701        &self,
702        days: u8,
703        hours: u8,
704        minutes: u8,
705        enabled: bool,
706    ) -> Result<()> {
707        let hours = hours.min(23);
708        let minutes = minutes.min(59);
709        let value = if enabled { days + 0x80 } else { days };
710
711        debug!(
712            "Setting schedule to turn on at {}:{:02} on days: {:#04x}, enabled: {}",
713            hours, minutes, days, enabled
714        );
715
716        self.send_command(&[0x7e, 0x00, 0x82, hours, minutes, 0x00, 0x00, value, 0xef])
717            .await?;
718
719        // Add a small delay to ensure the command has been processed
720        time::sleep(Duration::from_millis(200)).await;
721        info!("Schedule set to turn on at {}:{:02}", hours, minutes);
722        Ok(())
723    }
724
725    /// Sets a schedule to turn off the device
726    ///
727    /// # Arguments
728    ///
729    /// * `days` - Bitmask of days (use the WEEK_DAYS constants)
730    /// * `hours` - Hour to turn off (0-23)
731    /// * `minutes` - Minute to turn off (0-59)
732    /// * `enabled` - Whether to enable or disable this schedule
733    #[instrument(skip(self))]
734    pub async fn set_schedule_off(
735        &self,
736        days: u8,
737        hours: u8,
738        minutes: u8,
739        enabled: bool,
740    ) -> Result<()> {
741        let hours = hours.min(23);
742        let minutes = minutes.min(59);
743        let value = if enabled { days + 0x80 } else { days };
744
745        debug!(
746            "Setting schedule to turn off at {}:{:02} on days: {:#04x}, enabled: {}",
747            hours, minutes, days, enabled
748        );
749
750        self.send_command(&[0x7e, 0x00, 0x82, hours, minutes, 0x00, 0x01, value, 0xef])
751            .await?;
752
753        // Add a small delay to ensure the command has been processed
754        time::sleep(Duration::from_millis(200)).await;
755        info!("Schedule set to turn off at {}:{:02}", hours, minutes);
756        Ok(())
757    }
758
759    /// Sends a generic command to the device with retries
760    ///
761    /// # Arguments
762    ///
763    /// * `id` - Command ID
764    /// * `sub_id` - Sub command ID
765    /// * `arg1` - First argument
766    /// * `arg2` - Second argument
767    /// * `arg3` - Third argument
768    #[instrument(skip(self))]
769    pub async fn generic_command(
770        &self,
771        id: u8,
772        sub_id: u8,
773        arg1: u8,
774        arg2: u8,
775        arg3: u8,
776    ) -> Result<()> {
777        debug!(
778            "Sending generic command: id={:#04x}, sub_id={:#04x}, args=[{:#04x}, {:#04x}, {:#04x}]",
779            id, sub_id, arg1, arg2, arg3
780        );
781
782        self.send_command(&[0x7e, 0x00, id, sub_id, arg1, arg2, arg3, 0x00, 0xef])
783            .await?;
784        debug!("Generic command sent successfully");
785        Ok(())
786    }
787
788    /// Helper function to ensure commands are sent reliably with rate limiting
789    #[instrument(skip(self, command), fields(cmd_length = command.len()))]
790    async fn send_command(&self, command: &[u8]) -> Result<()> {
791        // Create a clone of the command for the async block
792        let cmd = command.to_vec();
793        let peripheral = self.peripheral.clone();
794        let write_characteristic = self.write_characteristic.clone();
795
796        // Use the command queue to handle rate limiting
797        self.command_queue
798            .execute(async move {
799                // TODO: Fix this as delay is not working
800                // BLE can be unreliable, so we implement retries
801                let max_retries = 3;
802                let mut attempt = 0;
803
804                // Determine write type - prefer WriteWithResponse when supported
805                let write_type = if write_characteristic
806                    .properties
807                    .contains(btleplug::api::CharPropFlags::WRITE)
808                {
809                    WriteType::WithResponse
810                } else {
811                    WriteType::WithoutResponse
812                };
813
814                while attempt < max_retries {
815                    trace!(
816                        "Sending BLE command (attempt {}/{})",
817                        attempt + 1,
818                        max_retries
819                    );
820
821                    match peripheral
822                        .write(&write_characteristic, &cmd, write_type)
823                        .await
824                    {
825                        Ok(_) => {
826                            trace!("Command sent successfully");
827                            return Ok(());
828                        }
829                        Err(e) => {
830                            attempt += 1;
831                            warn!(
832                                "Command failed (attempt {}/{}): {}",
833                                attempt, max_retries, e
834                            );
835
836                            if attempt < max_retries {
837                                // Wait a bit before retrying
838                                trace!("Waiting before retry...");
839                                tokio::time::sleep(std::time::Duration::from_millis(300)).await;
840                            } else {
841                                // Log the last error
842                                error!("Command failed permanently: {}", e);
843                                return Err(Error::BleError(e.to_string()));
844                            }
845                        }
846                    }
847                }
848
849                // Should never get here, but just in case
850                error!("Command failed after {} attempts", max_retries);
851                Err(Error::CommandTimeout(max_retries))
852            })
853            .await
854    }
855}