feat: config files, settings screen, hardware check
Build Android APK / build (push) Failing after 5m7s
Build Server .exe / build (push) Failing after 2m27s

- Add config.py with multi-source config (YAML, .env, env vars)
- Add .env.example and config.yaml.example
- Add settings API endpoints (GET/POST /api/v1/settings)
- Add status endpoint (GET /api/v1/status) with hardware subsystem check
- Add settings screen to web client (protected by API token)
- Add localization strings for settings UI
- Add tests for config module and new endpoints
- Update README with config documentation
This commit is contained in:
Imrayya
2026-07-01 13:32:08 +00:00
parent 8e2212f035
commit 871a3cc174
16 changed files with 1341 additions and 55 deletions
+176
View File
@@ -0,0 +1,176 @@
"""Configuration management for PaperDash.
Loads config from:
1. config.yaml (if exists)
2. .env file (if exists)
3. Environment variables (fallback)
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import Any
logger = logging.getLogger("paperdash.config")
# Try to import yaml, fall back to simple parser
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
class Config:
"""PaperDash configuration."""
def __init__(self) -> None:
self.dashboard_pin: str = ""
self.api_token: str = ""
self.host: str = "0.0.0.0"
self.port: int = 8921
self.log_level: str = "INFO"
def load(self) -> None:
"""Load configuration from all sources."""
# 1. Load config.yaml
config_file = self._find_config_file()
if config_file and config_file.exists():
self._load_yaml(config_file)
# 2. Load .env file
env_file = self._find_env_file()
if env_file and env_file.exists():
self._load_env(env_file)
# 3. Environment variables override everything
self._load_from_env()
# Validate
if not self.dashboard_pin:
logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled")
if not self.api_token:
logger.warning("API_TOKEN not set — /api/v1/notify will reject all requests")
def _get_base_path(self) -> Path:
"""Get base path (exe directory or cwd)."""
if getattr(sys, 'frozen', False):
return Path(sys.executable).parent
return Path.cwd()
def _find_config_file(self) -> Path | None:
"""Find config.yaml relative to the executable or working directory."""
base = self._get_base_path()
for candidate in [base] + list(base.parents):
config_path = candidate / "config.yaml"
if config_path.exists():
return config_path
return None
def _find_env_file(self) -> Path | None:
"""Find .env file relative to the executable or working directory."""
base = self._get_base_path()
for candidate in [base] + list(base.parents):
env_path = candidate / ".env"
if env_path.exists():
return env_path
return None
def _load_yaml(self, path: Path) -> None:
"""Load configuration from YAML file."""
if not HAS_YAML:
logger.warning("PyYAML not installed — skipping config.yaml")
return
try:
with open(path) as f:
data = yaml.safe_load(f) or {} # type: ignore[possibly-undefined]
self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin)
self.api_token = data.get("api_token", self.api_token)
self.host = data.get("host", self.host)
self.port = data.get("port", self.port)
self.log_level = data.get("log_level", self.log_level).upper()
logger.info(f"Loaded config from {path}")
except Exception as e:
logger.error(f"Failed to load config from {path}: {e}")
def _load_env(self, path: Path) -> None:
"""Load configuration from .env file."""
try:
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip("'\"")
match key:
case "DASHBOARD_PIN":
self.dashboard_pin = value
case "API_TOKEN":
self.api_token = value
case "HOST":
self.host = value
case "PORT":
self.port = int(value)
case "LOG_LEVEL":
self.log_level = value.upper()
logger.info(f"Loaded config from {path}")
except Exception as e:
logger.error(f"Failed to load .env from {path}: {e}")
def _load_from_env(self) -> None:
"""Load configuration from environment variables (highest priority)."""
self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin)
self.api_token = os.environ.get("API_TOKEN", self.api_token)
self.host = os.environ.get("HOST", self.host)
try:
self.port = int(os.environ.get("PORT", self.port))
except ValueError:
logger.warning("Invalid PORT environment variable")
self.log_level = os.environ.get("LOG_LEVEL", self.log_level).upper()
def save(self, path: Path | None = None) -> None:
"""Save configuration to YAML file."""
if path is None:
path = self._find_config_file() or Path.cwd() / "config.yaml"
if not HAS_YAML:
logger.error("PyYAML not installed — cannot save config")
return
data = {
"dashboard_pin": self.dashboard_pin,
"api_token": self.api_token,
"host": self.host,
"port": self.port,
"log_level": self.log_level,
}
try:
with open(path, "w") as f:
yaml.dump(data, f, default_flow_style=False) # type: ignore[possibly-undefined]
logger.info(f"Saved config to {path}")
except Exception as e:
logger.error(f"Failed to save config to {path}: {e}")
def to_dict(self) -> dict[str, Any]:
"""Return configuration as a dictionary (for API responses)."""
return {
"dashboard_pin": self.dashboard_pin,
"api_token": self.api_token,
"host": self.host,
"port": self.port,
"log_level": self.log_level,
}
# Global config instance
config = Config()