gestura_core_ring/backends/
simulator.rs

1use crate::{DeviceStatus, RingBackend};
2use async_trait::async_trait;
3use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
4use btleplug::platform::{Adapter, Peripheral};
5use futures::stream::StreamExt;
6use gestura_core_gestures::Gesture;
7use gestura_core_haptics::HapticPattern;
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10use tokio::sync::{Mutex, broadcast};
11use uuid::Uuid;
12
13const SIMULATOR_SERVICE_UUID: Uuid = Uuid::from_u128(0x12345678_1234_5678_9abc_123456789abc);
14
15/// Raw gestures emitted by the simulator matching the requested schema.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type")]
18pub enum SimulatorRawGesture {
19    Tap {
20        intensity: f32,
21    },
22    DoubleTap,
23    Hold {
24        start_time: u64,
25    },
26    Slide {
27        direction: SlideDirection,
28        distance: u32,
29    },
30    Tilt {
31        angle: f32,
32    },
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub enum SlideDirection {
37    Up,
38    Down,
39    Left,
40    Right,
41}
42
43impl SimulatorRawGesture {
44    /// Safely normalize the proprietary BLE enum into the generic `Gesture` struct,
45    /// explicitly isolating intent parsing to the `gestura-core-intent` crate layer.
46    ///
47    /// `gesture_type` is always one of a bounded closed set that aligns exactly
48    /// with the strings recognised by `gestura-core-intent::gesture_to_action`:
49    /// `tap`, `double_tap`, `hold`, `tilt_up`, `tilt_down`, `tilt_left`,
50    /// `tilt_right`.
51    ///
52    /// `Slide` directions are mapped to the corresponding `tilt_*` string so
53    /// they route to meaningful actions (`scroll_up`, `scroll_down`, `previous`,
54    /// `next`) instead of falling through to `unknown_gesture`.
55    ///
56    /// Physical `Tilt` direction is derived from the sign of the `angle` field:
57    /// non-negative → `tilt_right`, negative → `tilt_left`.
58    ///
59    /// Numeric values (intensity, distance, angle) are carried in the
60    /// `acceleration`/`gyroscope` sensor fields so downstream normalisation
61    /// never needs to parse the type string.
62    pub fn into_gesture(self) -> Gesture {
63        match self {
64            Self::Tap { intensity } => Gesture {
65                gesture_type: "tap".to_string(),
66                // Map tap intensity directly to gesture confidence so
67                // downstream intent normalization can weight the signal.
68                confidence: intensity.clamp(0.0, 1.0),
69                acceleration: None,
70                gyroscope: None,
71            },
72            Self::DoubleTap => Gesture {
73                gesture_type: "double_tap".to_string(),
74                confidence: 1.0,
75                acceleration: None,
76                gyroscope: None,
77            },
78            Self::Hold { .. } => Gesture {
79                gesture_type: "hold".to_string(),
80                confidence: 1.0,
81                acceleration: None,
82                gyroscope: None,
83            },
84            Self::Slide {
85                direction,
86                distance,
87            } => {
88                // Map slide directions to the tilt_* strings that
89                // gesture_to_action recognises so slides route to meaningful
90                // primary actions (scroll_up / scroll_down / previous / next)
91                // rather than falling through to "unknown_gesture".
92                let tilt_type = match direction {
93                    SlideDirection::Up => "tilt_up",
94                    SlideDirection::Down => "tilt_down",
95                    SlideDirection::Left => "tilt_left",
96                    SlideDirection::Right => "tilt_right",
97                };
98                Gesture {
99                    gesture_type: tilt_type.to_string(),
100                    confidence: 1.0,
101                    // Carry slide distance as the x-axis acceleration component
102                    // so downstream can read it without parsing the type string.
103                    acceleration: Some([distance as f32, 0.0, 0.0]),
104                    gyroscope: None,
105                }
106            }
107            Self::Tilt { angle } => {
108                // Derive direction from the sign of the angle so the emitted
109                // string is in the recognised set (tilt_right / tilt_left).
110                let tilt_type = if angle >= 0.0 {
111                    "tilt_right"
112                } else {
113                    "tilt_left"
114                };
115                Gesture {
116                    gesture_type: tilt_type.to_string(),
117                    confidence: 1.0,
118                    acceleration: None,
119                    // Carry the raw angle in the x-axis gyroscope component so
120                    // downstream can read it without parsing the type string.
121                    gyroscope: Some([angle, 0.0, 0.0]),
122                }
123            }
124        }
125    }
126}
127
128pub struct SimulatorBackend {
129    tx: broadcast::Sender<Gesture>,
130    peripheral: Arc<Mutex<Option<Peripheral>>>,
131    tx_char: Arc<Mutex<Option<Characteristic>>>,
132    adapter: Arc<Mutex<Option<Adapter>>>,
133}
134
135impl Default for SimulatorBackend {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl SimulatorBackend {
142    pub fn new() -> Self {
143        let (tx, _) = broadcast::channel(100);
144        Self {
145            tx,
146            peripheral: Arc::new(Mutex::new(None)),
147            tx_char: Arc::new(Mutex::new(None)),
148            adapter: Arc::new(Mutex::new(None)),
149        }
150    }
151
152    async fn find_simulator(&self) -> Result<Peripheral, String> {
153        let (adapter, peripheral) = gestura_core_ble::scanner::find_device_by_service_uuid(
154            SIMULATOR_SERVICE_UUID,
155            10,
156            std::time::Duration::from_millis(500),
157        )
158        .await?;
159
160        *self.adapter.lock().await = Some(adapter);
161        Ok(peripheral)
162    }
163
164    /// Spawns a background task monitoring notifications from the peripheral
165    fn spawn_event_listener(&self, peripheral: Peripheral, characteristic: Characteristic) {
166        let tx = self.tx.clone();
167        tokio::spawn(async move {
168            if let Err(e) = peripheral.subscribe(&characteristic).await {
169                tracing::error!("Failed to subscribe to gesture characteristics: {}", e);
170                return;
171            }
172
173            let mut notification_stream = match peripheral.notifications().await {
174                Ok(stream) => stream,
175                Err(e) => {
176                    tracing::error!("Failed to get notification stream: {}", e);
177                    return;
178                }
179            };
180
181            tracing::info!("Started listening for Simulator raw gestures");
182
183            while let Some(data) = notification_stream.next().await {
184                if let Ok(raw_str) = String::from_utf8(data.value)
185                    && let Ok(raw_gest) = serde_json::from_str::<SimulatorRawGesture>(&raw_str)
186                {
187                    let gesture = raw_gest.into_gesture();
188                    let _ = tx.send(gesture);
189                }
190            }
191        });
192    }
193
194    /// Helper for testing environment simulating Tauri invoke fallback.
195    pub async fn _untested_tauri_fallback_trigger(&self, raw: SimulatorRawGesture) {
196        let _ = self.tx.send(raw.into_gesture());
197    }
198}
199
200#[async_trait]
201impl RingBackend for SimulatorBackend {
202    async fn connect(&self) -> Result<(), String> {
203        tracing::info!("SimulatorBackend initializing connection sequence");
204        let peripheral = self.find_simulator().await?;
205
206        peripheral
207            .connect()
208            .await
209            .map_err(|e| format!("Connection failed: {}", e))?;
210        peripheral
211            .discover_services()
212            .await
213            .map_err(|e| format!("Service discovery failed: {}", e))?;
214
215        let chars = peripheral.characteristics();
216        // Just find a characteristic we can subscribe to/write to for gestures
217        // In a real device we explicitly target a rx/tx uuid pair.
218        let notify_char = chars
219            .iter()
220            .find(|c| c.properties.contains(CharPropFlags::NOTIFY));
221        let write_char = chars
222            .iter()
223            .find(|c| {
224                c.properties.contains(CharPropFlags::WRITE)
225                    || c.properties.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
226            })
227            .cloned();
228
229        *self.peripheral.lock().await = Some(peripheral.clone());
230        *self.tx_char.lock().await = write_char;
231
232        if let Some(c) = notify_char {
233            self.spawn_event_listener(peripheral, c.clone());
234        } else {
235            return Err("Simulator connected but no NOTIFY characteristic found; gestures will never be emitted.".to_string());
236        }
237
238        tracing::info!("SimulatorBackend successfully fully bound BLE channel");
239        Ok(())
240    }
241
242    async fn subscribe_to_gestures(&self) -> tokio::sync::broadcast::Receiver<Gesture> {
243        self.tx.subscribe()
244    }
245
246    async fn send_haptic(&self, pattern: HapticPattern, intensity: f32, duration_ms: u32) {
247        tracing::debug!(
248            "SimulatorBackend sending haptic: {:?} (int: {}, dur: {}ms)",
249            pattern,
250            intensity,
251            duration_ms
252        );
253
254        let p_lock = self.peripheral.lock().await;
255        let c_lock = self.tx_char.lock().await;
256
257        if let (Some(peripheral), Some(char)) = (&*p_lock, &*c_lock) {
258            let command_json = serde_json::json!({
259                "command": "trigger_haptic",
260                "pattern": pattern,
261                "intensity": intensity,
262                "duration_ms": duration_ms
263            })
264            .to_string();
265
266            // Choose the write type the characteristic actually supports.
267            // Blindly using WithoutResponse on a WRITE-only characteristic
268            // produces a silent failure; inspect the flags first.
269            let write_type = if char
270                .properties
271                .contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
272            {
273                btleplug::api::WriteType::WithoutResponse
274            } else {
275                btleplug::api::WriteType::WithResponse
276            };
277
278            if let Err(e) = peripheral
279                .write(char, command_json.as_bytes(), write_type)
280                .await
281            {
282                tracing::warn!(
283                    pattern = ?pattern,
284                    "Failed to send haptic feedback via BLE write: {}",
285                    e
286                );
287            }
288        }
289    }
290
291    async fn get_status(&self) -> DeviceStatus {
292        let is_connected = self.peripheral.lock().await.is_some();
293        DeviceStatus {
294            battery: 100,
295            is_charging: false,
296            connection_state: if is_connected {
297                "simulator_ble_connected".to_string()
298            } else {
299                "simulator_disconnected".to_string()
300            },
301        }
302    }
303}