/ src / settings.rs
settings.rs
  1  use anyhow::Result;
  2  use esp_idf_svc::nvs::{EspNvs, EspNvsPartition, NvsDefault};
  3  use log::{error, info};
  4  use serde::{Deserialize, Serialize};
  5  use std::sync::Mutex;
  6  
  7  use crate::network::auth;
  8  
  9  // =============================================================================
 10  // SYSTEM CONSTANTS (Compile-time, not user-configurable)
 11  // =============================================================================
 12  
 13  // Admin credentials for web interface (loaded at compile-time from .env or defaults)
 14  pub const ADMIN_USERNAME: &str = match option_env!("ADMIN_USERNAME") {
 15      Some(v) => v,
 16      None => "admin",
 17  };
 18  pub const ADMIN_PASSWORD: &str = match option_env!("ADMIN_PASSWORD") {
 19      Some(v) => v,
 20      None => "admin",
 21  };
 22  
 23  // Timing constants
 24  pub const SENSOR_READ_INTERVAL_MS: u64 = 2000; // Read every 2 seconds
 25  pub const BME680_MEASUREMENT_DELAY_MS: u32 = 150; // BME680 measurement delay with gas heater stability
 26  pub const WIFI_CHECK_INTERVAL_SECS: i64 = 30; // Check WiFi every 30 seconds
 27  pub const MQTT_RECONNECT_DELAY_MS: u64 = 5000; // Wait 5s before MQTT reconnect
 28  pub const WATCHDOG_TIMEOUT_SECS: u32 = 60; // System watchdog timeout
 29  pub const OTA_CHECK_INTERVAL_SECS: i64 = 86400; // Check for OTA updates daily (24 hours)
 30  pub const LED_BLINK_INTERVAL_MS: i64 = 500; // LED blink interval when WiFi disconnected (milliseconds)
 31  pub const MAIN_LOOP_DELAY_MS: u32 = 50; // Main loop delay for power efficiency (50ms reduces CPU load)
 32  pub const NVS_WRITE_BATCH_DELAY_SECS: u64 = 300; // Batch NVS writes (5 minutes) to reduce flash wear
 33  
 34  // Time conversion helpers (prevent overflow in microsecond calculations)
 35  #[allow(dead_code)]
 36  pub const MICROS_PER_SECOND: i64 = 1_000_000;
 37  #[allow(dead_code)]
 38  pub const MICROS_PER_MILLISECOND: i64 = 1_000;
 39  
 40  // Hardware configuration (informational - GPIO pins selected at compile time)
 41  #[allow(dead_code)]
 42  pub const STATUS_LED_GPIO: u8 = 2; // Onboard LED GPIO (usually GPIO2 on ESP32)
 43  #[allow(dead_code)]
 44  pub const ALARM_RELAY_GPIO: u8 = 25; // External alarm relay GPIO
 45  
 46  // =============================================================================
 47  // NVS KEY CONSTANTS (8-character max due to ESP32 constraints)
 48  // =============================================================================
 49  
 50  const NVS_NAMESPACE: &str = "settings";
 51  const KEY_WIFI_SSID: &str = "wifi_ssid";
 52  const KEY_WIFI_PASS: &str = "wifi_pass";
 53  const KEY_MQTT_BROKER: &str = "mqtt_broker";
 54  const KEY_PUBLISH_INT: &str = "pub_interval";
 55  
 56  // Multi-device relay configuration keys
 57  const KEY_EXHAUST_FAN_EN: &str = "exh_fan_en";
 58  const KEY_EXHAUST_FAN_GPIO: &str = "exh_fan_gpio";
 59  const KEY_CIRC_FAN_EN: &str = "circ_fan_en";
 60  const KEY_CIRC_FAN_GPIO: &str = "circ_fan_gpio";
 61  const KEY_HEATER_EN: &str = "heater_en";
 62  const KEY_HEATER_GPIO: &str = "heater_gpio";
 63  const KEY_PURIFIER_EN: &str = "purif_en";
 64  const KEY_PURIFIER_GPIO: &str = "purif_gpio";
 65  
 66  // Automation keys
 67  const KEY_AUTO_ENABLED: &str = "auto_en";
 68  const KEY_TEMP_HIGH_THRESH: &str = "temp_high";
 69  const KEY_TEMP_LOW_THRESH: &str = "temp_low";
 70  const KEY_HUM_HIGH_THRESH: &str = "hum_high";
 71  const KEY_GAS_THRESH: &str = "gas_thresh";
 72  
 73  // OTA keys
 74  const KEY_OTA_URL: &str = "ota_url";
 75  const KEY_OTA_AUTO: &str = "ota_auto";
 76  
 77  // Alarm keys
 78  const KEY_ALARM_ENABLED: &str = "alarm_en";
 79  const KEY_ALARM_WIFI_SEC: &str = "alarm_wifi";
 80  const KEY_ALARM_MQTT_SEC: &str = "alarm_mqtt";
 81  const KEY_ALARM_SENSOR_SEC: &str = "alarm_sens";
 82  const KEY_ALARM_TEMP_LOW: &str = "alarm_tlow";
 83  const KEY_ALARM_TEMP_HIGH: &str = "alarm_thigh";
 84  const KEY_ALARM_AUTO_SEC: &str = "alarm_auto";
 85  
 86  // Syslog keys
 87  const KEY_SYSLOG_ENABLED: &str = "syslog_en";
 88  const KEY_SYSLOG_SERVER: &str = "syslog_srv";
 89  const KEY_SYSLOG_PORT: &str = "syslog_port";
 90  
 91  // Device identification
 92  const KEY_APP_NAME: &str = "app_name";
 93  const KEY_HOSTNAME: &str = "hostname";
 94  const KEY_DEVICE_ID: &str = "device_id";
 95  
 96  // Backup keys
 97  const KEY_BACKUP_PASSWORD: &str = "backup_pw";
 98  const KEY_BACKUP_ENDPOINT: &str = "backup_ep";
 99  
100  // Authentication keys
101  const KEY_DEVICE_SECRET: &str = "dev_secret";
102  const KEY_REGISTRATION_URL: &str = "reg_url";
103  
104  // =============================================================================
105  // DATA STRUCTURES
106  // =============================================================================
107  
108  /// Device configuration
109  #[derive(Debug, Clone, Serialize, Deserialize)]
110  pub struct DeviceConfig {
111      pub enabled: bool,
112      pub gpio_pin: u8,
113  }
114  
115  impl Default for DeviceConfig {
116      fn default() -> Self {
117          Self {
118              enabled: false,
119              gpio_pin: 0,
120          }
121      }
122  }
123  
124  // =============================================================================
125  // SETTINGS IMPLEMENTATION
126  // =============================================================================
127  
128  /// Application settings that can be modified via web interface
129  #[derive(Debug, Serialize, Deserialize)]
130  pub struct Settings {
131      pub wifi_ssid: String,
132      pub wifi_pass: String,
133      pub mqtt_broker_url: String,
134      pub publish_interval_secs: i64,
135  
136      // Device configurations
137      pub exhaust_fan: DeviceConfig,
138      pub circulation_fan: DeviceConfig,
139      pub heater: DeviceConfig,
140      pub air_purifier: DeviceConfig,
141  
142      // Automation settings
143      pub automation_enabled: bool,
144      pub temp_high_threshold_c: f32,   // Turn on cooling/exhaust
145      pub temp_low_threshold_c: f32,    // Turn on heating
146      pub humidity_high_threshold: f32, // Turn on exhaust/purifier
147      pub gas_threshold_kohm: f32,      // Turn on purifier if gas < threshold
148  
149      // OTA settings
150      pub ota_update_url: String,
151      pub ota_auto_update: bool,
152  
153      // Alarm settings
154      pub alarm_enabled: bool,
155      pub alarm_wifi_disconnect_secs: i64,
156      pub alarm_mqtt_failure_secs: i64,
157      pub alarm_sensor_failure_secs: i64,
158      pub alarm_critical_temp_low: f32,
159      pub alarm_critical_temp_high: f32,
160      pub alarm_automation_delay_secs: i64,
161  
162      // Syslog settings
163      pub syslog_enabled: bool,
164      pub syslog_server: String,
165      pub syslog_port: u16,
166      pub app_name: String,
167      pub hostname: String,
168  
169      // Device identification (generated from MAC address on first boot)
170      pub device_id: String,
171  
172      // Settings backup configuration
173      pub backup_password: String,
174      pub backup_endpoint: String,
175  
176      // Device authentication (generated on first boot, never exported)
177      pub device_secret: String,
178      pub registration_url: String, // Server endpoint for first-boot registration
179  
180      // Internal: last NVS save timestamp for wear leveling (not persisted)
181      #[serde(skip)]
182      last_save_time: Mutex<u64>,
183  }
184  
185  impl Default for Settings {
186      fn default() -> Self {
187          Self {
188              wifi_ssid: option_env!("WIFI_SSID").unwrap_or("").to_string(),
189              wifi_pass: option_env!("WIFI_PASS").unwrap_or("").to_string(),
190              mqtt_broker_url: option_env!("MQTT_BROKER_URL").unwrap_or("").to_string(),
191              publish_interval_secs: option_env!("PUBLISH_INTERVAL_SECS")
192                  .and_then(|s| s.parse().ok())
193                  .unwrap_or(60),
194              exhaust_fan: DeviceConfig {
195                  enabled: false,
196                  gpio_pin: 26, // GPIO26
197              },
198              circulation_fan: DeviceConfig {
199                  enabled: false,
200                  gpio_pin: 27, // GPIO27
201              },
202              heater: DeviceConfig {
203                  enabled: false,
204                  gpio_pin: 14, // GPIO14
205              },
206              air_purifier: DeviceConfig {
207                  enabled: false,
208                  gpio_pin: 12, // GPIO12
209              },
210              automation_enabled: false,
211              temp_high_threshold_c: 28.0,   // Cooling threshold
212              temp_low_threshold_c: 18.0,    // Heating threshold
213              humidity_high_threshold: 70.0, // High humidity threshold
214              gas_threshold_kohm: 50.0,      // Air quality threshold
215              ota_update_url: option_env!("OTA_UPDATE_URL")
216                  .unwrap_or("https://update.ret.ac/firmware.bin")
217                  .to_string(),
218              ota_auto_update: false,
219              alarm_enabled: true,
220              alarm_wifi_disconnect_secs: 300,
221              alarm_mqtt_failure_secs: 600,
222              alarm_sensor_failure_secs: 120,
223              alarm_critical_temp_low: 5.0,
224              alarm_critical_temp_high: 45.0,
225              alarm_automation_delay_secs: 180,
226              syslog_enabled: option_env!("SYSLOG_ENABLED")
227                  .and_then(|s| s.parse().ok())
228                  .unwrap_or(false),
229              syslog_server: option_env!("SYSLOG_SERVER").unwrap_or("").to_string(),
230              syslog_port: option_env!("SYSLOG_PORT")
231                  .and_then(|s| s.parse().ok())
232                  .unwrap_or(514),
233              app_name: option_env!("APP_NAME").unwrap_or("Harrastila").to_string(),
234              hostname: option_env!("HOSTNAME").unwrap_or("ESP32").to_string(),
235              device_id: Self::generate_device_id(),
236              backup_password: String::new(),
237              backup_endpoint: option_env!("BACKUP_ENDPOINT")
238                  .unwrap_or("https://backup.ret.ac:8080/backup")
239                  .to_string(),
240              device_secret: String::new(), // Generated on first boot
241              registration_url: option_env!("REGISTRATION_URL")
242                  .unwrap_or("https://api.ret.ac/api/register")
243                  .to_string(),
244              last_save_time: Mutex::new(0),
245          }
246      }
247  }
248  
249  impl Clone for Settings {
250      fn clone(&self) -> Self {
251          Self {
252              wifi_ssid: self.wifi_ssid.clone(),
253              wifi_pass: self.wifi_pass.clone(),
254              mqtt_broker_url: self.mqtt_broker_url.clone(),
255              publish_interval_secs: self.publish_interval_secs,
256              exhaust_fan: self.exhaust_fan.clone(),
257              circulation_fan: self.circulation_fan.clone(),
258              heater: self.heater.clone(),
259              air_purifier: self.air_purifier.clone(),
260              automation_enabled: self.automation_enabled,
261              temp_high_threshold_c: self.temp_high_threshold_c,
262              temp_low_threshold_c: self.temp_low_threshold_c,
263              humidity_high_threshold: self.humidity_high_threshold,
264              gas_threshold_kohm: self.gas_threshold_kohm,
265              ota_update_url: self.ota_update_url.clone(),
266              ota_auto_update: self.ota_auto_update,
267              alarm_enabled: self.alarm_enabled,
268              alarm_wifi_disconnect_secs: self.alarm_wifi_disconnect_secs,
269              alarm_mqtt_failure_secs: self.alarm_mqtt_failure_secs,
270              alarm_sensor_failure_secs: self.alarm_sensor_failure_secs,
271              alarm_critical_temp_low: self.alarm_critical_temp_low,
272              alarm_critical_temp_high: self.alarm_critical_temp_high,
273              alarm_automation_delay_secs: self.alarm_automation_delay_secs,
274              syslog_enabled: self.syslog_enabled,
275              syslog_server: self.syslog_server.clone(),
276              syslog_port: self.syslog_port,
277              app_name: self.app_name.clone(),
278              hostname: self.hostname.clone(),
279              device_id: self.device_id.clone(),
280              backup_password: self.backup_password.clone(),
281              backup_endpoint: self.backup_endpoint.clone(),
282              device_secret: self.device_secret.clone(),
283              registration_url: self.registration_url.clone(),
284              last_save_time: Mutex::new(*self.last_save_time.lock().unwrap()),
285          }
286      }
287  }
288  
289  impl Settings {
290      /// Load settings from NVS, falling back to defaults if not found
291      /// Returns (Settings, is_first_boot)
292      pub fn load(nvs: &EspNvsPartition<NvsDefault>) -> Result<(Self, bool)> {
293          let nvs_handle = match EspNvs::new(nvs.clone(), NVS_NAMESPACE, true) {
294              Ok(h) => h,
295              Err(e) => {
296                  error!("Failed to open NVS namespace: {:?}", e);
297                  info!("Using default settings");
298                  return Ok((Settings::default(), false));
299              }
300          };
301          let settings = Settings {
302              wifi_ssid: Self::read_string(&nvs_handle, KEY_WIFI_SSID)
303                  .unwrap_or_else(|| option_env!("WIFI_SSID").unwrap_or("").to_string()),
304              wifi_pass: Self::read_string(&nvs_handle, KEY_WIFI_PASS)
305                  .unwrap_or_else(|| option_env!("WIFI_PASS").unwrap_or("").to_string()),
306              mqtt_broker_url: Self::read_string(&nvs_handle, KEY_MQTT_BROKER)
307                  .unwrap_or_else(|| option_env!("MQTT_BROKER_URL").unwrap_or("").to_string()),
308              publish_interval_secs: Self::read_i64(&nvs_handle, KEY_PUBLISH_INT).unwrap_or_else(
309                  || {
310                      option_env!("PUBLISH_INTERVAL_SECS")
311                          .and_then(|s| s.parse().ok())
312                          .unwrap_or(60)
313                  },
314              ),
315              exhaust_fan: DeviceConfig {
316                  enabled: Self::read_u8(&nvs_handle, KEY_EXHAUST_FAN_EN).unwrap_or(0) != 0,
317                  gpio_pin: Self::read_u8(&nvs_handle, KEY_EXHAUST_FAN_GPIO).unwrap_or(26),
318              },
319              circulation_fan: DeviceConfig {
320                  enabled: Self::read_u8(&nvs_handle, KEY_CIRC_FAN_EN).unwrap_or(0) != 0,
321                  gpio_pin: Self::read_u8(&nvs_handle, KEY_CIRC_FAN_GPIO).unwrap_or(27),
322              },
323              heater: DeviceConfig {
324                  enabled: Self::read_u8(&nvs_handle, KEY_HEATER_EN).unwrap_or(0) != 0,
325                  gpio_pin: Self::read_u8(&nvs_handle, KEY_HEATER_GPIO).unwrap_or(14),
326              },
327              air_purifier: DeviceConfig {
328                  enabled: Self::read_u8(&nvs_handle, KEY_PURIFIER_EN).unwrap_or(0) != 0,
329                  gpio_pin: Self::read_u8(&nvs_handle, KEY_PURIFIER_GPIO).unwrap_or(12),
330              },
331              automation_enabled: Self::read_u8(&nvs_handle, KEY_AUTO_ENABLED).unwrap_or(0) != 0,
332              temp_high_threshold_c: Self::read_i64(&nvs_handle, KEY_TEMP_HIGH_THRESH)
333                  .unwrap_or(28000) as f32
334                  / 1000.0,
335              temp_low_threshold_c: Self::read_i64(&nvs_handle, KEY_TEMP_LOW_THRESH).unwrap_or(18000)
336                  as f32
337                  / 1000.0,
338              humidity_high_threshold: Self::read_i64(&nvs_handle, KEY_HUM_HIGH_THRESH)
339                  .unwrap_or(70000) as f32
340                  / 1000.0,
341              gas_threshold_kohm: Self::read_i64(&nvs_handle, KEY_GAS_THRESH).unwrap_or(50000) as f32
342                  / 1000.0,
343              ota_update_url: Self::read_string(&nvs_handle, KEY_OTA_URL).unwrap_or_else(|| {
344                  option_env!("OTA_UPDATE_URL")
345                      .unwrap_or("https://update.ret.ac/firmware.bin")
346                      .to_string()
347              }),
348              ota_auto_update: Self::read_u8(&nvs_handle, KEY_OTA_AUTO).unwrap_or(0) != 0,
349              alarm_enabled: Self::read_u8(&nvs_handle, KEY_ALARM_ENABLED).unwrap_or(1) != 0,
350              alarm_wifi_disconnect_secs: Self::read_i64(&nvs_handle, KEY_ALARM_WIFI_SEC)
351                  .unwrap_or(300),
352              alarm_mqtt_failure_secs: Self::read_i64(&nvs_handle, KEY_ALARM_MQTT_SEC).unwrap_or(600),
353              alarm_sensor_failure_secs: Self::read_i64(&nvs_handle, KEY_ALARM_SENSOR_SEC)
354                  .unwrap_or(120),
355              alarm_critical_temp_low: Self::read_i64(&nvs_handle, KEY_ALARM_TEMP_LOW).unwrap_or(5000)
356                  as f32
357                  / 1000.0,
358              alarm_critical_temp_high: Self::read_i64(&nvs_handle, KEY_ALARM_TEMP_HIGH)
359                  .unwrap_or(45000) as f32
360                  / 1000.0,
361              alarm_automation_delay_secs: Self::read_i64(&nvs_handle, KEY_ALARM_AUTO_SEC)
362                  .unwrap_or(180),
363              syslog_enabled: Self::read_u8(&nvs_handle, KEY_SYSLOG_ENABLED).unwrap_or_else(|| {
364                  option_env!("SYSLOG_ENABLED")
365                      .and_then(|s| s.parse().ok())
366                      .unwrap_or(0)
367              }) != 0,
368              syslog_server: Self::read_string(&nvs_handle, KEY_SYSLOG_SERVER)
369                  .unwrap_or_else(|| option_env!("SYSLOG_SERVER").unwrap_or("").to_string()),
370              syslog_port: Self::read_i64(&nvs_handle, KEY_SYSLOG_PORT).unwrap_or_else(|| {
371                  option_env!("SYSLOG_PORT")
372                      .and_then(|s| s.parse().ok())
373                      .unwrap_or(514)
374              }) as u16,
375              app_name: Self::read_string(&nvs_handle, KEY_APP_NAME)
376                  .unwrap_or_else(|| option_env!("APP_NAME").unwrap_or("Harrastila").to_string()),
377              hostname: Self::read_string(&nvs_handle, KEY_HOSTNAME)
378                  .unwrap_or_else(|| option_env!("HOSTNAME").unwrap_or("ESP32").to_string()),
379              device_id: Self::read_string(&nvs_handle, KEY_DEVICE_ID)
380                  .unwrap_or_else(Self::generate_device_id),
381              backup_password: Self::read_string(&nvs_handle, KEY_BACKUP_PASSWORD)
382                  .unwrap_or_else(|| String::new()),
383              backup_endpoint: Self::read_string(&nvs_handle, KEY_BACKUP_ENDPOINT).unwrap_or_else(
384                  || {
385                      option_env!("BACKUP_ENDPOINT")
386                          .unwrap_or("https://backup.ret.ac/backup")
387                          .to_string()
388                  },
389              ),
390              device_secret: Self::read_string(&nvs_handle, KEY_DEVICE_SECRET)
391                  .unwrap_or_else(auth::generate_device_secret),
392              registration_url: Self::read_string(&nvs_handle, KEY_REGISTRATION_URL).unwrap_or_else(
393                  || {
394                      option_env!("REGISTRATION_URL")
395                          .unwrap_or("https://api.ret.ac/api/register")
396                          .to_string()
397                  },
398              ),
399              last_save_time: Mutex::new(0),
400          };
401  
402          // Save device_id and device_secret to NVS if they were just generated (first boot)
403          let mut first_boot = false;
404          if Self::read_string(&nvs_handle, KEY_DEVICE_ID).is_none() {
405              first_boot = true;
406              if let Err(e) = Self::write_string(
407                  &mut EspNvs::new(nvs.clone(), NVS_NAMESPACE, true)?,
408                  KEY_DEVICE_ID,
409                  &settings.device_id,
410              ) {
411                  error!("Failed to save device_id: {:?}", e);
412              } else {
413                  info!("Generated and saved device_id: {}", settings.device_id);
414              }
415          }
416  
417          if Self::read_string(&nvs_handle, KEY_DEVICE_SECRET).is_none() {
418              first_boot = true;
419              if let Err(e) = Self::write_string(
420                  &mut EspNvs::new(nvs.clone(), NVS_NAMESPACE, true)?,
421                  KEY_DEVICE_SECRET,
422                  &settings.device_secret,
423              ) {
424                  error!("Failed to save device_secret: {:?}", e);
425              } else {
426                  info!("Generated and saved device_secret (first boot)");
427              }
428          }
429  
430          if first_boot {
431              info!(
432                  "IMPORTANT: Device ID: {} - Register this device with the server!",
433                  settings.device_id
434              );
435          }
436  
437          info!("Settings loaded successfully");
438          Ok((settings, first_boot))
439      }
440  
441      /// Save settings to NVS
442      pub fn save(&self, nvs: &EspNvsPartition<NvsDefault>) -> Result<()> {
443          let mut nvs_handle = EspNvs::new(nvs.clone(), NVS_NAMESPACE, true)?;
444  
445          Self::write_string(&mut nvs_handle, KEY_WIFI_SSID, &self.wifi_ssid)?;
446          Self::write_string(&mut nvs_handle, KEY_WIFI_PASS, &self.wifi_pass)?;
447          Self::write_string(&mut nvs_handle, KEY_MQTT_BROKER, &self.mqtt_broker_url)?;
448          Self::write_i64(&mut nvs_handle, KEY_PUBLISH_INT, self.publish_interval_secs)?;
449  
450          // Device configurations
451          Self::write_u8(
452              &mut nvs_handle,
453              KEY_EXHAUST_FAN_EN,
454              if self.exhaust_fan.enabled { 1 } else { 0 },
455          )?;
456          Self::write_u8(
457              &mut nvs_handle,
458              KEY_EXHAUST_FAN_GPIO,
459              self.exhaust_fan.gpio_pin,
460          )?;
461          Self::write_u8(
462              &mut nvs_handle,
463              KEY_CIRC_FAN_EN,
464              if self.circulation_fan.enabled { 1 } else { 0 },
465          )?;
466          Self::write_u8(
467              &mut nvs_handle,
468              KEY_CIRC_FAN_GPIO,
469              self.circulation_fan.gpio_pin,
470          )?;
471          Self::write_u8(
472              &mut nvs_handle,
473              KEY_HEATER_EN,
474              if self.heater.enabled { 1 } else { 0 },
475          )?;
476          Self::write_u8(&mut nvs_handle, KEY_HEATER_GPIO, self.heater.gpio_pin)?;
477          Self::write_u8(
478              &mut nvs_handle,
479              KEY_PURIFIER_EN,
480              if self.air_purifier.enabled { 1 } else { 0 },
481          )?;
482          Self::write_u8(
483              &mut nvs_handle,
484              KEY_PURIFIER_GPIO,
485              self.air_purifier.gpio_pin,
486          )?;
487  
488          // Automation settings
489          Self::write_u8(
490              &mut nvs_handle,
491              KEY_AUTO_ENABLED,
492              if self.automation_enabled { 1 } else { 0 },
493          )?;
494          Self::write_i64(
495              &mut nvs_handle,
496              KEY_TEMP_HIGH_THRESH,
497              (self.temp_high_threshold_c * 1000.0) as i64,
498          )?;
499          Self::write_i64(
500              &mut nvs_handle,
501              KEY_TEMP_LOW_THRESH,
502              (self.temp_low_threshold_c * 1000.0) as i64,
503          )?;
504          Self::write_i64(
505              &mut nvs_handle,
506              KEY_HUM_HIGH_THRESH,
507              (self.humidity_high_threshold * 1000.0) as i64,
508          )?;
509          Self::write_i64(
510              &mut nvs_handle,
511              KEY_GAS_THRESH,
512              (self.gas_threshold_kohm * 1000.0) as i64,
513          )?;
514  
515          // OTA settings
516          Self::write_string(&mut nvs_handle, KEY_OTA_URL, &self.ota_update_url)?;
517          Self::write_u8(
518              &mut nvs_handle,
519              KEY_OTA_AUTO,
520              if self.ota_auto_update { 1 } else { 0 },
521          )?;
522  
523          // Alarm settings
524          Self::write_u8(
525              &mut nvs_handle,
526              KEY_ALARM_ENABLED,
527              if self.alarm_enabled { 1 } else { 0 },
528          )?;
529          Self::write_i64(
530              &mut nvs_handle,
531              KEY_ALARM_WIFI_SEC,
532              self.alarm_wifi_disconnect_secs,
533          )?;
534          Self::write_i64(
535              &mut nvs_handle,
536              KEY_ALARM_MQTT_SEC,
537              self.alarm_mqtt_failure_secs,
538          )?;
539          Self::write_i64(
540              &mut nvs_handle,
541              KEY_ALARM_SENSOR_SEC,
542              self.alarm_sensor_failure_secs,
543          )?;
544          Self::write_i64(
545              &mut nvs_handle,
546              KEY_ALARM_TEMP_LOW,
547              (self.alarm_critical_temp_low * 1000.0) as i64,
548          )?;
549          Self::write_i64(
550              &mut nvs_handle,
551              KEY_ALARM_TEMP_HIGH,
552              (self.alarm_critical_temp_high * 1000.0) as i64,
553          )?;
554          Self::write_i64(
555              &mut nvs_handle,
556              KEY_ALARM_AUTO_SEC,
557              self.alarm_automation_delay_secs,
558          )?;
559  
560          // Syslog settings
561          Self::write_u8(
562              &mut nvs_handle,
563              KEY_SYSLOG_ENABLED,
564              if self.syslog_enabled { 1 } else { 0 },
565          )?;
566          Self::write_string(&mut nvs_handle, KEY_SYSLOG_SERVER, &self.syslog_server)?;
567          Self::write_i64(&mut nvs_handle, KEY_SYSLOG_PORT, self.syslog_port as i64)?;
568          Self::write_string(&mut nvs_handle, KEY_APP_NAME, &self.app_name)?;
569          Self::write_string(&mut nvs_handle, KEY_HOSTNAME, &self.hostname)?;
570          Self::write_string(&mut nvs_handle, KEY_DEVICE_ID, &self.device_id)?;
571          Self::write_string(&mut nvs_handle, KEY_BACKUP_PASSWORD, &self.backup_password)?;
572          Self::write_string(&mut nvs_handle, KEY_BACKUP_ENDPOINT, &self.backup_endpoint)?;
573          Self::write_string(&mut nvs_handle, KEY_DEVICE_SECRET, &self.device_secret)?;
574          Self::write_string(
575              &mut nvs_handle,
576              KEY_REGISTRATION_URL,
577              &self.registration_url,
578          )?;
579  
580          // Update last save timestamp for wear leveling
581          *self.last_save_time.lock().unwrap() = Self::get_current_time_secs();
582  
583          info!("Settings saved successfully");
584          Ok(())
585      }
586  
587      /// Save settings to NVS only if enough time has passed since last save
588      /// This reduces flash wear by batching writes
589      /// Returns true if settings were actually saved, false if skipped
590      #[allow(dead_code)]
591      pub fn save_if_dirty(&self, nvs: &EspNvsPartition<NvsDefault>) -> Result<bool> {
592          let now = Self::get_current_time_secs();
593          let last_save = *self.last_save_time.lock().unwrap();
594  
595          // Skip save if within batch delay window
596          if last_save > 0 && (now - last_save) < NVS_WRITE_BATCH_DELAY_SECS {
597              return Ok(false); // Settings not saved (batched for later)
598          }
599  
600          self.save(nvs)?;
601          Ok(true)
602      }
603  
604      /// Force immediate save to NVS (bypasses wear leveling delay)
605      #[allow(dead_code)]
606      pub fn save_immediate(&self, nvs: &EspNvsPartition<NvsDefault>) -> Result<()> {
607          self.save(nvs)
608      }
609  
610      fn read_string(nvs: &EspNvs<NvsDefault>, key: &str) -> Option<String> {
611          let mut buf = [0u8; 128];
612          match nvs.get_str(key, &mut buf) {
613              Ok(Some(s)) => Some(s.to_string()),
614              _ => None,
615          }
616      }
617  
618      fn write_string(nvs: &mut EspNvs<NvsDefault>, key: &str, value: &str) -> Result<()> {
619          nvs.set_str(key, value)?;
620          Ok(())
621      }
622  
623      fn read_u8(nvs: &EspNvs<NvsDefault>, key: &str) -> Option<u8> {
624          nvs.get_u8(key).ok().flatten()
625      }
626  
627      fn write_u8(nvs: &mut EspNvs<NvsDefault>, key: &str, value: u8) -> Result<()> {
628          nvs.set_u8(key, value)?;
629          Ok(())
630      }
631  
632      fn read_i64(nvs: &EspNvs<NvsDefault>, key: &str) -> Option<i64> {
633          nvs.get_i64(key).ok().flatten()
634      }
635  
636      fn write_i64(nvs: &mut EspNvs<NvsDefault>, key: &str, value: i64) -> Result<()> {
637          nvs.set_i64(key, value)?;
638          Ok(())
639      }
640  
641      /// Get current time in seconds since boot (for timestamps)
642      fn get_current_time_secs() -> u64 {
643          unsafe { esp_idf_svc::sys::esp_timer_get_time() as u64 / 1_000_000 }
644      }
645  
646      /// Generate a unique device identifier from MAC address
647      /// Returns last 6 characters of base32-encoded MAC for readability
648      fn generate_device_id() -> String {
649          use esp_idf_svc::sys::{esp_efuse_mac_get_default, ESP_OK};
650  
651          let mut mac: [u8; 6] = [0; 6];
652          unsafe {
653              let result = esp_efuse_mac_get_default(mac.as_mut_ptr());
654              if result != ESP_OK {
655                  // Fallback to random value if MAC read fails
656                  return format!(
657                      "UNKN{:04X}",
658                      (std::time::SystemTime::now()
659                          .duration_since(std::time::UNIX_EPOCH)
660                          .unwrap_or_default()
661                          .as_secs()
662                          & 0xFFFF)
663                  );
664              }
665          }
666  
667          // Create compact ID using last 4 bytes of MAC in hex
668          // Format: AABBCCDD (8 characters, easy to read)
669          format!("{:02X}{:02X}{:02X}{:02X}", mac[2], mac[3], mac[4], mac[5])
670      }
671  }