Files
sensorpajen/src/sensorpajen/config.py
Fredrik Wahlberg fcaaf29307 Release v3.0.0
- Bump version to 3.0.0 and update docs

- Fix Debian payload to include TUI and install /usr/bin/sensorpajen-tui wrapper

- Make systemd unit upgrades safer and ignore deb build artifacts
2025-12-29 15:34:03 +01:00

352 lines
12 KiB
Python

"""
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("================================")