1use crate::core::HsiSnapshot;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12#[derive(Debug, Clone)]
14pub struct GatewayConfig {
15 pub host: String,
17 pub port: u16,
19 pub token: String,
21}
22
23impl GatewayConfig {
24 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 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 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 pub fn url(&self) -> String {
100 format!("http://{}:{}", self.host, self.port)
101 }
102
103 pub fn ingest_url(&self) -> String {
105 format!("{}/v1/ingest/behavioral", self.url())
106 }
107
108 pub fn health_url(&self) -> String {
110 format!("{}/health", self.url())
111 }
112}
113
114#[derive(Debug)]
116pub enum GatewayError {
117 Config(String),
119 Network(String),
121 Server {
123 status: u16,
125 message: String,
127 },
128 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#[derive(Debug, Clone, Serialize)]
149pub struct BehavioralSession {
150 pub session: SessionPayload,
152}
153
154#[derive(Debug, Clone, Serialize)]
156pub struct SessionPayload {
157 pub session_id: String,
159 pub device_id: String,
161 pub timezone: String,
163 pub start_time: String,
165 pub end_time: String,
167 pub snapshots: Vec<HsiSnapshot>,
169 pub meta: SessionMeta,
171}
172
173#[derive(Debug, Clone, Serialize)]
175pub struct SessionMeta {
176 pub source: String,
178 pub version: String,
180 pub snapshot_count: usize,
182}
183
184#[derive(Debug, Clone, Deserialize)]
186pub struct GatewayResponse {
187 pub timestamp: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub flux_payload: Option<serde_json::Value>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub state: Option<HsiState>,
195}
196
197#[derive(Debug, Clone, Deserialize)]
199pub struct HsiState {
200 pub focus: Option<String>,
202 pub load: Option<String>,
204 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#[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 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 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 pub fn from_runtime() -> Result<Self, GatewayError> {
253 let config = GatewayConfig::from_runtime_dir()?;
254 Ok(Self::new(config))
255 }
256
257 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 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 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 pub fn device_id(&self) -> &str {
339 &self.device_id
340 }
341}
342
343#[cfg(feature = "gateway")]
345pub struct BlockingGatewayClient {
346 inner: GatewayClient,
347 runtime: tokio::runtime::Runtime,
348}
349
350#[cfg(feature = "gateway")]
351impl BlockingGatewayClient {
352 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 pub fn from_runtime() -> Result<Self, GatewayError> {
367 let config = GatewayConfig::from_runtime_dir()?;
368 Self::new(config)
369 }
370
371 pub fn test_connection(&self) -> Result<bool, GatewayError> {
373 self.runtime.block_on(self.inner.test_connection())
374 }
375
376 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 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}