steelseries_sonar/
sonar.rs

1//! SteelSeries Sonar API client.
2
3use crate::error::{Result, SonarError};
4use reqwest::Client;
5use serde::{Deserialize};
6use serde_json::Value;
7use std::path::Path;
8
9/// Valid audio channel names in SteelSeries Sonar.
10pub const CHANNEL_NAMES: &[&str] = &["master", "game", "chatRender", "media", "aux", "chatCapture"];
11
12/// Valid streamer slider names.
13pub const STREAMER_SLIDER_NAMES: &[&str] = &["streaming", "monitoring"];
14
15/// Core properties structure from SteelSeries Engine.
16#[derive(Debug, Deserialize)]
17pub struct CoreProps {
18    #[serde(rename = "ggEncryptedAddress")]
19    pub gg_encrypted_address: String,
20}
21
22/// Sub-application information structure.
23#[derive(Debug, Deserialize)]
24pub struct SubApp {
25    #[serde(rename = "isEnabled")]
26    pub is_enabled: bool,
27    #[serde(rename = "isReady")]
28    pub is_ready: bool,
29    #[serde(rename = "isRunning")]
30    pub is_running: bool,
31    pub metadata: SubAppMetadata,
32}
33
34/// Sub-application metadata.
35#[derive(Debug, Deserialize)]
36pub struct SubAppMetadata {
37    #[serde(rename = "webServerAddress")]
38    pub web_server_address: String,
39}
40
41/// Response from the /subApps endpoint.
42#[derive(Debug, Deserialize)]
43pub struct SubAppsResponse {
44    #[serde(rename = "subApps")]
45    pub sub_apps: SubApps,
46}
47
48/// Sub-applications container.
49#[derive(Debug, Deserialize)]
50pub struct SubApps {
51    pub sonar: SubApp,
52}
53
54/// Main SteelSeries Sonar API client.
55#[derive(Debug)]
56pub struct Sonar {
57    client: Client,
58    #[allow(dead_code)]
59    base_url: String,
60    web_server_address: String,
61    streamer_mode: bool,
62    volume_path: String,
63}
64
65impl Sonar {
66    /// Create a new Sonar client with default settings.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the SteelSeries Engine is not found or accessible.
71    pub async fn new() -> Result<Self> {
72        Self::with_config(None, None).await
73    }
74
75    /// Create a new Sonar client with custom configuration.
76    ///
77    /// # Arguments
78    ///
79    /// * `app_data_path` - Custom path to the coreProps.json file
80    /// * `streamer_mode` - Whether to use streamer mode (if None, will be auto-detected)
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the SteelSeries Engine is not found or accessible.
85    pub async fn with_config(app_data_path: Option<&Path>, streamer_mode: Option<bool>) -> Result<Self> {
86        let client = Client::builder()
87            .danger_accept_invalid_certs(true)
88            .build()?;
89
90        let app_data_path = app_data_path.unwrap_or_else(|| {
91            #[cfg(target_os = "windows")]
92            {
93                Path::new("C:\\ProgramData\\SteelSeries\\SteelSeries Engine 3\\coreProps.json")
94            }
95            #[cfg(not(target_os = "windows"))]
96            {
97                // For non-Windows systems, this would need to be adapted based on where
98                // SteelSeries Engine might be installed
99                Path::new("/tmp/coreProps.json") // Placeholder
100            }
101        });
102
103        let base_url = Self::load_base_url(app_data_path).await?;
104        let web_server_address = Self::load_server_address(&client, &base_url).await?;
105
106        let detected_streamer_mode = match streamer_mode {
107            Some(mode) => mode,
108            None => Self::is_streamer_mode_internal(&client, &web_server_address).await?,
109        };
110
111        let volume_path = if detected_streamer_mode {
112            "/volumeSettings/streamer".to_string()
113        } else {
114            "/volumeSettings/classic".to_string()
115        };
116
117        Ok(Self {
118            client,
119            base_url,
120            web_server_address,
121            streamer_mode: detected_streamer_mode,
122            volume_path,
123        })
124    }
125
126    /// Check if streamer mode is currently enabled.
127    pub async fn is_streamer_mode(&self) -> Result<bool> {
128        Self::is_streamer_mode_internal(&self.client, &self.web_server_address).await
129    }
130
131    async fn is_streamer_mode_internal(client: &Client, web_server_address: &str) -> Result<bool> {
132        let url = format!("{}/mode/", web_server_address);
133        let response = client.get(&url).send().await?;
134        
135        if !response.status().is_success() {
136            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
137        }
138
139        let mode: String = response.json().await?;
140        Ok(mode == "stream")
141    }
142
143    /// Set streamer mode on or off.
144    ///
145    /// # Arguments
146    ///
147    /// * `streamer_mode` - Whether to enable streamer mode
148    ///
149    /// # Returns
150    ///
151    /// Returns the new streamer mode state.
152    pub async fn set_streamer_mode(&mut self, streamer_mode: bool) -> Result<bool> {
153        let mode = if streamer_mode { "stream" } else { "classic" };
154        let url = format!("{}/mode/{}", self.web_server_address, mode);
155        
156        let response = self.client.put(&url).send().await?;
157        
158        if !response.status().is_success() {
159            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
160        }
161
162        let new_mode: String = response.json().await?;
163        self.streamer_mode = new_mode == "stream";
164        
165        self.volume_path = if self.streamer_mode {
166            "/volumeSettings/streamer".to_string()
167        } else {
168            "/volumeSettings/classic".to_string()
169        };
170
171        Ok(self.streamer_mode)
172    }
173
174    /// Get volume data for all channels.
175    pub async fn get_volume_data(&self) -> Result<Value> {
176        let url = format!("{}{}", self.web_server_address, self.volume_path);
177        let response = self.client.get(&url).send().await?;
178        
179        if !response.status().is_success() {
180            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
181        }
182
183        let volume_data: Value = response.json().await?;
184        Ok(volume_data)
185    }
186
187    /// Set the volume for a specific channel.
188    ///
189    /// # Arguments
190    ///
191    /// * `channel` - The audio channel name
192    /// * `volume` - Volume level (0.0 to 1.0)
193    /// * `streamer_slider` - Streamer slider to use in streamer mode
194    pub async fn set_volume(&self, channel: &str, volume: f64, streamer_slider: Option<&str>) -> Result<Value> {
195        if !CHANNEL_NAMES.contains(&channel) {
196            return Err(SonarError::ChannelNotFound(channel.to_string()));
197        }
198
199        if !(0.0..=1.0).contains(&volume) {
200            return Err(SonarError::InvalidVolume(volume));
201        }
202
203        let streamer_slider = streamer_slider.unwrap_or("streaming");
204        if self.streamer_mode && !STREAMER_SLIDER_NAMES.contains(&streamer_slider) {
205            return Err(SonarError::SliderNotFound(streamer_slider.to_string()));
206        }
207
208        let full_volume_path = if self.streamer_mode {
209            format!("{}/{}", self.volume_path, streamer_slider)
210        } else {
211            self.volume_path.clone()
212        };
213
214        let url = format!("{}{}/{}/Volume/{}", 
215            self.web_server_address, full_volume_path, channel, serde_json::to_string(&volume)?);
216        
217        let response = self.client.put(&url).send().await?;
218        
219        if !response.status().is_success() {
220            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
221        }
222
223        let result: Value = response.json().await?;
224        Ok(result)
225    }
226
227    /// Mute or unmute a specific channel.
228    ///
229    /// # Arguments
230    ///
231    /// * `channel` - The audio channel name
232    /// * `muted` - Whether to mute the channel
233    /// * `streamer_slider` - Streamer slider to use in streamer mode
234    pub async fn mute_channel(&self, channel: &str, muted: bool, streamer_slider: Option<&str>) -> Result<Value> {
235        if !CHANNEL_NAMES.contains(&channel) {
236            return Err(SonarError::ChannelNotFound(channel.to_string()));
237        }
238
239        let streamer_slider = streamer_slider.unwrap_or("streaming");
240        if self.streamer_mode && !STREAMER_SLIDER_NAMES.contains(&streamer_slider) {
241            return Err(SonarError::SliderNotFound(streamer_slider.to_string()));
242        }
243
244        let full_volume_path = if self.streamer_mode {
245            format!("{}/{}", self.volume_path, streamer_slider)
246        } else {
247            self.volume_path.clone()
248        };
249
250        let mute_keyword = if self.streamer_mode { "isMuted" } else { "Mute" };
251
252        let url = format!("{}{}/{}/{}/{}", 
253            self.web_server_address, full_volume_path, channel, mute_keyword, serde_json::to_string(&muted)?);
254        
255        let response = self.client.put(&url).send().await?;
256        
257        if !response.status().is_success() {
258            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
259        }
260
261        let result: Value = response.json().await?;
262        Ok(result)
263    }
264
265    /// Get chat mix data.
266    pub async fn get_chat_mix_data(&self) -> Result<Value> {
267        let url = format!("{}/chatMix", self.web_server_address);
268        let response = self.client.get(&url).send().await?;
269        
270        if !response.status().is_success() {
271            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
272        }
273
274        let chat_mix_data: Value = response.json().await?;
275        Ok(chat_mix_data)
276    }
277
278    /// Set the chat mix volume.
279    ///
280    /// # Arguments
281    ///
282    /// * `mix_volume` - Mix volume level (-1.0 to 1.0)
283    pub async fn set_chat_mix(&self, mix_volume: f64) -> Result<Value> {
284        if !(-1.0..=1.0).contains(&mix_volume) {
285            return Err(SonarError::InvalidMixVolume(mix_volume));
286        }
287
288        let url = format!("{}/chatMix?balance={}", 
289            self.web_server_address, serde_json::to_string(&mix_volume)?);
290        
291        let response = self.client.put(&url).send().await?;
292        
293        if !response.status().is_success() {
294            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
295        }
296
297        let result: Value = response.json().await?;
298        Ok(result)
299    }
300
301    async fn load_base_url(app_data_path: &Path) -> Result<String> {
302        if !app_data_path.exists() {
303            return Err(SonarError::EnginePathNotFound);
304        }
305
306        let content = tokio::fs::read_to_string(app_data_path).await?;
307        let core_props: CoreProps = serde_json::from_str(&content)?;
308        
309        Ok(format!("https://{}", core_props.gg_encrypted_address))
310    }
311
312    async fn load_server_address(client: &Client, base_url: &str) -> Result<String> {
313        let url = format!("{}/subApps", base_url);
314        let response = client.get(&url).send().await?;
315        
316        if !response.status().is_success() {
317            return Err(SonarError::ServerNotAccessible(response.status().as_u16()));
318        }
319
320        let sub_apps_response: SubAppsResponse = response.json().await?;
321        let sonar = &sub_apps_response.sub_apps.sonar;
322
323        if !sonar.is_enabled {
324            return Err(SonarError::SonarNotEnabled);
325        }
326
327        if !sonar.is_ready {
328            return Err(SonarError::ServerNotReady);
329        }
330
331        if !sonar.is_running {
332            return Err(SonarError::ServerNotRunning);
333        }
334
335        let web_server_address = &sonar.metadata.web_server_address;
336        if web_server_address.is_empty() || web_server_address == "null" {
337            return Err(SonarError::WebServerAddressNotFound);
338        }
339
340        Ok(web_server_address.clone())
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_channel_names() {
350        assert!(CHANNEL_NAMES.contains(&"master"));
351        assert!(CHANNEL_NAMES.contains(&"game"));
352        assert!(CHANNEL_NAMES.contains(&"chatRender"));
353        assert!(CHANNEL_NAMES.contains(&"media"));
354        assert!(CHANNEL_NAMES.contains(&"aux"));
355        assert!(CHANNEL_NAMES.contains(&"chatCapture"));
356    }
357
358    #[test]
359    fn test_streamer_slider_names() {
360        assert!(STREAMER_SLIDER_NAMES.contains(&"streaming"));
361        assert!(STREAMER_SLIDER_NAMES.contains(&"monitoring"));
362    }
363}