koprogo_api/domain/entities/
linky_device.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Linky smart meter device configuration
6/// Stores configuration for Linky (Enedis France) or Ores (Belgium) smart meters
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct LinkyDevice {
9    pub id: Uuid,
10    pub building_id: Uuid,
11    pub prm: String, // Point Reference Measure (unique meter identifier)
12    pub provider: LinkyProvider,
13    pub api_key_encrypted: String, // OAuth2 access token (encrypted at rest)
14    pub refresh_token_encrypted: Option<String>, // OAuth2 refresh token (encrypted)
15    pub token_expires_at: Option<DateTime<Utc>>,
16    pub last_sync_at: Option<DateTime<Utc>>,
17    pub sync_enabled: bool,
18    pub created_at: DateTime<Utc>,
19    pub updated_at: DateTime<Utc>,
20}
21
22impl LinkyDevice {
23    /// Create a new Linky device configuration
24    pub fn new(
25        building_id: Uuid,
26        prm: String,
27        provider: LinkyProvider,
28        api_key_encrypted: String,
29    ) -> Result<Self, String> {
30        // Validate PRM (Point Reference Measure)
31        Self::validate_prm(&prm)?;
32
33        // Validate API key is non-empty
34        if api_key_encrypted.trim().is_empty() {
35            return Err("API key cannot be empty".to_string());
36        }
37
38        Ok(Self {
39            id: Uuid::new_v4(),
40            building_id,
41            prm,
42            provider,
43            api_key_encrypted,
44            refresh_token_encrypted: None,
45            token_expires_at: None,
46            last_sync_at: None,
47            sync_enabled: true,
48            created_at: Utc::now(),
49            updated_at: Utc::now(),
50        })
51    }
52
53    /// Set refresh token (for OAuth2 token rotation)
54    pub fn with_refresh_token(
55        mut self,
56        refresh_token_encrypted: String,
57        expires_at: DateTime<Utc>,
58    ) -> Self {
59        self.refresh_token_encrypted = Some(refresh_token_encrypted);
60        self.token_expires_at = Some(expires_at);
61        self
62    }
63
64    /// Enable/disable automatic sync
65    pub fn set_sync_enabled(&mut self, enabled: bool) {
66        self.sync_enabled = enabled;
67        self.updated_at = Utc::now();
68    }
69
70    /// Enable automatic sync (convenience method)
71    pub fn enable_sync(&mut self) {
72        self.set_sync_enabled(true);
73    }
74
75    /// Disable automatic sync (convenience method)
76    pub fn disable_sync(&mut self) {
77        self.set_sync_enabled(false);
78    }
79
80    /// Update last sync timestamp
81    pub fn mark_synced(&mut self) {
82        self.last_sync_at = Some(Utc::now());
83        self.updated_at = Utc::now();
84    }
85
86    /// Update OAuth2 tokens (access + refresh)
87    pub fn update_tokens(
88        &mut self,
89        api_key_encrypted: String,
90        refresh_token_encrypted: Option<String>,
91        expires_at: Option<DateTime<Utc>>,
92    ) -> Result<(), String> {
93        if api_key_encrypted.trim().is_empty() {
94            return Err("API key cannot be empty".to_string());
95        }
96
97        self.api_key_encrypted = api_key_encrypted;
98        self.refresh_token_encrypted = refresh_token_encrypted;
99        self.token_expires_at = expires_at;
100        self.updated_at = Utc::now();
101
102        Ok(())
103    }
104
105    /// Check if OAuth2 token is expired or about to expire (within 5 minutes)
106    pub fn is_token_expired(&self) -> bool {
107        match self.token_expires_at {
108            Some(expires_at) => expires_at <= Utc::now() + chrono::Duration::minutes(5),
109            None => false, // No expiration set, assume valid
110        }
111    }
112
113    /// Check if device needs sync (never synced or last sync > 24h ago)
114    pub fn needs_sync(&self) -> bool {
115        if !self.sync_enabled {
116            return false;
117        }
118
119        match self.last_sync_at {
120            Some(last_sync) => {
121                let hours_since_sync = (Utc::now() - last_sync).num_hours();
122                hours_since_sync >= 24
123            }
124            None => true, // Never synced
125        }
126    }
127
128    /// Validate PRM format
129    /// - France (Enedis): 14 digits
130    /// - Belgium (Ores): 18 digits (EAN code)
131    fn validate_prm(prm: &str) -> Result<(), String> {
132        let prm = prm.trim();
133
134        if prm.is_empty() {
135            return Err("PRM cannot be empty".to_string());
136        }
137
138        // Check if all characters are digits
139        if !prm.chars().all(|c| c.is_ascii_digit()) {
140            return Err(format!("PRM must contain only digits: {}", prm));
141        }
142
143        // Validate length (14 for France, 18 for Belgium)
144        let len = prm.len();
145        if len != 14 && len != 18 {
146            return Err(format!(
147                "PRM must be 14 digits (France) or 18 digits (Belgium), got {}: {}",
148                len, prm
149            ));
150        }
151
152        Ok(())
153    }
154
155    /// Get API endpoint for this provider
156    pub fn api_endpoint(&self) -> &'static str {
157        match self.provider {
158            LinkyProvider::Ores => "https://ext.prod-eu.oresnet.be/v1",
159            LinkyProvider::Enedis => "https://ext.hml.myelectricaldata.fr/v1",
160        }
161    }
162}
163
164/// Linky smart meter provider
165#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "snake_case")]
167pub enum LinkyProvider {
168    Ores,   // Belgium (Ores network)
169    Enedis, // France (Enedis Linky)
170}
171
172impl std::fmt::Display for LinkyProvider {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            LinkyProvider::Ores => write!(f, "Ores"),
176            LinkyProvider::Enedis => write!(f, "Enedis"),
177        }
178    }
179}
180
181impl std::str::FromStr for LinkyProvider {
182    type Err = String;
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        match s {
185            "Ores" => Ok(LinkyProvider::Ores),
186            "Enedis" => Ok(LinkyProvider::Enedis),
187            _ => Err(format!("Invalid LinkyProvider: {}", s)),
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn sample_building_id() -> Uuid {
197        Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
198    }
199
200    #[test]
201    fn test_create_linky_device_success() {
202        let device = LinkyDevice::new(
203            sample_building_id(),
204            "12345678901234".to_string(), // 14 digits (France)
205            LinkyProvider::Enedis,
206            "encrypted_access_token".to_string(),
207        );
208
209        assert!(device.is_ok());
210        let d = device.unwrap();
211        assert_eq!(d.building_id, sample_building_id());
212        assert_eq!(d.prm, "12345678901234");
213        assert_eq!(d.provider, LinkyProvider::Enedis);
214        assert!(d.sync_enabled);
215        assert!(d.last_sync_at.is_none());
216    }
217
218    #[test]
219    fn test_create_linky_device_belgium() {
220        let device = LinkyDevice::new(
221            sample_building_id(),
222            "541448030003312345".to_string(), // 18 digits (Belgium EAN)
223            LinkyProvider::Ores,
224            "encrypted_access_token".to_string(),
225        );
226
227        assert!(device.is_ok());
228        let d = device.unwrap();
229        assert_eq!(d.prm, "541448030003312345");
230        assert_eq!(d.provider, LinkyProvider::Ores);
231    }
232
233    #[test]
234    fn test_validate_prm_empty() {
235        let device = LinkyDevice::new(
236            sample_building_id(),
237            "".to_string(),
238            LinkyProvider::Enedis,
239            "encrypted_access_token".to_string(),
240        );
241
242        assert!(device.is_err());
243        assert!(device.unwrap_err().contains("PRM cannot be empty"));
244    }
245
246    #[test]
247    fn test_validate_prm_invalid_length() {
248        let device = LinkyDevice::new(
249            sample_building_id(),
250            "12345".to_string(), // Too short
251            LinkyProvider::Enedis,
252            "encrypted_access_token".to_string(),
253        );
254
255        assert!(device.is_err());
256        assert!(device.unwrap_err().contains("must be 14 digits"));
257    }
258
259    #[test]
260    fn test_validate_prm_non_digits() {
261        let device = LinkyDevice::new(
262            sample_building_id(),
263            "1234567890ABCD".to_string(), // Contains letters
264            LinkyProvider::Enedis,
265            "encrypted_access_token".to_string(),
266        );
267
268        assert!(device.is_err());
269        assert!(device.unwrap_err().contains("must contain only digits"));
270    }
271
272    #[test]
273    fn test_validate_api_key_empty() {
274        let device = LinkyDevice::new(
275            sample_building_id(),
276            "12345678901234".to_string(),
277            LinkyProvider::Enedis,
278            "".to_string(),
279        );
280
281        assert!(device.is_err());
282        assert!(device.unwrap_err().contains("API key cannot be empty"));
283    }
284
285    #[test]
286    fn test_with_refresh_token() {
287        let expires_at = Utc::now() + chrono::Duration::hours(1);
288        let device = LinkyDevice::new(
289            sample_building_id(),
290            "12345678901234".to_string(),
291            LinkyProvider::Enedis,
292            "encrypted_access_token".to_string(),
293        )
294        .unwrap()
295        .with_refresh_token("encrypted_refresh_token".to_string(), expires_at);
296
297        assert!(device.refresh_token_encrypted.is_some());
298        assert_eq!(
299            device.refresh_token_encrypted.unwrap(),
300            "encrypted_refresh_token"
301        );
302        assert_eq!(device.token_expires_at.unwrap(), expires_at);
303    }
304
305    #[test]
306    fn test_set_sync_enabled() {
307        let mut device = LinkyDevice::new(
308            sample_building_id(),
309            "12345678901234".to_string(),
310            LinkyProvider::Enedis,
311            "encrypted_access_token".to_string(),
312        )
313        .unwrap();
314
315        assert!(device.sync_enabled);
316
317        device.set_sync_enabled(false);
318        assert!(!device.sync_enabled);
319    }
320
321    #[test]
322    fn test_mark_synced() {
323        let mut device = LinkyDevice::new(
324            sample_building_id(),
325            "12345678901234".to_string(),
326            LinkyProvider::Enedis,
327            "encrypted_access_token".to_string(),
328        )
329        .unwrap();
330
331        assert!(device.last_sync_at.is_none());
332
333        device.mark_synced();
334        assert!(device.last_sync_at.is_some());
335        assert!(device.last_sync_at.unwrap() <= Utc::now());
336    }
337
338    #[test]
339    fn test_update_tokens() {
340        let mut device = LinkyDevice::new(
341            sample_building_id(),
342            "12345678901234".to_string(),
343            LinkyProvider::Enedis,
344            "old_token".to_string(),
345        )
346        .unwrap();
347
348        let expires_at = Utc::now() + chrono::Duration::hours(2);
349        let result = device.update_tokens(
350            "new_access_token".to_string(),
351            Some("new_refresh_token".to_string()),
352            Some(expires_at),
353        );
354
355        assert!(result.is_ok());
356        assert_eq!(device.api_key_encrypted, "new_access_token");
357        assert_eq!(device.refresh_token_encrypted.unwrap(), "new_refresh_token");
358        assert_eq!(device.token_expires_at.unwrap(), expires_at);
359    }
360
361    #[test]
362    fn test_update_tokens_empty() {
363        let mut device = LinkyDevice::new(
364            sample_building_id(),
365            "12345678901234".to_string(),
366            LinkyProvider::Enedis,
367            "old_token".to_string(),
368        )
369        .unwrap();
370
371        let result = device.update_tokens("".to_string(), None, None);
372
373        assert!(result.is_err());
374        assert!(result.unwrap_err().contains("API key cannot be empty"));
375    }
376
377    #[test]
378    fn test_is_token_expired() {
379        let mut device = LinkyDevice::new(
380            sample_building_id(),
381            "12345678901234".to_string(),
382            LinkyProvider::Enedis,
383            "encrypted_access_token".to_string(),
384        )
385        .unwrap();
386
387        // No expiration set
388        assert!(!device.is_token_expired());
389
390        // Token expires in 10 minutes (not expired)
391        device.token_expires_at = Some(Utc::now() + chrono::Duration::minutes(10));
392        assert!(!device.is_token_expired());
393
394        // Token expires in 3 minutes (considered expired - within 5 min buffer)
395        device.token_expires_at = Some(Utc::now() + chrono::Duration::minutes(3));
396        assert!(device.is_token_expired());
397
398        // Token already expired
399        device.token_expires_at = Some(Utc::now() - chrono::Duration::hours(1));
400        assert!(device.is_token_expired());
401    }
402
403    #[test]
404    fn test_needs_sync() {
405        let mut device = LinkyDevice::new(
406            sample_building_id(),
407            "12345678901234".to_string(),
408            LinkyProvider::Enedis,
409            "encrypted_access_token".to_string(),
410        )
411        .unwrap();
412
413        // Never synced
414        assert!(device.needs_sync());
415
416        // Synced recently (1 hour ago)
417        device.last_sync_at = Some(Utc::now() - chrono::Duration::hours(1));
418        assert!(!device.needs_sync());
419
420        // Synced long ago (25 hours ago)
421        device.last_sync_at = Some(Utc::now() - chrono::Duration::hours(25));
422        assert!(device.needs_sync());
423
424        // Sync disabled
425        device.set_sync_enabled(false);
426        assert!(!device.needs_sync());
427    }
428
429    #[test]
430    fn test_api_endpoint() {
431        let device_ores = LinkyDevice::new(
432            sample_building_id(),
433            "541448030003312345".to_string(),
434            LinkyProvider::Ores,
435            "encrypted_access_token".to_string(),
436        )
437        .unwrap();
438
439        assert_eq!(
440            device_ores.api_endpoint(),
441            "https://ext.prod-eu.oresnet.be/v1"
442        );
443
444        let device_enedis = LinkyDevice::new(
445            sample_building_id(),
446            "12345678901234".to_string(),
447            LinkyProvider::Enedis,
448            "encrypted_access_token".to_string(),
449        )
450        .unwrap();
451
452        assert_eq!(
453            device_enedis.api_endpoint(),
454            "https://ext.hml.myelectricaldata.fr/v1"
455        );
456    }
457}