lighthouse_manager/
bluetooth.rs1use 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
16pub 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![], })
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 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, 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
72fn is_lighthouse_name(name: &str) -> bool {
74 name.starts_with("HTC BS") || name.starts_with("LHB-")
75}
76
77async fn get_local_name(peripheral: &Peripheral) -> Option<String> {
79 peripheral.properties().await.ok()??.local_name
80}
81
82pub 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 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 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
123pub struct ConnectedPeripheral {
125 pub(crate) peripheral: Peripheral,
126}
127
128impl ConnectedPeripheral {
129 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 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 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 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 pub async fn identify(&self, lh: &Lighthouse) -> Result<()> {
213 protocol::build_identify_command(lh).map_err(|e| anyhow!("{e}"))?; 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 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
232pub 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 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
283pub 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 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}