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
+166 -10
View File
@@ -17,6 +17,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from .audio_mixer import AudioMixerManager
from .config import config
from .media import MediaManager
from .notifications import NotificationManager, Notification
from .telemetry import TelemetryManager
@@ -24,9 +25,78 @@ from .telemetry import TelemetryManager
logger = logging.getLogger("paperdash")
# ---------------------------------------------------------------------------
# Hardware Subsystem Check
# ---------------------------------------------------------------------------
def check_subsystems() -> dict[str, bool]:
"""Check which hardware subsystems are available."""
results: dict[str, bool] = {}
# Check Voicemeeter
try:
import voicemeeter_api # noqa: F401 # type: ignore
results["voicemeeter"] = True
except ImportError:
results["voicemeeter"] = False
# Check Windows Audio (pycaw)
try:
import pycaw # noqa: F401 # type: ignore
results["windows_audio"] = True
except ImportError:
results["windows_audio"] = False
# Check Windows Media (winsdk)
try:
import winsdk # noqa: F401 # type: ignore
results["windows_media"] = True
except ImportError:
results["windows_media"] = False
# Check HWiNFO (pyhwinfo)
try:
import pyhwinfo # noqa: F401 # type: ignore
results["hwinfo"] = True
except ImportError:
results["hwinfo"] = False
return results
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Start and stop background managers."""
# Load configuration
config.load()
# Configure logging
logging.basicConfig(
level=getattr(logging, config.log_level, logging.INFO),
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
# Check hardware subsystems
subsystems = check_subsystems()
app.state.subsystems = subsystems
logger.info(f"Hardware subsystems: {subsystems}")
# Log configuration status
if config.dashboard_pin:
logger.info("Dashboard PIN is configured")
else:
logger.warning("Dashboard PIN is NOT configured — WebSocket auth disabled")
if config.api_token:
logger.info("API token is configured")
else:
logger.warning("API token is NOT configured — /api/v1/notify disabled")
# Start managers
app.state.media = MediaManager()
app.state.audio = AudioMixerManager()
@@ -59,7 +129,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Local network only — fine for this use case
allow_origins=["*"], # Local network only — fine for this use case # noqa: B008
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -78,7 +148,7 @@ class WebSocketManager:
self.connections: list[WebSocket] = []
async def connect(self, ws: WebSocket, pin: str) -> bool:
expected_pin = os.environ.get("DASHBOARD_PIN", "")
expected_pin = config.dashboard_pin
if not expected_pin or pin != expected_pin:
await ws.close(code=4001, reason="Invalid PIN")
return False
@@ -162,7 +232,7 @@ async def handle_client_command(data: dict[str, Any]) -> None:
def _verify_token(authorization: str | None) -> bool:
"""Verify Bearer token from Authorization header."""
expected = os.environ.get("API_TOKEN", "")
expected = config.api_token
if not expected:
return False
if not authorization or not authorization.startswith("Bearer "):
@@ -178,6 +248,7 @@ async def get_state():
"audio": app.state.audio.get_state(),
"telemetry": app.state.telemetry.get_state(),
"notifications": app.state.notifications.get_active(),
"subsystems": app.state.subsystems,
}
@@ -195,6 +266,83 @@ async def create_notification(
return {"id": created.id, "status": "created"}
# ---------------------------------------------------------------------------
# Settings (Protected by API Token)
# ---------------------------------------------------------------------------
class SettingsUpdate:
"""Settings update payload."""
def __init__(
self,
dashboard_pin: str | None = None,
api_token: str | None = None,
host: str | None = None,
port: int | None = None,
log_level: str | None = None,
):
self.dashboard_pin = dashboard_pin
self.api_token = api_token
self.host = host
self.port = port
self.log_level = log_level
@app.get("/api/v1/settings")
async def get_settings(
authorization: str | None = None,
):
"""Get current settings (requires API token)."""
if not _verify_token(authorization):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
return config.to_dict()
@app.post("/api/v1/settings")
async def update_settings(
settings: SettingsUpdate,
authorization: str | None = None,
):
"""Update settings (requires API token)."""
if not _verify_token(authorization):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
if settings.dashboard_pin is not None:
config.dashboard_pin = settings.dashboard_pin
if settings.api_token is not None:
config.api_token = settings.api_token
if settings.host is not None:
config.host = settings.host
if settings.port is not None:
config.port = settings.port
if settings.log_level is not None:
config.log_level = settings.log_level.upper()
# Save to config file
config.save()
logger.info("Settings updated")
return config.to_dict()
# ---------------------------------------------------------------------------
# Hardware Status
# ---------------------------------------------------------------------------
@app.get("/api/v1/status")
async def get_status():
"""Get server status including hardware subsystem availability."""
return {
"subsystems": app.state.subsystems,
"config": {
"pin_configured": bool(config.dashboard_pin),
"token_configured": bool(config.api_token),
},
}
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@@ -206,16 +354,24 @@ async def health():
# ---------------------------------------------------------------------------
# Client HTML (served directly for Hermit WebView)
# Client HTML (served directly for WebView)
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def serve_client():
"""Serve the E-Ink dashboard client."""
client_dir = os.path.join(os.path.dirname(__file__), "..", "client")
index_path = os.path.join(client_dir, "index.html")
if os.path.exists(index_path):
with open(index_path, "r") as f:
return f.read()
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found in client/</p>")
# Try multiple locations for the client files
possible_paths = [
os.path.join(os.path.dirname(__file__), "..", "client"),
os.path.join(os.path.dirname(__file__), "..", "..", "client"),
os.path.join(os.path.dirname(__file__), "client"),
]
for client_dir in possible_paths:
index_path = os.path.join(client_dir, "index.html")
if os.path.exists(index_path):
with open(index_path) as f:
return f.read()
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found.</p>")