""" Configuration management for Sensorpajen. Loads configuration from environment variables with sensible defaults. Configuration files are loaded relative to the project root. """ import os import json import logging from pathlib import Path from typing import Dict, List, Optional logger = logging.getLogger(__name__) # Determine project root and config directory # Check if running from system installation (/opt/sensorpajen) current_file = Path(__file__).resolve() is_system_install = str(current_file).startswith('/opt/sensorpajen') if is_system_install: # System installation PROJECT_ROOT = Path('/opt/sensorpajen') CONFIG_DIR = Path('/etc/sensorpajen') STATE_DIR = Path('/var/lib/sensorpajen') else: # Development installation # Enforce using directory explicitly for dev-remote case # In dev-remote, we run from ~/sensorpajen-dev context # Check if we are in sensorpajen-dev environment cwd = Path.cwd() if 'sensorpajen-dev' in str(cwd) or (cwd / 'config').exists(): PROJECT_ROOT = cwd else: # Fallback logic PROJECT_ROOT = Path(__file__).parent.parent.parent CONFIG_DIR = PROJECT_ROOT / "config" STATE_DIR = CONFIG_DIR # MQTT Configuration from environment MQTT_HOST = os.environ.get("MQTT_HOST") MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) MQTT_USER = os.environ.get("MQTT_USER") MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD") MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "sensorpajen") MQTT_TOPIC_PREFIX = os.environ.get("MQTT_TOPIC_PREFIX", "MiTemperature2") def validate_mqtt_config(): """Validate that required MQTT configuration is present.""" if not MQTT_HOST: raise RuntimeError( "MQTT_HOST environment variable must be set. " "Please configure config/sensorpajen.env" ) # Sensor configuration file SENSOR_CONFIG_FILE = os.environ.get( "SENSOR_CONFIG_FILE", str(CONFIG_DIR / "sensors.json") ) # Application settings WATCHDOG_TIMEOUT = int(os.environ.get("WATCHDOG_TIMEOUT", "5")) ENABLE_BATTERY = os.environ.get("ENABLE_BATTERY", "true").lower() == "true" LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() # Bluetooth settings SKIP_IDENTICAL = int(os.environ.get("SKIP_IDENTICAL", "50")) DEBOUNCE = os.environ.get("DEBOUNCE", "true").lower() == "true" # ntfy notification settings (optional) NTFY_ENABLED = os.environ.get("NTFY_ENABLED", "false").lower() == "true" NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.sh") NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "sensorpajen") NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") # Discovery settings DISCOVERED_SENSORS_FILE = os.environ.get( "DISCOVERED_SENSORS_FILE", str(STATE_DIR / "discovered_sensors.json") ) DATABASE_FILE = os.environ.get( "DATABASE_FILE", str(STATE_DIR / "sensorpajen.db") ) CONFIG_RELOAD_INTERVAL = int(os.environ.get("CONFIG_RELOAD_INTERVAL", "900")) # 15 minutes class SensorConfig: """Manages sensor configuration from JSON file.""" def __init__(self, config_file: str = SENSOR_CONFIG_FILE): """ Initialize sensor configuration. Args: config_file: Path to sensors JSON configuration file """ self.config_file = Path(config_file) self.sensors: Dict[str, str] = {} self.comments: Dict[str, str] = {} self.load() def load(self): """Load sensor configuration from JSON file.""" if not self.config_file.exists(): logger.warning( f"Sensor configuration file not found: {self.config_file}\n" f"Starting with no sensors - use discovery to add sensors" ) return try: with open(self.config_file, 'r') as f: data = json.load(f) # Convert sensors list to MAC -> name mapping for sensor in data.get('sensors', []): mac = sensor.get('mac', '').upper() name = sensor.get('name') comment = sensor.get('comment') if mac and name: self.sensors[mac] = name if isinstance(comment, str) and comment != "": self.comments[mac] = comment logger.debug(f"Loaded sensor: {mac} -> {name}") logger.info(f"Loaded {len(self.sensors)} sensors from {self.config_file}") except json.JSONDecodeError as e: raise RuntimeError(f"Invalid JSON in {self.config_file}: {e}") except Exception as e: raise RuntimeError(f"Error loading sensor config: {e}") def get_name(self, mac: str) -> str: """ Get sensor name by MAC address. Args: mac: MAC address (any case) Returns: Sensor name or the MAC address if not found """ return self.sensors.get(mac.upper(), mac) def get_all_macs(self) -> List[str]: """Get list of all configured MAC addresses.""" return list(self.sensors.keys()) def get_comment(self, mac: str) -> Optional[str]: """Get sensor comment by MAC address, if present.""" return self.comments.get(mac.upper()) def add_sensor(self, mac: str, name: str, comment: Optional[str] = None): """ Add or update a sensor in the configuration. Args: mac: MAC address name: Sensor name comment: Optional comment """ mac = mac.upper() logger.debug(f"add_sensor called: MAC={mac}, name={name}") self.sensors[mac] = name if comment is not None: # Allow explicit clearing by passing empty string if comment == "": self.comments.pop(mac, None) else: self.comments[mac] = comment logger.debug(f"Updated in-memory dict: {mac} -> {name}") logger.debug(f"Current sensors dict: {self.sensors}") try: self.save(mac, name, comment) logger.info(f"Successfully saved sensor {mac}={name}") except Exception as e: # If save fails, remove from memory too logger.error(f"Failed to save sensor {mac}: {e}") if mac in self.sensors: del self.sensors[mac] raise e def remove_sensor(self, mac: str): """ Remove a sensor from the configuration. Args: mac: MAC address """ mac = mac.upper() if mac in self.sensors: del self.sensors[mac] self.comments.pop(mac, None) # Load current file, remove entry, and save try: if self.config_file.exists(): with open(self.config_file, 'r') as f: data = json.load(f) sensors = data.get('sensors', []) data['sensors'] = [s for s in sensors if s.get('mac', '').upper() != mac] with open(self.config_file, 'w') as f: json.dump(data, f, indent=2) logger.info(f"Removed sensor {mac} from {self.config_file}") except Exception as e: logger.error(f"Error removing sensor from config: {e}") def save(self, mac: str, name: str, comment: Optional[str] = None): """ Save a sensor to the configuration file. Args: mac: MAC address name: Sensor name comment: Optional comment """ mac = mac.upper() logger.debug(f"save() called for MAC={mac}, name={name}") data = {"sensors": []} try: if self.config_file.exists(): logger.debug(f"Reading existing config from {self.config_file}") with open(self.config_file, 'r') as f: data = json.load(f) logger.debug(f"Loaded config with {len(data.get('sensors', []))} sensors") else: logger.debug(f"Config file does not exist: {self.config_file}") sensors = data.get('sensors', []) # Update existing or add new found = False for s in sensors: if s.get('mac', '').upper() == mac: logger.debug(f"Found existing sensor entry for {mac}, updating name") s['name'] = name if comment is not None: if comment == "": s.pop('comment', None) else: s['comment'] = comment found = True break if not found: logger.debug(f"Sensor {mac} not found in config, adding new entry") new_sensor = {"mac": mac, "name": name} if comment is not None and comment != "": new_sensor["comment"] = comment sensors.append(new_sensor) data['sensors'] = sensors # Ensure directory exists self.config_file.parent.mkdir(parents=True, exist_ok=True) logger.debug(f"Writing {len(sensors)} sensors to {self.config_file}") with open(self.config_file, 'w') as f: json.dump(data, f, indent=2) logger.info(f"Saved sensor {mac} to {self.config_file}") # Verify the write with open(self.config_file, 'r') as f: saved_data = json.load(f) saved_sensors = saved_data.get('sensors', []) logger.debug(f"Verification: File now contains {len(saved_sensors)} sensors") for s in saved_sensors: if s.get('mac', '').upper() == mac: logger.debug(f"Verification: Found {mac} in file with name={s.get('name')}") except Exception as e: logger.error(f"Error saving sensor config: {e}", exc_info=True) raise e def save_env_var(key: str, value: str): """ Update a value in the sensorpajen.env file. Args: key: Environment variable name value: New value """ env_file = CONFIG_DIR / "sensorpajen.env" if not env_file.exists(): raise FileNotFoundError(f"Env file not found: {env_file}") try: lines = [] with open(env_file, 'r') as f: lines = f.readlines() new_lines = [] found = False for line in lines: if line.strip().startswith(f"{key}="): new_lines.append(f"{key}={value}\n") found = True else: new_lines.append(line) if not found: # Add to end if not found if new_lines and not new_lines[-1].endswith('\n'): new_lines.append('\n') new_lines.append(f"{key}={value}\n") with open(env_file, 'w') as f: f.writelines(new_lines) logger.info(f"Updated {key} in {env_file}") except Exception as e: logger.error(f"Error updating env file: {e}") raise e def validate_config(): """ Validate configuration and log settings. Should be called at application startup. """ validate_mqtt_config() install_type = "System" if Path('/opt/sensorpajen').exists() else "Development" logger.info("=== Sensorpajen Configuration ===") logger.info(f"Installation Type: {install_type}") logger.info(f"Project Root: {PROJECT_ROOT}") logger.info(f"Config Directory: {CONFIG_DIR}") logger.info(f"State Directory: {STATE_DIR}") logger.info(f"MQTT Host: {MQTT_HOST}:{MQTT_PORT}") logger.info(f"MQTT User: {MQTT_USER}") logger.info(f"MQTT Client ID: {MQTT_CLIENT_ID}") logger.info(f"MQTT Topic Prefix: {MQTT_TOPIC_PREFIX}") logger.info(f"Sensor Config: {SENSOR_CONFIG_FILE}") logger.info(f"Discovered Sensors: {DISCOVERED_SENSORS_FILE}") logger.info(f"Watchdog Timeout: {WATCHDOG_TIMEOUT}s") logger.info(f"Battery Monitoring: {ENABLE_BATTERY}") logger.info(f"Config Reload Interval: {CONFIG_RELOAD_INTERVAL}s") logger.info(f"ntfy Enabled: {NTFY_ENABLED}") if NTFY_ENABLED: logger.info(f"ntfy URL: {NTFY_URL}/{NTFY_TOPIC}") logger.info(f"Log Level: {LOG_LEVEL}") logger.info("================================")