Skip to main content

synheart_sensor_agent/
gateway.rs

1//! Gateway client for syncing HSI snapshots to synheart-core-gateway.
2//!
3//! Provides integration with the local synheart-core-gateway for real-time
4//! HSI processing. Use [`GatewayConfig`] to configure the connection and
5//! [`GatewayClient`] (async) or [`BlockingGatewayClient`] (sync) to send
6//! [`HsiSnapshot`](crate::core::HsiSnapshot)s.
7
8use crate::core::HsiSnapshot;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12/// Gateway configuration.
13#[derive(Debug, Clone)]
14pub struct GatewayConfig {
15    /// Gateway host (default: 127.0.0.1)
16    pub host: String,
17    /// Gateway port
18    pub port: u16,
19    /// Bearer authentication token
20    pub token: String,
21}
22
23impl GatewayConfig {
24    /// Create a new gateway configuration.
25    pub fn new(host: impl Into<String>, port: u16, token: impl Into<String>) -> Self {
26        Self {
27            host: host.into(),
28            port,
29            token: token.into(),
30        }
31    }
32
33    /// Load configuration from SyniLife runtime directory.
34    ///
35    /// Reads port from `~/Library/Application Support/SyniLife/runtime/gateway.port`
36    /// and token from `~/Library/Application Support/SyniLife/runtime/gateway.token`
37    pub fn from_runtime_dir() -> Result<Self, GatewayError> {
38        let state_dir = Self::default_state_dir()?;
39        let runtime_dir = state_dir.join("runtime");
40
41        let port_path = runtime_dir.join("gateway.port");
42        let token_path = runtime_dir.join("gateway.token");
43
44        let port_str = std::fs::read_to_string(&port_path).map_err(|e| {
45            GatewayError::Config(format!(
46                "Failed to read gateway port from {port_path:?}: {e}"
47            ))
48        })?;
49
50        let port: u16 = port_str.trim().parse().map_err(|e| {
51            GatewayError::Config(format!("Invalid port number '{}': {}", port_str.trim(), e))
52        })?;
53
54        let token = std::fs::read_to_string(&token_path)
55            .map_err(|e| {
56                GatewayError::Config(format!(
57                    "Failed to read gateway token from {token_path:?}: {e}"
58                ))
59            })?
60            .trim()
61            .to_string();
62
63        Ok(Self {
64            host: "127.0.0.1".to_string(),
65            port,
66            token,
67        })
68    }
69
70    /// Get the default SyniLife state directory.
71    fn default_state_dir() -> Result<PathBuf, GatewayError> {
72        #[cfg(target_os = "macos")]
73        {
74            if let Some(home) = dirs::home_dir() {
75                return Ok(home.join("Library/Application Support/SyniLife"));
76            }
77        }
78
79        #[cfg(target_os = "linux")]
80        {
81            if let Some(data_dir) = dirs::data_dir() {
82                return Ok(data_dir.join("SyniLife"));
83            }
84        }
85
86        #[cfg(target_os = "windows")]
87        {
88            if let Some(data_dir) = dirs::data_dir() {
89                return Ok(data_dir.join("SyniLife"));
90            }
91        }
92
93        Err(GatewayError::Config(
94            "Could not determine SyniLife state directory".to_string(),
95        ))
96    }
97
98    /// Get the full gateway URL.
99    pub fn url(&self) -> String {
100        format!("http://{}:{}", self.host, self.port)
101    }
102
103    /// Get the behavioral ingest endpoint URL.
104    pub fn ingest_url(&self) -> String {
105        format!("{}/v1/ingest/behavioral", self.url())
106    }
107
108    /// Get the health check endpoint URL.
109    pub fn health_url(&self) -> String {
110        format!("{}/health", self.url())
111    }
112}
113
114/// Gateway client error types.
115#[derive(Debug)]
116pub enum GatewayError {
117    /// Configuration error
118    Config(String),
119    /// Network/HTTP error
120    Network(String),
121    /// Server returned an error response.
122    Server {
123        /// HTTP status code.
124        status: u16,
125        /// Error message from the server.
126        message: String,
127    },
128    /// JSON serialization error
129    Serialization(String),
130}
131
132impl std::fmt::Display for GatewayError {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            GatewayError::Config(msg) => write!(f, "Gateway config error: {msg}"),
136            GatewayError::Network(msg) => write!(f, "Gateway network error: {msg}"),
137            GatewayError::Server { status, message } => {
138                write!(f, "Gateway server error ({status}): {message}")
139            }
140            GatewayError::Serialization(msg) => write!(f, "Gateway serialization error: {msg}"),
141        }
142    }
143}
144
145impl std::error::Error for GatewayError {}
146
147/// Session payload for the behavioral ingest endpoint.
148#[derive(Debug, Clone, Serialize)]
149pub struct BehavioralSession {
150    /// Session containing HSI snapshots
151    pub session: SessionPayload,
152}
153
154/// Session payload structure matching core-gateway expectations.
155#[derive(Debug, Clone, Serialize)]
156pub struct SessionPayload {
157    /// Session identifier
158    pub session_id: String,
159    /// Device identifier
160    pub device_id: String,
161    /// Timezone
162    pub timezone: String,
163    /// Session start time (RFC3339)
164    pub start_time: String,
165    /// Session end time (RFC3339)
166    pub end_time: String,
167    /// HSI snapshots as events
168    pub snapshots: Vec<HsiSnapshot>,
169    /// Metadata
170    pub meta: SessionMeta,
171}
172
173/// Session metadata.
174#[derive(Debug, Clone, Serialize)]
175pub struct SessionMeta {
176    /// Source identifier
177    pub source: String,
178    /// Version
179    pub version: String,
180    /// Snapshot count
181    pub snapshot_count: usize,
182}
183
184/// Gateway response from the behavioral ingest endpoint.
185#[derive(Debug, Clone, Deserialize)]
186pub struct GatewayResponse {
187    /// Timestamp of processing
188    pub timestamp: String,
189    /// Flux payload (if processed)
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub flux_payload: Option<serde_json::Value>,
192    /// HSI state summary
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub state: Option<HsiState>,
195}
196
197/// HSI state summary from gateway.
198#[derive(Debug, Clone, Deserialize)]
199pub struct HsiState {
200    /// Focus level
201    pub focus: Option<String>,
202    /// Load level
203    pub load: Option<String>,
204    /// Recovery level
205    pub recovery: Option<String>,
206}
207
208impl std::fmt::Display for HsiState {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        let focus = self.focus.as_deref().unwrap_or("unknown");
211        let load = self.load.as_deref().unwrap_or("unknown");
212        let recovery = self.recovery.as_deref().unwrap_or("unknown");
213        write!(f, "focus: {focus}, load: {load}, recovery: {recovery}")
214    }
215}
216
217/// Gateway client for syncing with synheart-core-gateway.
218#[cfg(feature = "gateway")]
219pub struct GatewayClient {
220    config: GatewayConfig,
221    client: reqwest::Client,
222    device_id: String,
223}
224
225#[cfg(feature = "gateway")]
226impl GatewayClient {
227    /// Create a new gateway client.
228    pub fn new(config: GatewayConfig) -> Self {
229        let client = reqwest::Client::builder()
230            .timeout(std::time::Duration::from_secs(10))
231            .build()
232            .expect("Failed to create HTTP client");
233
234        // Generate device ID from hostname + instance
235        let hostname = hostname::get()
236            .map(|h| h.to_string_lossy().to_string())
237            .unwrap_or_else(|_| "unknown".to_string());
238        let device_id = format!(
239            "sensor-{}-{}",
240            hostname,
241            &uuid::Uuid::new_v4().to_string()[..8]
242        );
243
244        Self {
245            config,
246            client,
247            device_id,
248        }
249    }
250
251    /// Create a new gateway client from runtime directory configuration.
252    pub fn from_runtime() -> Result<Self, GatewayError> {
253        let config = GatewayConfig::from_runtime_dir()?;
254        Ok(Self::new(config))
255    }
256
257    /// Test connection to the gateway.
258    pub async fn test_connection(&self) -> Result<bool, GatewayError> {
259        let response = self
260            .client
261            .get(self.config.health_url())
262            .send()
263            .await
264            .map_err(|e| GatewayError::Network(e.to_string()))?;
265
266        Ok(response.status().is_success())
267    }
268
269    /// Sync HSI snapshots to the gateway.
270    pub async fn sync_snapshots(
271        &self,
272        snapshots: &[HsiSnapshot],
273        session_id: &str,
274    ) -> Result<GatewayResponse, GatewayError> {
275        if snapshots.is_empty() {
276            return Err(GatewayError::Config("No snapshots to sync".to_string()));
277        }
278
279        // Build session payload
280        let start_time = snapshots
281            .first()
282            .map(|s| s.observed_at_utc.clone())
283            .unwrap_or_default();
284        let end_time = snapshots
285            .last()
286            .map(|s| s.computed_at_utc.clone())
287            .unwrap_or_default();
288
289        let timezone = chrono_tz::Tz::UTC.to_string();
290
291        let session = BehavioralSession {
292            session: SessionPayload {
293                session_id: session_id.to_string(),
294                device_id: self.device_id.clone(),
295                timezone,
296                start_time,
297                end_time,
298                snapshots: snapshots.to_vec(),
299                meta: SessionMeta {
300                    source: "synheart-sensor-agent".to_string(),
301                    version: env!("CARGO_PKG_VERSION").to_string(),
302                    snapshot_count: snapshots.len(),
303                },
304            },
305        };
306
307        let response = self
308            .client
309            .post(self.config.ingest_url())
310            .header("Authorization", format!("Bearer {}", self.config.token))
311            .header("Content-Type", "application/json")
312            .json(&session)
313            .send()
314            .await
315            .map_err(|e| GatewayError::Network(e.to_string()))?;
316
317        let status = response.status();
318        if !status.is_success() {
319            let message = response
320                .text()
321                .await
322                .unwrap_or_else(|_| "Unknown error".to_string());
323            return Err(GatewayError::Server {
324                status: status.as_u16(),
325                message,
326            });
327        }
328
329        let gateway_response: GatewayResponse = response
330            .json()
331            .await
332            .map_err(|e| GatewayError::Serialization(e.to_string()))?;
333
334        Ok(gateway_response)
335    }
336
337    /// Get the device ID.
338    pub fn device_id(&self) -> &str {
339        &self.device_id
340    }
341}
342
343/// Blocking gateway client for use in synchronous contexts.
344#[cfg(feature = "gateway")]
345pub struct BlockingGatewayClient {
346    inner: GatewayClient,
347    runtime: tokio::runtime::Runtime,
348}
349
350#[cfg(feature = "gateway")]
351impl BlockingGatewayClient {
352    /// Create a new blocking gateway client.
353    pub fn new(config: GatewayConfig) -> Result<Self, GatewayError> {
354        let runtime = tokio::runtime::Builder::new_current_thread()
355            .enable_all()
356            .build()
357            .map_err(|e| GatewayError::Config(format!("Failed to create runtime: {e}")))?;
358
359        Ok(Self {
360            inner: GatewayClient::new(config),
361            runtime,
362        })
363    }
364
365    /// Create a new blocking gateway client from runtime directory configuration.
366    pub fn from_runtime() -> Result<Self, GatewayError> {
367        let config = GatewayConfig::from_runtime_dir()?;
368        Self::new(config)
369    }
370
371    /// Test connection to the gateway.
372    pub fn test_connection(&self) -> Result<bool, GatewayError> {
373        self.runtime.block_on(self.inner.test_connection())
374    }
375
376    /// Sync HSI snapshots to the gateway.
377    pub fn sync_snapshots(
378        &self,
379        snapshots: &[HsiSnapshot],
380        session_id: &str,
381    ) -> Result<GatewayResponse, GatewayError> {
382        self.runtime
383            .block_on(self.inner.sync_snapshots(snapshots, session_id))
384    }
385
386    /// Get the device ID.
387    pub fn device_id(&self) -> &str {
388        self.inner.device_id()
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_gateway_config_url() {
398        let config = GatewayConfig::new("127.0.0.1", 8080, "test-token");
399        assert_eq!(config.url(), "http://127.0.0.1:8080");
400        assert_eq!(
401            config.ingest_url(),
402            "http://127.0.0.1:8080/v1/ingest/behavioral"
403        );
404        assert_eq!(config.health_url(), "http://127.0.0.1:8080/health");
405    }
406
407    #[test]
408    fn test_hsi_state_display() {
409        let state = HsiState {
410            focus: Some("high".to_string()),
411            load: Some("moderate".to_string()),
412            recovery: None,
413        };
414        let display = format!("{state}");
415        assert!(display.contains("high"));
416        assert!(display.contains("moderate"));
417    }
418}