Skip to main content

lighthouse_manager/
storage.rs

1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::fs;
6use std::path::PathBuf;
7
8use crate::lighthouse::Lighthouse;
9
10/// Returns the platform-specific local config directory for this app.
11///
12/// The directory is created if it doesn't already exist.
13///
14/// # Errors
15///
16/// Returns an error if the platform-specific config directory cannot be determined.
17pub fn config_local_dir() -> Result<PathBuf> {
18    let proj = ProjectDirs::from("io", "atomicflag", "Lighthouse Manager")
19        .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
20    let dir = proj.config_local_dir();
21    // Create the directory if it doesn't exist
22    fs::create_dir_all(dir).context("Failed to create config directory")?;
23    Ok(dir.to_path_buf())
24}
25
26/// Path to the JSON settings file, determined cross-platform via `directories`.
27fn config_path() -> Result<PathBuf> {
28    let dir = config_local_dir()?;
29    Ok(dir.join("settings.json"))
30}
31
32/// Autostart-related settings.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Autostart {
35    /// Cooldown period in seconds after powering lighthouses off. If `SteamVR` is launched
36    /// within this window, lighthouses won't be turned on to avoid frequent toggling.
37    pub cooldown_secs: u64,
38    /// Unix timestamp (seconds) of when the lighthouses were last turned off.
39    pub last_turned_off_at: Option<u64>,
40}
41
42impl Default for Autostart {
43    fn default() -> Self {
44        Self {
45            cooldown_secs: 600,
46            last_turned_off_at: None,
47        }
48    }
49}
50
51/// Application settings persisted to disk.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct AppSettings {
54    pub version: u32,
55    pub lighthouses: Vec<Lighthouse>,
56    #[serde(default)]
57    pub autostart: Autostart,
58}
59
60impl Default for AppSettings {
61    fn default() -> Self {
62        Self {
63            version: 1,
64            lighthouses: Vec::new(),
65            autostart: Autostart::default(),
66        }
67    }
68}
69
70/// Load settings from a specific path. Returns defaults if file doesn't exist.
71fn load_at(path: &PathBuf) -> Result<AppSettings> {
72    if !path.exists() {
73        return Ok(AppSettings::default());
74    }
75    let content = fs::read_to_string(path).context("Failed to read settings")?;
76    let settings: AppSettings =
77        serde_json::from_str(&content).context("Failed to parse settings JSON")?;
78    Ok(settings)
79}
80
81/// Save settings to a specific path.
82fn save_at(path: &PathBuf, settings: &AppSettings) -> Result<()> {
83    let content = serde_json::to_string_pretty(settings).context("Failed to serialize settings")?;
84    fs::write(path, content).context("Failed to write settings")?;
85    Ok(())
86}
87
88/// Load settings from disk (using the default config path). Returns defaults if file doesn't exist.
89///
90/// # Errors
91///
92/// Returns an error if the config directory cannot be determined.
93pub fn load() -> Result<AppSettings> {
94    let path = config_path()?;
95    load_at(&path)
96}
97
98/// Save settings to disk (using the default config path).
99///
100/// # Errors
101///
102/// Returns an error if the config directory cannot be determined, the JSON cannot be serialized,
103/// or the file cannot be written.
104pub fn save(settings: &AppSettings) -> Result<()> {
105    let path = config_path()?;
106    save_at(&path, settings)
107}
108
109/// Add newly discovered lighthouses to the settings.
110/// - Newly discovered units are marked unmanaged (managed: false) by default.
111/// - Deduplication by Bluetooth address: if an entry already exists for this address, it is NOT overwritten.
112/// - Returns the count of new entries added.
113pub fn add_new(settings: &mut AppSettings, discovered: &[Lighthouse]) -> usize {
114    let existing: HashSet<String> = settings
115        .lighthouses
116        .iter()
117        .map(|l| l.address.clone())
118        .collect();
119
120    let new_lhs: Vec<Lighthouse> = discovered
121        .iter()
122        .filter(|lh| !existing.contains(&lh.address))
123        .cloned()
124        .collect();
125    let count = new_lhs.len();
126    settings.lighthouses.extend(new_lhs);
127    count
128}
129
130/// Get all managed lighthouses.
131#[must_use]
132pub fn managed_lighthouses(settings: &AppSettings) -> Vec<&Lighthouse> {
133    settings.lighthouses.iter().filter(|l| l.managed).collect()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::path::PathBuf;
140
141    fn test_settings_path() -> (PathBuf, tempfile::TempDir) {
142        let dir = tempfile::tempdir().unwrap();
143        let path = dir.path().join("settings.json");
144        (path, dir)
145    }
146
147    #[test]
148    fn test_load_empty_settings() {
149        let (path, _guard) = test_settings_path();
150        let settings = load_at(&path).unwrap();
151        assert!(settings.lighthouses.is_empty());
152        assert_eq!(settings.version, 1);
153    }
154
155    #[test]
156    fn test_save_and_load() {
157        let (path, _guard) = test_settings_path();
158
159        let settings = AppSettings {
160            version: 1,
161            lighthouses: vec![Lighthouse {
162                name: "LHB-0A1B2C3D".into(),
163                address: "AA:BB:CC:DD:EE:FF".into(),
164                id: None,
165                managed: true,
166            }],
167            ..Default::default()
168        };
169        save_at(&path, &settings).unwrap();
170
171        let loaded = load_at(&path).unwrap();
172        assert_eq!(loaded.lighthouses.len(), 1);
173        assert_eq!(loaded.lighthouses[0].name, "LHB-0A1B2C3D");
174        assert!(loaded.lighthouses[0].managed);
175    }
176
177    #[test]
178    fn test_add_new_deduplication() {
179        let (path, _guard) = test_settings_path();
180
181        let mut settings = AppSettings {
182            version: 1,
183            lighthouses: vec![Lighthouse {
184                name: "HTC BS-AABBCCDD".into(),
185                address: "AA:BB:CC:DD:EE:FF".into(),
186                id: Some("AABBCCDD".into()),
187                managed: true,
188            }],
189            ..Default::default()
190        };
191
192        // Discover same device (should be deduplicated) and a new one
193        let discovered = vec![
194            Lighthouse {
195                name: "HTC BS-AABBCCDD-NEW".into(), // Same address, different name
196                address: "AA:BB:CC:DD:EE:FF".into(),
197                id: Some("AABBCCDD2".into()),
198                managed: true,
199            },
200            Lighthouse {
201                name: "LHB-0A1B2C3D".into(),
202                address: "11:22:33:44:55:66".into(),
203                id: None,
204                managed: true,
205            },
206        ];
207
208        let count = add_new(&mut settings, &discovered);
209        assert_eq!(count, 1); // Only the new address was added
210        assert_eq!(settings.lighthouses.len(), 2);
211        // Original entry preserved (not overwritten by discovered)
212        assert_eq!(settings.lighthouses[0].name, "HTC BS-AABBCCDD");
213
214        save_at(&path, &settings).ok();
215    }
216
217    #[test]
218    fn test_newly_discovered_are_unmanaged() {
219        let (path, _guard) = test_settings_path();
220
221        let mut settings = AppSettings::default();
222        let discovered = vec![Lighthouse {
223            name: "LHB-0A1B2C3D".into(),
224            address: "AA:BB:CC:DD:EE:FF".into(),
225            id: None,
226            managed: false, // BLE scan always produces unmanaged lighthouses
227        }];
228
229        add_new(&mut settings, &discovered);
230
231        assert!(!settings.lighthouses[0].managed);
232
233        save_at(&path, &settings).ok();
234    }
235
236    #[test]
237    fn test_managed_lighthouses_filter() {
238        let settings = AppSettings {
239            version: 1,
240            lighthouses: vec![
241                Lighthouse {
242                    name: "LHB-0000".into(),
243                    address: "AA:00".into(),
244                    id: None,
245                    managed: true,
246                },
247                Lighthouse {
248                    name: "HTC BS-1111".into(),
249                    address: "BB:00".into(),
250                    id: Some("1111".into()),
251                    managed: false,
252                },
253                Lighthouse {
254                    name: "LHB-2222".into(),
255                    address: "CC:00".into(),
256                    id: None,
257                    managed: true,
258                },
259            ],
260            ..Default::default()
261        };
262
263        let managed = managed_lighthouses(&settings);
264        assert_eq!(managed.len(), 2);
265        assert_eq!(managed[0].name, "LHB-0000");
266        assert_eq!(managed[1].name, "LHB-2222");
267    }
268
269    #[test]
270    fn test_serde_roundtrip() {
271        let settings = AppSettings {
272            version: 1,
273            lighthouses: vec![
274                Lighthouse {
275                    name: "HTC BS-AABBCCDD".into(),
276                    address: "AA:BB:CC:DD:EE:FF".into(),
277                    id: Some("AABBCCDD".into()),
278                    managed: true,
279                },
280                Lighthouse {
281                    name: "LHB-0A1B2C3D".into(),
282                    address: "11:22:33:44:55:66".into(),
283                    id: None,
284                    managed: false,
285                },
286            ],
287            ..Default::default()
288        };
289
290        let json = serde_json::to_string_pretty(&settings).unwrap();
291        let restored: AppSettings = serde_json::from_str(&json).unwrap();
292        assert_eq!(restored.version, 1);
293        assert_eq!(restored.lighthouses.len(), 2);
294    }
295}