1use anyhow::{Context, Result};
18use rusqlite::{params, Connection, OptionalExtension};
19use std::path::PathBuf;
20use std::sync::Arc;
21use tokio::sync::Mutex;
22
23use super::protocol::DeviceType;
24
25#[derive(Clone, Debug)]
27pub struct DeviceRecord {
28 pub device_id: String,
29 pub device_type: DeviceType,
30 pub name: String,
31 pub token_hash: String,
33 pub last_seen: Option<i64>,
35 pub battery_pct: Option<i32>,
37 pub prefs: serde_json::Value,
39 pub ambient_meeting_id: Option<String>,
43 pub created_at: i64,
44}
45
46pub fn hash_token(raw: &str) -> String {
51 use sha2::{Digest, Sha256};
52 let digest = Sha256::digest(raw.as_bytes());
53 hex::encode(digest)
54}
55
56fn now_ms() -> i64 {
57 chrono::Utc::now().timestamp_millis()
58}
59
60#[derive(Clone)]
62pub struct DeviceStore {
63 conn: Arc<Mutex<Connection>>,
64}
65
66impl DeviceStore {
67 pub fn open(path: PathBuf) -> Result<Self> {
71 if let Some(parent) = path.parent() {
72 std::fs::create_dir_all(parent)
73 .with_context(|| format!("creating db dir {}", parent.display()))?;
74 }
75 let conn = Connection::open(&path)
76 .with_context(|| format!("opening hardware db {}", path.display()))?;
77 Self::init_schema(&conn)?;
78 Ok(Self {
79 conn: Arc::new(Mutex::new(conn)),
80 })
81 }
82
83 fn init_schema(conn: &Connection) -> Result<()> {
84 conn.execute_batch(
85 "PRAGMA journal_mode = WAL;
86 CREATE TABLE IF NOT EXISTS devices (
87 device_id TEXT PRIMARY KEY,
88 device_type TEXT NOT NULL,
89 name TEXT NOT NULL,
90 token_hash TEXT NOT NULL,
91 last_seen INTEGER,
92 battery_pct INTEGER,
93 prefs TEXT NOT NULL DEFAULT '{}',
94 ambient_meeting_id TEXT,
95 created_at INTEGER NOT NULL
96 );",
97 )
98 .context("initializing hardware schema")?;
99 Ok(())
100 }
101
102 fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<DeviceRecord> {
103 let device_type_str: String = row.get(1)?;
104 let prefs_str: String = row.get(6)?;
105 Ok(DeviceRecord {
106 device_id: row.get(0)?,
107 device_type: super::protocol::parse_device_type(&device_type_str)
108 .unwrap_or(DeviceType::Necklace),
109 name: row.get(2)?,
110 token_hash: row.get(3)?,
111 last_seen: row.get(4)?,
112 battery_pct: row.get(5)?,
113 prefs: serde_json::from_str(&prefs_str).unwrap_or(serde_json::Value::Null),
114 ambient_meeting_id: row.get(7)?,
115 created_at: row.get(8)?,
116 })
117 }
118
119 pub async fn insert(&self, record: DeviceRecord) -> Result<DeviceRecord> {
122 let device_type = super::protocol::device_type_str(record.device_type).to_string();
123 let prefs = serde_json::to_string(&record.prefs).unwrap_or_else(|_| "{}".to_string());
124 let conn = self.conn.lock().await;
125 conn.execute(
126 "INSERT INTO devices
127 (device_id, device_type, name, token_hash, last_seen, battery_pct, prefs, ambient_meeting_id, created_at)
128 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
129 ON CONFLICT(device_id) DO UPDATE SET
130 device_type = ?2, name = ?3, token_hash = ?4, prefs = ?7",
131 params![
132 record.device_id,
133 device_type,
134 record.name,
135 record.token_hash,
136 record.last_seen,
137 record.battery_pct,
138 prefs,
139 record.ambient_meeting_id,
140 record.created_at,
141 ],
142 )
143 .context("inserting device")?;
144 Ok(record)
145 }
146
147 pub async fn get(&self, device_id: &str) -> Result<Option<DeviceRecord>> {
149 let conn = self.conn.lock().await;
150 conn.query_row(
151 "SELECT device_id, device_type, name, token_hash, last_seen, battery_pct, prefs, ambient_meeting_id, created_at
152 FROM devices WHERE device_id = ?1",
153 params![device_id],
154 Self::row_to_record,
155 )
156 .optional()
157 .context("reading device")
158 }
159
160 pub async fn list(&self) -> Result<Vec<DeviceRecord>> {
162 let conn = self.conn.lock().await;
163 let mut stmt = conn.prepare(
164 "SELECT device_id, device_type, name, token_hash, last_seen, battery_pct, prefs, ambient_meeting_id, created_at
165 FROM devices ORDER BY created_at DESC",
166 )?;
167 let rows = stmt.query_map([], Self::row_to_record)?;
168 let mut out = Vec::new();
169 for row in rows {
170 out.push(row?);
171 }
172 Ok(out)
173 }
174
175 pub async fn verify_token(&self, device_id: &str, token: &str) -> Result<bool> {
178 let Some(record) = self.get(device_id).await? else {
179 return Ok(false);
180 };
181 Ok(record.token_hash == hash_token(token))
182 }
183
184 pub async fn update(
187 &self,
188 device_id: &str,
189 name: Option<String>,
190 prefs: Option<serde_json::Value>,
191 ) -> Result<bool> {
192 let conn = self.conn.lock().await;
193 let mut changed = 0usize;
194 if let Some(name) = name {
195 changed += conn.execute(
196 "UPDATE devices SET name = ?2 WHERE device_id = ?1",
197 params![device_id, name],
198 )?;
199 }
200 if let Some(prefs) = prefs {
201 let json = serde_json::to_string(&prefs).unwrap_or_else(|_| "{}".to_string());
202 changed += conn.execute(
203 "UPDATE devices SET prefs = ?2 WHERE device_id = ?1",
204 params![device_id, json],
205 )?;
206 }
207 Ok(changed > 0)
208 }
209
210 pub async fn touch(&self, device_id: &str, battery_pct: Option<i32>) -> Result<()> {
213 let conn = self.conn.lock().await;
214 match battery_pct {
215 Some(pct) => conn.execute(
216 "UPDATE devices SET last_seen = ?2, battery_pct = ?3 WHERE device_id = ?1",
217 params![device_id, now_ms(), pct],
218 )?,
219 None => conn.execute(
220 "UPDATE devices SET last_seen = ?2 WHERE device_id = ?1",
221 params![device_id, now_ms()],
222 )?,
223 };
224 Ok(())
225 }
226
227 pub async fn set_ambient_meeting(&self, device_id: &str, meeting_id: &str) -> Result<()> {
230 let conn = self.conn.lock().await;
231 conn.execute(
232 "UPDATE devices SET ambient_meeting_id = ?2 WHERE device_id = ?1",
233 params![device_id, meeting_id],
234 )?;
235 Ok(())
236 }
237
238 pub async fn revoke(&self, device_id: &str) -> Result<bool> {
241 let conn = self.conn.lock().await;
242 let n = conn.execute(
243 "DELETE FROM devices WHERE device_id = ?1",
244 params![device_id],
245 )?;
246 Ok(n > 0)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn temp_store() -> DeviceStore {
255 let dir = std::env::temp_dir().join(format!("ryu-hw-test-{}", uuid::Uuid::new_v4()));
256 DeviceStore::open(dir.join("hardware.db")).expect("open")
257 }
258
259 fn sample(id: &str, token: &str) -> DeviceRecord {
260 DeviceRecord {
261 device_id: id.to_string(),
262 device_type: DeviceType::Watch,
263 name: "Test Watch".to_string(),
264 token_hash: hash_token(token),
265 last_seen: None,
266 battery_pct: None,
267 prefs: serde_json::json!({}),
268 ambient_meeting_id: None,
269 created_at: now_ms(),
270 }
271 }
272
273 #[tokio::test]
274 async fn insert_get_and_verify_token() {
275 let store = temp_store();
276 store.insert(sample("rhw_1", "secret-token")).await.unwrap();
277
278 let got = store.get("rhw_1").await.unwrap().expect("present");
279 assert_eq!(got.device_id, "rhw_1");
280 assert_eq!(got.device_type, DeviceType::Watch);
281
282 assert!(store.verify_token("rhw_1", "secret-token").await.unwrap());
283 assert!(!store.verify_token("rhw_1", "wrong").await.unwrap());
284 assert!(!store.verify_token("nope", "secret-token").await.unwrap());
285 }
286
287 #[tokio::test]
288 async fn touch_updates_last_seen_and_battery() {
289 let store = temp_store();
290 store.insert(sample("rhw_2", "t")).await.unwrap();
291 store.touch("rhw_2", Some(81)).await.unwrap();
292 let got = store.get("rhw_2").await.unwrap().unwrap();
293 assert!(got.last_seen.is_some());
294 assert_eq!(got.battery_pct, Some(81));
295 store.touch("rhw_2", None).await.unwrap();
297 let got = store.get("rhw_2").await.unwrap().unwrap();
298 assert_eq!(got.battery_pct, Some(81));
299 }
300
301 #[tokio::test]
302 async fn update_revoke_and_ambient() {
303 let store = temp_store();
304 store.insert(sample("rhw_3", "t")).await.unwrap();
305 assert!(store
306 .update("rhw_3", Some("Kitchen".into()), None)
307 .await
308 .unwrap());
309 store.set_ambient_meeting("rhw_3", "mtg_x").await.unwrap();
310 let got = store.get("rhw_3").await.unwrap().unwrap();
311 assert_eq!(got.name, "Kitchen");
312 assert_eq!(got.ambient_meeting_id.as_deref(), Some("mtg_x"));
313 assert!(store.revoke("rhw_3").await.unwrap());
314 assert!(store.get("rhw_3").await.unwrap().is_none());
315 }
316}