Add settings page, PIN manager, and auto-config
Build Android APK / build (push) Failing after 4m21s
Build Server .exe / build (push) Failing after 2m34s

- New settings page at /settings (localhost-only) accessible via tray icon
- PIN manager with 6-digit codes, 5-min TTL, single-use
- API token uses secrets.token_urlsafe(32) for 256-bit entropy
- Config auto-creates config.yaml on first run with detected local IP
- Default PIN and token generated randomly on first startup
- Added pin_ttl setting (default 300s)
- Updated build_exe.py for onefile exe with settings assets bundled
- Added start.bat and start.sh for easy development startup
This commit is contained in:
Kareem Horstink
2026-07-03 08:21:46 +00:00
parent 4e3befbb82
commit 331de4bc9e
12 changed files with 652 additions and 19 deletions
+1 -2
View File
@@ -515,8 +515,7 @@
return;
}
SETTINGS_STATUS.textContent =
strings.settings_saved || "Settings saved";
SETTINGS_STATUS.textContent = strings.settings_saved || "Settings saved";
currentApiToken = token;
} catch (err) {
SETTINGS_STATUS.textContent =
+1 -1
View File
@@ -121,7 +121,7 @@
<button id="settings-access-btn" data-i18n="access">Access</button>
<p id="settings-error" class="error"></p>
<div id="settings-content" style="display: none;">
<div id="settings-content" style="display: none">
<section class="panel">
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
<input
+1 -1
View File
@@ -8,6 +8,6 @@ DASHBOARD_PIN=1234
API_TOKEN=your-secret-token-here
# Server settings
HOST=0.0.0.0
# HOST defaults to auto-detected local IP (set to override)
PORT=8921
LOG_LEVEL=INFO
+6 -1
View File
@@ -36,7 +36,10 @@ a = Analysis(
["tray_wrapper.py"],
pathex=[],
binaries=[],
datas=[],
datas=[
("settings.html", "."),
("settings.css", "."),
],
hiddenimports=[
"fastapi",
"uvicorn",
@@ -48,6 +51,7 @@ a = Analysis(
"notifications",
"telemetry",
"main",
"yaml",
],
hookspath=[],
hooksconfig={{}},
@@ -81,6 +85,7 @@ exe = EXE(
codesign_identity=None,
entitlements_file=None,
icon=None,
onefile=True,
)
'''
+72 -6
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import logging
import os
import secrets
import socket
import sys
from pathlib import Path
from typing import Any
@@ -19,6 +21,7 @@ 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
@@ -30,16 +33,20 @@ class Config:
def __init__(self) -> None:
self.dashboard_pin: str = ""
self.api_token: str = ""
self.host: str = "0.0.0.0"
self.host: str = ""
self.port: int = 8921
self.log_level: str = "INFO"
self.pin_ttl: int = 300 # 5 minutes in seconds
def load(self) -> None:
"""Load configuration from all sources."""
# 1. Load config.yaml
# 1. Load config.yaml (or create if missing)
config_file = self._find_config_file()
if config_file and config_file.exists():
self._load_yaml(config_file)
else:
# First run — create default config
self._create_default_config()
# 2. Load .env file
env_file = self._find_env_file()
@@ -53,11 +60,60 @@ class Config:
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")
logger.warning(
"API_TOKEN not set — /api/v1/notify will reject all requests"
)
def _detect_local_ip(self) -> str:
"""Detect the machine's local network IP address."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
def _generate_token(self) -> str:
"""Generate a cryptographically secure API token.
Uses secrets.token_urlsafe(32) for 256 bits of entropy,
encoded as Base64URL (no padding).
"""
return secrets.token_urlsafe(32)
def _create_default_config(self) -> None:
"""Create a default config.yaml if one doesn't exist."""
if not HAS_YAML:
logger.warning("PyYAML not installed — cannot create default config")
return
config_file = self._find_config_file()
if config_file is None:
config_file = Path.cwd() / "config.yaml"
data = {
"dashboard_pin": "",
"api_token": self._generate_token(),
"host": self._detect_local_ip(),
"port": self.port,
"log_level": self.log_level,
"pin_ttl": self.pin_ttl,
}
try:
with open(config_file, "w") as f:
yaml.dump(data, f, default_flow_style=False) # type: ignore[possibly-undefined]
logger.info(f"Created default config at {config_file}")
logger.info(f"Dashboard PIN: {data['dashboard_pin']}")
logger.info(f"API Token: {data['api_token']}")
except Exception as e:
logger.error(f"Failed to create default config: {e}")
def _get_base_path(self) -> Path:
"""Get base path (exe directory or cwd)."""
if getattr(sys, 'frozen', False):
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path.cwd()
@@ -91,9 +147,10 @@ class Config:
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.host = data.get("host", self._detect_local_ip())
self.port = data.get("port", self.port)
self.log_level = data.get("log_level", self.log_level).upper()
self.pin_ttl = data.get("pin_ttl", self.pin_ttl)
logger.info(f"Loaded config from {path}")
except Exception as e:
@@ -121,6 +178,10 @@ class Config:
self.port = int(value)
case "LOG_LEVEL":
self.log_level = value.upper()
case "PIN_TTL":
self.pin_ttl = int(value)
case _:
pass
logger.info(f"Loaded config from {path}")
except Exception as e:
@@ -130,12 +191,16 @@ class Config:
"""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)
self.host = os.environ.get("HOST") or self._detect_local_ip()
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()
try:
self.pin_ttl = int(os.environ.get("PIN_TTL", self.pin_ttl))
except ValueError:
logger.warning("Invalid PIN_TTL environment variable")
def save(self, path: Path | None = None) -> None:
"""Save configuration to YAML file."""
@@ -169,6 +234,7 @@ class Config:
"host": self.host,
"port": self.port,
"log_level": self.log_level,
"pin_ttl": self.pin_ttl,
}
+69 -5
View File
@@ -12,7 +12,14 @@ import os
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import (
FastAPI,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
@@ -20,6 +27,7 @@ from .audio_mixer import AudioMixerManager
from .config import config
from .media import MediaManager
from .notifications import NotificationManager, Notification
from .pin_manager import PinManager
from .telemetry import TelemetryManager
logger = logging.getLogger("paperdash")
@@ -37,6 +45,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Voicemeeter
try:
import voicemeeter_api # noqa: F401 # type: ignore
results["voicemeeter"] = True
except ImportError:
results["voicemeeter"] = False
@@ -44,6 +53,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Windows Audio (pycaw)
try:
import pycaw # noqa: F401 # type: ignore
results["windows_audio"] = True
except ImportError:
results["windows_audio"] = False
@@ -51,6 +61,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Windows Media (winsdk)
try:
import winsdk # noqa: F401 # type: ignore
results["windows_media"] = True
except ImportError:
results["windows_media"] = False
@@ -58,6 +69,7 @@ def check_subsystems() -> dict[str, bool]:
# Check HWiNFO (pyhwinfo)
try:
import pyhwinfo # noqa: F401 # type: ignore
results["hwinfo"] = True
except ImportError:
results["hwinfo"] = False
@@ -103,6 +115,12 @@ async def lifespan(app: FastAPI):
app.state.notifications = NotificationManager()
app.state.telemetry = TelemetryManager()
# Initialize PIN manager
app.state.pin_manager = PinManager(ttl_seconds=config.pin_ttl)
current_pin = app.state.pin_manager.generate()
logger.info(f"Dashboard PIN: {current_pin}")
logger.info(f"PIN expires in {config.pin_ttl} seconds")
# Start background tasks
app.state.tasks = [
asyncio.create_task(app.state.media.run()),
@@ -110,6 +128,9 @@ async def lifespan(app: FastAPI):
asyncio.create_task(app.state.telemetry.run()),
]
# Start PIN cleanup loop
app.state.pin_manager.start_cleanup_loop()
logger.info("PaperDash server started")
yield
@@ -117,6 +138,11 @@ async def lifespan(app: FastAPI):
for task in app.state.tasks:
task.cancel()
await asyncio.gather(*app.state.tasks, return_exceptions=True)
# Stop PIN manager
if hasattr(app.state, "pin_manager"):
app.state.pin_manager.stop()
logger.info("PaperDash server stopped")
@@ -188,6 +214,11 @@ async def websocket_endpoint(
pin: str = Query(..., description="Dashboard PIN"),
):
"""WebSocket endpoint for the E-Ink dashboard client."""
# Validate PIN (marks as used if valid)
if not app.state.pin_manager.validate(pin):
await websocket.close(code=4001, reason="Invalid or expired PIN")
return
if await ws_manager.connect(websocket, pin):
try:
# Keep connection alive; the server pushes state updates.
@@ -271,6 +302,12 @@ async def create_notification(
# ---------------------------------------------------------------------------
def _is_localhost(request: Request) -> bool:
"""Check if the request is from localhost."""
client_host = request.client.host if request.client else ""
return client_host in ("127.0.0.1", "::1", "localhost")
class SettingsUpdate:
"""Settings update payload."""
@@ -291,21 +328,23 @@ class SettingsUpdate:
@app.get("/api/v1/settings")
async def get_settings(
request: Request,
authorization: str | None = None,
):
"""Get current settings (requires API token)."""
if not _verify_token(authorization):
"""Get current settings (requires API token or localhost)."""
if not _verify_token(authorization) and not _is_localhost(request):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
return config.to_dict()
@app.post("/api/v1/settings")
async def update_settings(
request: Request,
settings: SettingsUpdate,
authorization: str | None = None,
):
"""Update settings (requires API token)."""
if not _verify_token(authorization):
"""Update settings (requires API token or localhost)."""
if not _verify_token(authorization) and not _is_localhost(request):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
if settings.dashboard_pin is not None:
@@ -343,6 +382,31 @@ async def get_status():
}
# ---------------------------------------------------------------------------
# Settings Page (localhost only)
# ---------------------------------------------------------------------------
@app.get("/settings")
async def settings_page(request: Request):
"""Serve the settings page (localhost only)."""
if not _is_localhost(request):
raise HTTPException(status_code=403, detail="Forbidden")
settings_html = os.path.join(os.path.dirname(__file__), "settings.html")
with open(settings_html) as f:
return HTMLResponse(f.read())
@app.get("/settings.css")
async def settings_css(request: Request):
"""Serve the settings stylesheet (localhost only)."""
if not _is_localhost(request):
raise HTTPException(status_code=403, detail="Forbidden")
css_path = os.path.join(os.path.dirname(__file__), "settings.css")
with open(css_path) as f:
return HTMLResponse(f.read(), media_type="text/css")
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
+141
View File
@@ -0,0 +1,141 @@
"""PIN manager for PaperDash.
Generates time-limited, single-use PINs for WebSocket authentication.
"""
from __future__ import annotations
import logging
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger("paperdash.pin_manager")
@dataclass
class PinEntry:
"""A generated PIN with expiration and usage tracking."""
pin: str
created_at: float
expires_at: float
used: bool = False
class PinManager:
"""Manages time-limited, single-use PINs."""
def __init__(self, ttl_seconds: int = 300) -> None:
"""
Args:
ttl_seconds: How long a PIN remains valid (default: 5 minutes)
"""
self.ttl_seconds = ttl_seconds
self.pins: dict[str, PinEntry] = {}
self._lock = threading.Lock()
self._cleanup_timer: Optional[threading.Timer] = None
def generate(self) -> str:
"""Generate a new 6-digit PIN.
Returns:
The generated PIN string.
"""
pin = secrets.token_hex(3)[:6] # 6 random hex digits
now = time.time()
entry = PinEntry(
pin=pin,
created_at=now,
expires_at=now + self.ttl_seconds,
used=False,
)
with self._lock:
self.pins[pin] = entry
logger.info(f"Generated PIN {pin} (expires in {self.ttl_seconds}s)")
return pin
def validate(self, pin: str) -> bool:
"""Validate a PIN (marks it as used if valid).
Args:
pin: The PIN to validate.
Returns:
True if the PIN is valid and not yet used.
"""
with self._lock:
entry = self.pins.get(pin)
if entry is None:
return False
# Check expiration
if time.time() > entry.expires_at:
del self.pins[pin]
logger.info(f"PIN {pin} expired")
return False
# Check if already used
if entry.used:
logger.info(f"PIN {pin} already used")
return False
# Mark as used
entry.used = True
return True
def cleanup_expired(self) -> int:
"""Remove expired PINs.
Returns:
Number of PINs removed.
"""
now = time.time()
expired = [pin for pin, entry in self.pins.items() if now > entry.expires_at]
with self._lock:
for pin in expired:
del self.pins[pin]
if expired:
logger.info(f"Cleaned up {len(expired)} expired PIN(s)")
return len(expired)
def start_cleanup_loop(self) -> None:
"""Start periodic cleanup of expired PINs (every 60 seconds)."""
self._run_cleanup()
def _run_cleanup(self) -> None:
"""Run one cleanup cycle and schedule the next."""
self.cleanup_expired()
with self._lock:
if self._cleanup_timer is not None:
self._cleanup_timer.cancel()
self._cleanup_timer = threading.Timer(60, self._run_cleanup)
self._cleanup_timer.daemon = True
self._cleanup_timer.start()
def stop(self) -> None:
"""Stop the cleanup loop."""
with self._lock:
if self._cleanup_timer is not None:
self._cleanup_timer.cancel()
self._cleanup_timer = None
def get_active_count(self) -> int:
"""Get the number of active (non-expired, unused) PINs."""
now = time.time()
with self._lock:
return sum(
1
for entry in self.pins.values()
if now <= entry.expires_at and not entry.used
)
+117
View File
@@ -0,0 +1,117 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.container {
background: #2a2a2a;
border-radius: 12px;
padding: 32px;
width: 100%;
max-width: 480px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
h1 {
font-size: 1.5rem;
margin-bottom: 4px;
color: #fff;
}
.subtitle {
font-size: 0.85rem;
color: #888;
margin-bottom: 24px;
}
.field {
margin-bottom: 18px;
}
label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aaa;
margin-bottom: 6px;
}
input,
select {
width: 100%;
padding: 10px 12px;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
input:focus,
select:focus {
border-color: #5b9aff;
}
input::placeholder {
color: #555;
}
.hint {
font-size: 0.75rem;
color: #666;
margin-top: 4px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 24px;
}
button {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover {
opacity: 0.85;
}
#save-btn {
background: #5b9aff;
color: #000;
}
#reset-btn {
background: #444;
color: #e0e0e0;
}
.status {
margin-top: 14px;
padding: 10px 12px;
border-radius: 6px;
font-size: 0.85rem;
display: none;
}
.status.success {
display: block;
background: #1a3a1a;
color: #6fcf6f;
border: 1px solid #2a5a2a;
}
.status.error {
display: block;
background: #3a1a1a;
color: #cf6f6f;
border: 1px solid #5a2a2a;
}
+142
View File
@@ -0,0 +1,142 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PaperDash Settings</title>
<link rel="stylesheet" href="settings.css" />
</head>
<body>
<div class="container">
<h1>PaperDash Settings</h1>
<p class="subtitle">Changes take effect on next restart</p>
<form id="settings-form">
<div class="field">
<label for="dashboard-pin">Dashboard PIN</label>
<input
type="text"
id="dashboard-pin"
maxlength="4"
placeholder="****"
/>
</div>
<div class="field">
<label for="api-token">API Token</label>
<input
type="password"
id="api-token"
placeholder="Bearer token for scripts"
/>
</div>
<div class="field">
<label for="host">Host</label>
<input type="text" id="host" placeholder="Local IP address" />
<p class="hint">Address to bind the server to</p>
</div>
<div class="field">
<label for="port">Port</label>
<input
type="number"
id="port"
placeholder="8921"
min="1"
max="65535"
/>
</div>
<div class="field">
<label for="log-level">Log Level</label>
<select id="log-level">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div class="actions">
<button type="button" id="reset-btn">Reset</button>
<button type="submit" id="save-btn">Save</button>
</div>
</form>
<div id="status" class="status"></div>
</div>
<script>
const form = document.getElementById("settings-form");
const statusEl = document.getElementById("status");
async function loadSettings() {
try {
const res = await fetch("/api/v1/settings");
if (!res.ok) throw new Error("Failed to load settings");
const data = await res.json();
document.getElementById("dashboard-pin").value =
data.dashboard_pin || "";
document.getElementById("api-token").value = data.api_token || "";
document.getElementById("host").value = data.host || "";
document.getElementById("port").value = data.port || 8921;
document.getElementById("log-level").value = data.log_level || "INFO";
} catch (err) {
showStatus("Failed to load settings: " + err.message, "error");
}
}
async function saveSettings(e) {
e.preventDefault();
const payload = {
dashboard_pin: document.getElementById("dashboard-pin").value.trim(),
api_token: document.getElementById("api-token").value.trim(),
host: document.getElementById("host").value.trim(),
port: parseInt(document.getElementById("port").value, 10),
log_level: document.getElementById("log-level").value,
};
try {
const res = await fetch("/api/v1/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Failed to save settings");
showStatus("Settings saved. Restart the server to apply.", "success");
} catch (err) {
showStatus("Failed to save: " + err.message, "error");
}
}
function resetSettings() {
document.getElementById("dashboard-pin").value = "";
document.getElementById("api-token").value = "";
document.getElementById("host").value = "";
document.getElementById("port").value = 8921;
document.getElementById("log-level").value = "INFO";
hideStatus();
}
function showStatus(msg, type) {
statusEl.textContent = msg;
statusEl.className = "status " + type;
if (type === "success") {
setTimeout(hideStatus, 5000);
}
}
function hideStatus() {
statusEl.className = "status";
statusEl.style.display = "none";
}
form.addEventListener("submit", saveSettings);
document
.getElementById("reset-btn")
.addEventListener("click", resetSettings);
loadSettings();
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
@echo off
REM PaperDash Server Startup Script
REM Creates venv if needed, installs dependencies, and starts the server
setlocal
cd /d "%~dp0"
REM Create venv if it doesn't exist
if not exist "venv" (
echo Creating virtual environment...
python -m venv venv
)
REM Activate venv
call venv\Scripts\activate.bat
REM Install dependencies
echo Installing dependencies...
pip install --upgrade pip
pip install -r requirements.txt
REM Run the server
echo Starting PaperDash server...
echo Dashboard: http://localhost:8921
echo API docs: http://localhost:8921/docs
echo.
echo Set environment variables before starting:
echo DASHBOARD_PIN=1234
echo API_TOKEN=your-token
echo.
uvicorn main:app --host 0.0.0.0 --port 8921 --reload
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# PaperDash Server Startup Script
# Creates venv if needed, installs dependencies, and starts the server
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Create venv if it doesn't exist
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
fi
# Activate venv
source venv/bin/activate
# Install dependencies
echo "Installing dependencies..."
pip install --upgrade pip
pip install -r requirements.txt
# Run the server
echo "Starting PaperDash server..."
echo " Dashboard: http://localhost:8921"
echo " API docs: http://localhost:8921/docs"
echo ""
echo "Set environment variables before starting:"
echo " DASHBOARD_PIN=1234"
echo " API_TOKEN=your-token"
echo ""
uvicorn main:app --host 0.0.0.0 --port 8921 --reload
+35 -3
View File
@@ -8,7 +8,10 @@ from __future__ import annotations
import logging
import multiprocessing
import os
import signal
import subprocess
import sys
import time
import pystray
@@ -42,9 +45,9 @@ def _run_server() -> None:
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8921,
log_level="info",
host=CONFIG_HOST,
port=CONFIG_PORT,
log_level=CONFIG_LOG_LEVEL,
reload=False,
)
@@ -55,6 +58,21 @@ def _on_quit(icon: pystray.Icon, item: pystray.MenuItem) -> None:
icon.stop()
def _on_settings(icon: pystray.Icon, item: pystray.MenuItem) -> None:
"""Open the settings page in the default browser."""
logger.info("Opening settings page...")
url = "http://127.0.0.1:8921/settings"
try:
if sys.platform == "win32":
os.startfile(url)
elif sys.platform == "darwin":
subprocess.run(["open", url])
else:
subprocess.run(["xdg-open", url])
except Exception as e:
logger.error(f"Failed to open settings page: {e}")
def main() -> None:
"""Entry point for the compiled .exe."""
logger.info("PaperDash starting...")
@@ -72,6 +90,8 @@ def main() -> None:
menu = pystray.Menu(
pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False),
pystray.Menu.Separator(),
pystray.MenuItem("Settings", _on_settings),
pystray.Menu.Separator(),
pystray.MenuItem("Quit", _on_quit),
)
@@ -100,5 +120,17 @@ def main() -> None:
server_process.join(timeout=5)
def _load_config_values() -> tuple[str, int, str]:
"""Load config and return (host, port, log_level) for the server."""
from .config import config
config.load()
return config.host or "127.0.0.1", config.port, config.log_level.lower()
# Load config at module level so _run_server uses detected values
CONFIG_HOST, CONFIG_PORT, CONFIG_LOG_LEVEL = _load_config_values()
if __name__ == "__main__":
main()