Skip to main content

lighthouse_manager/
storage.rs

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