Settings tab

Everything that was previously a curl command now has a surface: surroundings,
site geometry, the matrix, the psychrometric flag, and the maintenance actions.

Changes persist to data/state/settings.json, not config.yaml. That file is
hand-annotated and hand-edited per station, and rewriting it from an API would
destroy the comments and risk clobbering something the owner set. The overlay is
applied last in load_config, so a dashboard change beats both the file and the
environment, and deleting the overlay reverts everything. Written atomically via
a temp file so a crash cannot truncate it.

Every field applies live. A settings page that needs a restart is one people
stop trusting, so site geometry is re-read per sample, the compensator flag is
set on the live object, and the display picks up its rate the next frame.

Two deliberate frictions. Selecting a surroundings pill only stages it: nothing
is recorded until you press the button, because that writes a discontinuity
marker and queues a retrain. And changing altitude or the psychrometric flag
says outright that the stored history is now inconsistent and offers the
re-derive, rather than leaving a silent mismatch.

Verified in a browser: pills stage and apply, the toggle round-trips, re-derive
ran 6201 rows in 0.29 s from the button, all six tabs report zero scrollbars and
zero clipping, zero console errors.
This commit is contained in:
2026-08-15 23:46:57 +01:00
parent 50b29f8077
commit 498b3f6e38
4 changed files with 332 additions and 4 deletions
+102 -1
View File
@@ -34,7 +34,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .config import CONFIG
from .config import CONFIG, load_overrides, save_overrides
from .dashboard import DASHBOARD_HTML
from .features import FEATURE_NAMES
from .led import LedDisplay
@@ -128,6 +128,19 @@ class CalibrationIn(BaseModel):
"covariance, returning to the configured prior")
class SettingsIn(BaseModel):
"""Every field optional: the UI sends only what changed."""
environment: Optional[str] = None
enclosure: Optional[str] = None
note: str = ""
altitude_m: Optional[float] = Field(None, ge=-430, le=9000)
latitude: Optional[float] = Field(None, ge=-90, le=90)
longitude: Optional[float] = Field(None, ge=-180, le=180)
hum_psychrometric: Optional[bool] = None
led_enabled: Optional[bool] = None
led_fps: Optional[float] = Field(None, ge=4, le=30)
class EnvironmentIn(BaseModel):
environment: Optional[str] = Field(None, description="indoor | sheltered | outdoor")
enclosure: Optional[str] = Field(None, description="closed | ventilated | open")
@@ -624,6 +637,94 @@ def environment(body: EnvironmentIn) -> Dict:
return _clean(_st().set_environment(body.environment, body.enclosure, body.note))
@app.get("/api/settings")
def get_settings() -> Dict:
st = _st()
return _clean({
"site": {"environment": CONFIG.site.environment,
"enclosure": CONFIG.site.enclosure,
"altitude_m": CONFIG.site.altitude_m,
"latitude": CONFIG.site.latitude,
"longitude": CONFIG.site.longitude,
"timezone": CONFIG.site.timezone,
"name": CONFIG.site.name},
"sensor": {"hum_psychrometric": CONFIG.sensor.hum_psychrometric,
"cpu_heat_k": round(st.tracker.compensator.k, 4),
"hum_offset": round(st.tracker.hum_compensator.offset, 3)},
"server": {"led_enabled": CONFIG.server.led_enabled,
"led_fps": CONFIG.server.led_fps},
"options": {
"environment": ["indoor", "sheltered", "outdoor"],
"enclosure": ["closed", "ventilated", "open"],
},
"overrides": load_overrides(CONFIG),
})
@app.post("/api/settings")
def post_settings(body: SettingsIn) -> Dict:
"""Apply settings live and persist them to the overlay.
Everything here takes effect without a restart, because a settings page that
needs one is a settings page people stop trusting. Site geometry is read per
sample, the compensator flag is a field on a live object, and the display
reads its own rate each frame.
"""
st = _st()
patch: Dict[str, Dict] = {}
applied, needs_recompute = [], False
if body.environment or body.enclosure:
r = st.set_environment(body.environment, body.enclosure, body.note)
if r.get("changed"):
applied.append(r["detail"])
patch.setdefault("site", {}).update(
{"environment": CONFIG.site.environment,
"enclosure": CONFIG.site.enclosure})
for name, value in (("altitude_m", body.altitude_m),
("latitude", body.latitude),
("longitude", body.longitude)):
if value is not None and value != getattr(CONFIG.site, name):
applied.append(f"{name} {getattr(CONFIG.site, name)} -> {value}")
setattr(CONFIG.site, name, float(value))
patch.setdefault("site", {})[name] = float(value)
# Altitude feeds the sea-level reduction on every stored row, so the
# history is now inconsistent with the new value until re-derived.
needs_recompute = needs_recompute or name == "altitude_m"
if body.hum_psychrometric is not None and \
body.hum_psychrometric != CONFIG.sensor.hum_psychrometric:
CONFIG.sensor.hum_psychrometric = bool(body.hum_psychrometric)
st.tracker.hum_compensator.psychrometric = bool(body.hum_psychrometric)
patch.setdefault("sensor", {})["hum_psychrometric"] = bool(body.hum_psychrometric)
applied.append(f"psychrometric correction {'on' if body.hum_psychrometric else 'off'}")
needs_recompute = True
if body.led_enabled is not None and body.led_enabled != CONFIG.server.led_enabled:
CONFIG.server.led_enabled = bool(body.led_enabled)
patch.setdefault("server", {})["led_enabled"] = bool(body.led_enabled)
if display is not None:
display.enabled = bool(body.led_enabled)
if not body.led_enabled:
st.board.clear()
applied.append(f"matrix {'on' if body.led_enabled else 'off'}")
if body.led_fps is not None and body.led_fps != CONFIG.server.led_fps:
CONFIG.server.led_fps = float(body.led_fps)
patch.setdefault("server", {})["led_fps"] = float(body.led_fps)
if display is not None:
display.fps = float(body.led_fps)
applied.append(f"matrix {body.led_fps:g} fps")
if patch:
save_overrides(CONFIG, patch)
st.store.log_event("settings", "info", "; ".join(applied))
return _clean({"applied": applied, "changed": bool(applied),
"needs_recompute": needs_recompute})
@app.get("/api/status")
def status() -> Dict:
st = _st()