1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ServiceStatus {
11 Starting,
12 Idle,
13 Running,
14 Paused,
15 Syncing,
16 Error,
17 Stopping,
18}
19
20impl ServiceStatus {
21 pub const ALL: &[ServiceStatus] = &[
22 Self::Starting, Self::Idle, Self::Running, Self::Paused,
23 Self::Syncing, Self::Error, Self::Stopping,
24 ];
25
26 pub fn as_str(&self) -> &'static str {
27 match self {
28 Self::Starting => "starting",
29 Self::Idle => "idle",
30 Self::Running => "running",
31 Self::Paused => "paused",
32 Self::Syncing => "syncing",
33 Self::Error => "error",
34 Self::Stopping => "stopping",
35 }
36 }
37
38 pub fn from_str(s: &str) -> Option<Self> {
39 Self::ALL.iter().find(|v| v.as_str() == s).copied()
40 }
41}
42
43impl std::fmt::Display for ServiceStatus {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.write_str(self.as_str())
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum CommandStatus {
52 Pending,
53 Acknowledged,
54 Completed,
55 Failed,
56 Expired,
57}
58
59impl CommandStatus {
60 pub const fn as_str(&self) -> &'static str {
61 match self {
62 Self::Pending => "pending",
63 Self::Acknowledged => "acknowledged",
64 Self::Completed => "completed",
65 Self::Failed => "failed",
66 Self::Expired => "expired",
67 }
68 }
69}
70
71impl std::fmt::Display for CommandStatus {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.write_str(self.as_str())
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum SyncEventType {
80 Scheduled,
81 Manual,
82 Command,
83 Triggered,
84 FullSync,
85}
86
87impl SyncEventType {
88 pub const ALL: &[SyncEventType] = &[
89 Self::Scheduled, Self::Manual, Self::Command, Self::Triggered, Self::FullSync,
90 ];
91
92 pub fn as_str(&self) -> &'static str {
93 match self {
94 Self::Scheduled => "scheduled",
95 Self::Manual => "manual",
96 Self::Command => "command",
97 Self::Triggered => "triggered",
98 Self::FullSync => "full_sync",
99 }
100 }
101
102 pub fn from_str(s: &str) -> Option<Self> {
103 Self::ALL.iter().find(|v| v.as_str() == s).copied()
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum SyncEventStatus {
110 Running,
111 Completed,
112 Partial,
113 Failed,
114 Cancelled,
115}
116
117impl SyncEventStatus {
118 pub const ALL: &[SyncEventStatus] = &[
119 Self::Running, Self::Completed, Self::Partial, Self::Failed, Self::Cancelled,
120 ];
121
122 pub fn as_str(&self) -> &'static str {
123 match self {
124 Self::Running => "running",
125 Self::Completed => "completed",
126 Self::Partial => "partial",
127 Self::Failed => "failed",
128 Self::Cancelled => "cancelled",
129 }
130 }
131
132 pub fn from_str(s: &str) -> Option<Self> {
133 Self::ALL.iter().find(|v| v.as_str() == s).copied()
134 }
135
136 pub fn is_terminal(&self) -> bool {
137 matches!(self, Self::Completed | Self::Partial | Self::Failed | Self::Cancelled)
138 }
139
140 pub fn is_success(&self) -> bool {
141 matches!(self, Self::Completed | Self::Partial)
142 }
143}
144
145#[derive(Debug, Clone)]
150pub struct SyncServerConfig {
151 pub session_token_ttl_secs: u64,
152 pub token_cache_capacity: u64,
153 pub token_cache_ttl_secs: u64,
154 pub command_expiry_secs: u64,
155 pub health_healthy_secs: i64,
156 pub health_warning_secs: i64,
157 pub client_id_prefix: String,
158}
159
160impl Default for SyncServerConfig {
161 fn default() -> Self {
162 Self {
163 session_token_ttl_secs: 900,
164 token_cache_capacity: 100,
165 token_cache_ttl_secs: 780,
166 command_expiry_secs: 300,
167 health_healthy_secs: 90,
168 health_warning_secs: 300,
169 client_id_prefix: "svc_".to_string(),
170 }
171 }
172}
173
174#[derive(Debug, Serialize, Deserialize)]
179pub struct EnrollRequest {
180 pub client_id: String,
181 pub client_secret: String,
182 pub instance_id: String,
183}
184
185#[derive(Debug, Serialize, Deserialize)]
186pub struct EnrollResponse {
187 pub service_id: Uuid,
188 pub session_token: String,
189}
190
191#[derive(Debug, Serialize, Deserialize)]
196pub struct HeartbeatRequest {
197 pub service_id: Uuid,
198 pub status: String,
199 pub current_operation: Option<String>,
200}
201
202#[derive(Debug, Serialize, Deserialize)]
203pub struct HeartbeatResponse {
204 pub session_token: String,
205 pub pending_commands: Vec<PendingCommand>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PendingCommand {
210 pub id: Uuid,
211 pub command: String,
212 pub payload: Option<serde_json::Value>,
213}
214
215#[derive(Debug, Serialize, Deserialize)]
220pub struct CommandUpdateRequest {
221 pub status: String,
222 pub result: Option<serde_json::Value>,
223}
224
225#[derive(Debug, Default, Serialize)]
230pub struct SyncResult {
231 pub readings_synced: u64,
232 pub status_events_synced: u64,
233 pub full_sync: bool,
234 pub duration_ms: u64,
235 #[serde(skip_serializing_if = "Vec::is_empty")]
236 pub errors: Vec<String>,
237 #[serde(skip_serializing_if = "Vec::is_empty")]
238 pub log: Vec<String>,
239}
240
241#[derive(Debug)]
242pub enum SyncTrigger {
243 Scheduled,
244 Command { id: Uuid, full: bool },
245}
246
247#[derive(Debug, Clone)]
252pub struct RunnerConfig {
253 pub api_base_url: String,
254 pub client_id: String,
255 pub client_secret: String,
256 pub instance_id: String,
257 pub heartbeat_interval_secs: u64,
258 pub sync_interval_secs: u64,
259 pub enrollment_retry_secs: u64,
260}
261
262impl RunnerConfig {
263 pub fn from_env() -> Result<Self, String> {
264 Ok(Self {
265 api_base_url: require_env("API_BASE_URL")?,
266 client_id: require_env("SERVICE_CLIENT_ID")?,
267 client_secret: require_env("SERVICE_CLIENT_SECRET")?,
268 instance_id: std::env::var("INSTANCE_ID").unwrap_or_else(|_| "default".to_string()),
269 heartbeat_interval_secs: env_u64("HEARTBEAT_INTERVAL_SECONDS", 30),
270 sync_interval_secs: env_u64("SYNC_INTERVAL_SECONDS", 300),
271 enrollment_retry_secs: env_u64("ENROLLMENT_RETRY_SECONDS", 10),
272 })
273 }
274}
275
276fn require_env(key: &str) -> Result<String, String> {
277 std::env::var(key).map_err(|_| format!("Missing required env var: {key}"))
278}
279
280fn env_u64(key: &str, default: u64) -> u64 {
281 std::env::var(key)
282 .ok()
283 .and_then(|v| v.parse().ok())
284 .unwrap_or(default)
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct DataStream {
293 pub id: Uuid,
294 pub source_system: String,
295 pub source_key: String,
296 pub source_name: Option<String>,
297 pub source_path: Option<String>,
298 pub metadata: serde_json::Value,
299 pub site_parameter_id: Option<Uuid>,
300 pub is_active: bool,
301 pub last_data_time: Option<chrono::DateTime<chrono::Utc>>,
302}
303
304#[derive(Debug, Serialize)]
305pub struct RegisterStreamRequest {
306 pub source_system: String,
307 pub source_key: String,
308 pub source_name: Option<String>,
309 pub source_path: Option<String>,
310 pub metadata: serde_json::Value,
311}
312
313#[derive(Debug, Clone, Serialize)]
314pub struct IngestReading {
315 pub time: chrono::DateTime<chrono::Utc>,
316 pub raw_value: f64,
317 #[serde(skip_serializing_if = "is_zero")]
318 pub replicate_index: i16,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub sensor_id: Option<Uuid>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub calibration_id: Option<Uuid>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub deployment_id: Option<Uuid>,
325}
326
327fn is_zero(v: &i16) -> bool {
328 *v == 0
329}
330
331#[derive(Debug, Serialize)]
332pub struct IngestStatusEvent {
333 pub time: chrono::DateTime<chrono::Utc>,
334 pub value: String,
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn test_enroll_request_serialization() {
343 let req = EnrollRequest {
344 client_id: "svc_abc".to_string(),
345 client_secret: "secret123".to_string(),
346 instance_id: "service-01".to_string(),
347 };
348 let json = serde_json::to_value(&req).unwrap();
349 assert_eq!(json["client_id"], "svc_abc");
350 assert_eq!(json["instance_id"], "service-01");
351 }
352
353 #[test]
354 fn test_enroll_response_deserialization() {
355 let json = serde_json::json!({
356 "service_id": "550e8400-e29b-41d4-a716-446655440000",
357 "session_token": "tok-abc"
358 });
359 let resp: EnrollResponse = serde_json::from_value(json).unwrap();
360 assert_eq!(resp.session_token, "tok-abc");
361 }
362
363 #[test]
364 fn test_heartbeat_response_with_commands() {
365 let json = serde_json::json!({
366 "session_token": "new-tok",
367 "pending_commands": [
368 {
369 "id": "550e8400-e29b-41d4-a716-446655440000",
370 "command": "trigger_sync",
371 "payload": null
372 }
373 ]
374 });
375 let resp: HeartbeatResponse = serde_json::from_value(json).unwrap();
376 assert_eq!(resp.pending_commands.len(), 1);
377 assert_eq!(resp.pending_commands[0].command, "trigger_sync");
378 }
379
380 #[test]
381 fn test_sync_result_default() {
382 let r = SyncResult::default();
383 assert_eq!(r.readings_synced, 0);
384 assert!(!r.full_sync);
385 assert!(r.errors.is_empty());
386 }
387
388 #[test]
389 fn test_sync_result_serialization_skips_empty() {
390 let r = SyncResult {
391 readings_synced: 100,
392 ..Default::default()
393 };
394 let json = serde_json::to_value(&r).unwrap();
395 assert_eq!(json["readings_synced"], 100);
396 assert!(json.get("errors").is_none());
397 }
398
399 #[test]
400 fn test_ingest_reading_serialization() {
401 let r = IngestReading {
402 time: chrono::Utc::now(),
403 raw_value: 42.5,
404 replicate_index: 0,
405 sensor_id: None,
406 calibration_id: None,
407 deployment_id: None,
408 };
409 let json = serde_json::to_value(&r).unwrap();
410 assert_eq!(json["raw_value"], 42.5);
411 assert!(json.get("replicate_index").is_none());
412 assert!(json.get("sensor_id").is_none());
413 }
414
415 #[test]
416 fn test_register_stream_request() {
417 let req = RegisterStreamRequest {
418 source_system: "test_system".to_string(),
419 source_key: "source_1".to_string(),
420 source_name: Some("stream_a".to_string()),
421 source_path: None,
422 metadata: serde_json::json!({"device": "dev_001"}),
423 };
424 let json = serde_json::to_value(&req).unwrap();
425 assert_eq!(json["source_system"], "test_system");
426 assert_eq!(json["metadata"]["device"], "dev_001");
427 }
428
429 #[test]
430 fn test_data_stream_deserialization() {
431 let json = serde_json::json!({
432 "id": "550e8400-e29b-41d4-a716-446655440000",
433 "source_system": "test_system",
434 "source_key": "source_1",
435 "source_name": "stream_a",
436 "source_path": null,
437 "metadata": {},
438 "site_parameter_id": null,
439 "is_active": true,
440 "last_data_time": null
441 });
442 let stream: DataStream = serde_json::from_value(json).unwrap();
443 assert_eq!(stream.source_system, "test_system");
444 assert!(stream.is_active);
445 assert!(stream.site_parameter_id.is_none());
446 }
447}