- 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
352 lines
12 KiB
Python
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("================================")
|