Skip to main content

lighthouse_manager/
bluetooth.rs

1use anyhow::{Context, Result, anyhow, bail};
2pub use btleplug::platform::Adapter;
3pub use btleplug::platform::Manager;
4pub use btleplug::platform::Peripheral;
5
6use btleplug::api::{BDAddr, Central as _, Manager as _, Peripheral as _, ScanFilter, WriteType};
7use std::collections::HashSet;
8use std::str::FromStr;
9use std::time::Duration;
10use tracing::{debug, info, warn};
11use uuid::Uuid;
12
13use crate::lighthouse::Lighthouse;
14use crate::protocol;
15
16/// Discover nearby lighthouses by scanning BLE advertisements for a given duration.
17/// Filters results to only devices whose name starts with "HTC BS" or "LHB-".
18///
19/// # Errors
20///
21/// Returns an error if the BLE scan fails to start, stop, or retrieve peripherals,
22/// or if any underlying Bluetooth adapter operation fails.
23pub async fn discover_lighthouses(adapter: &Adapter, timeout_secs: u64) -> Result<Vec<Lighthouse>> {
24    info!(
25        "Starting Bluetooth LE discovery for {} seconds...",
26        timeout_secs
27    );
28
29    adapter
30        .start_scan(ScanFilter {
31            services: vec![], // Scan all to catch name-based matches
32        })
33        .await
34        .context("Failed to start BLE scan")?;
35
36    tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
37
38    adapter
39        .stop_scan()
40        .await
41        .context("Failed to stop BLE scan")?;
42
43    let peripherals = adapter
44        .peripherals()
45        .await
46        .context("Failed to get discovered peripherals")?;
47
48    info!("Scan complete. Found {} raw devices", peripherals.len());
49
50    // Filter for lighthouse name patterns
51    let mut lighthouses = Vec::new();
52    for peripheral in &peripherals {
53        let address_str = peripheral.address().to_string();
54        if let Some(name) = get_local_name(peripheral).await
55            && is_lighthouse_name(&name)
56        {
57            let lh = Lighthouse {
58                name,
59                address: address_str.clone(),
60                id: None, // Will be filled from DB if available
61                managed: false,
62            };
63            debug!("Discovered lighthouse: {} ({})", lh.name, address_str);
64            lighthouses.push(lh);
65        }
66    }
67
68    info!("Found {} Lighthouse(s)", lighthouses.len());
69    Ok(lighthouses)
70}
71
72/// Check if a device name matches known Lighthouse naming patterns.
73fn is_lighthouse_name(name: &str) -> bool {
74    name.starts_with("HTC BS") || name.starts_with("LHB-")
75}
76
77/// Get the local name from a peripheral's properties.
78async fn get_local_name(peripheral: &Peripheral) -> Option<String> {
79    peripheral.properties().await.ok()??.local_name
80}
81
82/// Connect to a specific lighthouse by its Bluetooth address.
83///
84/// # Errors
85///
86/// Returns an error if the address is invalid, the peripheral is not found,
87/// or any BLE connection/discovery operation fails.
88pub async fn connect_lighthouse(
89    adapter: &Adapter,
90    address_str: &str,
91) -> Result<ConnectedPeripheral> {
92    let target_addr = BDAddr::from_str(address_str)
93        .map_err(|e| anyhow!("Invalid Bluetooth address format '{address_str}': {e}"))?;
94
95    // Find the peripheral in the adapter's known peripherals
96    let peripherals = adapter
97        .peripherals()
98        .await
99        .context("Failed to get adapter peripherals")?;
100
101    let peripheral = peripherals
102        .into_iter()
103        .find(|p| p.address() == target_addr)
104        .ok_or_else(|| anyhow!("Peripheral not found: {address_str}"))?;
105
106    info!("Connecting to {}...", peripheral.address());
107    peripheral
108        .connect()
109        .await
110        .context("Failed to connect to device")?;
111    info!("Connected to {}", peripheral.address());
112
113    // Discover services and characteristics
114    peripheral
115        .discover_services()
116        .await
117        .context("Failed to discover GATT services")?;
118    debug!("Services discovered for {}", peripheral.address());
119
120    Ok(ConnectedPeripheral { peripheral })
121}
122
123/// A connected lighthouse device ready for GATT operations.
124pub struct ConnectedPeripheral {
125    pub(crate) peripheral: Peripheral,
126}
127
128impl ConnectedPeripheral {
129    /// Write data to a characteristic and disconnect.
130    async fn write_and_disconnect(&self, uuid_str: &str, data: &[u8]) -> Result<()> {
131        let uuid = Uuid::parse_str(uuid_str).map_err(|_| anyhow!("Invalid UUID: {uuid_str}"))?;
132
133        // Retry logic: 5 attempts × 1s delay
134        for attempt in 1..=5 {
135            debug!(
136                "Write attempt {}/5 to characteristic {} on device {}",
137                attempt,
138                uuid_str,
139                self.peripheral.address()
140            );
141
142            match self.write_characteristic(&uuid, data).await {
143                Ok(()) => {
144                    info!(
145                        "Successfully wrote {} bytes to {} on {}",
146                        data.len(),
147                        uuid_str,
148                        self.peripheral.address()
149                    );
150                    return Ok(());
151                }
152                Err(e) if attempt < 5 => {
153                    warn!("Write attempt {} failed: {}. Retrying in 1s...", attempt, e);
154                    tokio::time::sleep(Duration::from_secs(1)).await;
155                }
156                Err(e) => {
157                    bail!("Failed to write to characteristic {uuid_str} after 5 attempts: {e}");
158                }
159            }
160        }
161        unreachable!()
162    }
163
164    async fn write_characteristic(&self, uuid: &Uuid, data: &[u8]) -> Result<()> {
165        let chars = self.peripheral.characteristics();
166        let char = chars.iter().find(|c| c.uuid == *uuid).ok_or_else(|| {
167            anyhow!(
168                "Characteristic {} not found on device {} (has {} characteristics)",
169                uuid,
170                self.peripheral.address(),
171                chars.len()
172            )
173        })?;
174
175        self.peripheral
176            .write(char, data, WriteType::WithoutResponse)
177            .await
178            .context("Failed to write characteristic")?;
179        Ok(())
180    }
181
182    /// Power on the connected lighthouse.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the power command cannot be built (e.g. missing V1 ID)
187    /// or if writing to the GATT characteristic fails after retries.
188    pub async fn power_on(&self, lh: &Lighthouse) -> Result<()> {
189        let cmd = protocol::build_power_command(lh).map_err(|e| anyhow!("{e}"))?;
190        self.write_and_disconnect(lh.power_characteristic(), &cmd)
191            .await
192    }
193
194    /// Sleep the connected lighthouse.
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if the sleep command cannot be built (e.g. missing V1 ID)
199    /// or if writing to the GATT characteristic fails after retries.
200    pub async fn sleep(&self, lh: &Lighthouse) -> Result<()> {
201        let cmd = protocol::build_sleep_command(lh).map_err(|e| anyhow!("{e}"))?;
202        self.write_and_disconnect(lh.power_characteristic(), &cmd)
203            .await
204    }
205
206    /// Identify the connected lighthouse (V2 only — causes LED flash).
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the lighthouse is not V2, or if writing to the
211    /// identify characteristic fails after retries.
212    pub async fn identify(&self, lh: &Lighthouse) -> Result<()> {
213        protocol::build_identify_command(lh).map_err(|e| anyhow!("{e}"))?; // validates version
214        let cmd = protocol::build_v2_identify();
215        let uuid = lh
216            .identify_characteristic()
217            .ok_or_else(|| anyhow!("Identify is not supported on this lighthouse"))?;
218        self.write_and_disconnect(uuid, &cmd).await
219    }
220
221    /// Disconnect the device.
222    pub async fn disconnect(self) {
223        info!("Disconnecting from {}...", self.peripheral.address());
224        if let Err(e) = self.peripheral.disconnect().await {
225            warn!("Failed to disconnect {}: {}", self.peripheral.address(), e);
226        } else {
227            debug!("Disconnected from {}", self.peripheral.address());
228        }
229    }
230}
231
232/// Start a BLE scan, poll until a predicate is satisfied (or timeout), then stop the scan.
233/// Returns the final set of discovered peripheral addresses (lowercase).
234///
235/// # Errors
236///
237/// Returns an error if the initial BLE scan fails to start.
238pub async fn scan_until_predicate<F>(adapter: &Adapter, predicate: F) -> Result<HashSet<String>>
239where
240    F: Fn(&HashSet<String>) -> bool + Send,
241{
242    adapter
243        .start_scan(ScanFilter { services: vec![] })
244        .await
245        .context("Failed to start BLE scan")?;
246
247    let poll_interval = Duration::from_millis(500);
248    let timeout = Duration::from_secs(15);
249    let deadline = tokio::time::Instant::now() + timeout;
250
251    loop {
252        if tokio::time::Instant::now() >= deadline {
253            break;
254        }
255
256        if let Ok(peripherals) = adapter.peripherals().await {
257            let discovered: HashSet<String> = peripherals
258                .iter()
259                .map(|p| p.address().to_string().to_lowercase())
260                .collect();
261
262            if predicate(&discovered) {
263                break;
264            }
265        }
266
267        tokio::time::sleep(poll_interval).await;
268    }
269
270    adapter.stop_scan().await.ok();
271
272    // Return the final discovered set.
273    Ok(if let Ok(peripherals) = adapter.peripherals().await {
274        peripherals
275            .iter()
276            .map(|p| p.address().to_string().to_lowercase())
277            .collect()
278    } else {
279        HashSet::new()
280    })
281}
282
283/// Get the first available Bluetooth adapter. Returns an error if none found.
284///
285/// # Errors
286///
287/// Returns an error if the BLE manager cannot be created, no adapters are enumerated,
288/// or no adapters are available.
289pub async fn get_adapter() -> Result<Adapter> {
290    let manager = Manager::new()
291        .await
292        .map_err(|e| anyhow!("Failed to create BLE manager: {e}"))?;
293    let adapters = manager
294        .adapters()
295        .await
296        .context("Failed to enumerate Bluetooth adapters")?;
297
298    if adapters.is_empty() {
299        bail!("No Bluetooth adapter found. Please ensure a Bluetooth adapter is available.");
300    }
301
302    let adapter = &adapters[0];
303    // Use adapter_info for debug output since Adapter doesn't have address() method
304    info!(
305        "Using Bluetooth adapter at index 0 ({} adapters total)",
306        adapters.len()
307    );
308    Ok(adapter.clone())
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_is_lighthouse_name_v1() {
317        assert!(is_lighthouse_name("HTC BS-AABBCCDD"));
318        assert!(is_lighthouse_name("HTC BS-12345678"));
319        assert!(!is_lighthouse_name("OtherDevice"));
320    }
321
322    #[test]
323    fn test_is_lighthouse_name_v2() {
324        assert!(is_lighthouse_name("LHB-0A1B2C3D"));
325        assert!(is_lighthouse_name("LHB-AABBCCDD"));
326        assert!(!is_lighthouse_name("LBH-Something"));
327    }
328}