Add read-only household dashboard with themes and container validation
Validate Dashboard / validate (push) Successful in 27s
Validate Dashboard / validate (push) Successful in 27s
This commit is contained in:
commit
3e5ca50b93
18 files changed
+1085
No files matched your search
@@ -0,0 +1,11 @@
|
||||
__pycache__
|
||||
tests
|
||||
*.pyc
|
||||
.env
|
||||
*.env
|
||||
|
||||
.git
|
||||
.gitea
|
||||
.venv
|
||||
node_modules
|
||||
config.example.json
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Validate Dashboard
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: shared-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -r requirements.lock
|
||||
- name: Test backend and read-only routes
|
||||
run: .venv/bin/python -m unittest discover -s tests
|
||||
- name: Test calendar layout and themes
|
||||
run: npm test
|
||||
- name: Validate container configuration
|
||||
run: docker compose config --quiet
|
||||
- name: Build container
|
||||
run: docker build -t household-dashboard:ci .
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.env
|
||||
*.env
|
||||
node_modules/
|
||||
coverage/
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
FROM python:3.13-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
COPY requirements.txt requirements.lock .
|
||||
RUN pip install --no-cache-dir -r requirements.lock
|
||||
COPY app.py .
|
||||
COPY static ./static
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "--timeout", "60", "--graceful-timeout", "20", "--worker-tmp-dir", "/tmp", "app:create_app()"]
|
||||
@@ -0,0 +1,104 @@
|
||||
# Household dashboard
|
||||
|
||||
Read-only household display at `https://dashboard.uuard.com`, available on the
|
||||
LAN/VPN through Traefik's `allow-local` middleware. Home Assistant and its
|
||||
household dashboard remain available at their original URL. The wall display now
|
||||
opens this dashboard on startup.
|
||||
|
||||
The plain HTML/JavaScript page shows the same seven-day rolling schedule (today plus six days), enlarged
|
||||
12-hour clock, 12 hourly weather slots, and the Next task list.
|
||||
It has no buttons, links, forms, checkboxes, or editing actions. Calendar events
|
||||
include recurrence expansion, all-day and multi-day events, locations, overlapping
|
||||
events, past-event dimming, and a current-time line in America/Chicago.
|
||||
|
||||
A small Python server reads the five calendars and four task lists directly from
|
||||
Stalwart using the existing read-only `dashboard@uuard.com` account. Weather uses
|
||||
Home Assistant's existing `weather.forecast_home` entity via a read-only forecast
|
||||
subscription. Each calendar, task list, and weather feed refreshes independently every five
|
||||
minutes, with bounded requests and backoff after errors. The display polls once a
|
||||
minute, retains the last successful data, and shows which sources failed or became
|
||||
stale. Crowded lists and calendar details rotate automatically every 20 seconds. Credentials never
|
||||
reach the browser. Only fixed static files and GET snapshot/health routes are served.
|
||||
|
||||
## Themes
|
||||
|
||||
Skylight is the default theme, based on the official Skylight Home/Schedule
|
||||
references: light panels, pale blue rail, serif date/time headings, pastel events,
|
||||
and orange current-day/time markers. Read-only calendar, weather and Next tasks
|
||||
remain visible. Its styling lives in `static/skylight.css`; the original Home
|
||||
Assistant styling remains in `static/style.css`.
|
||||
|
||||
Use `https://dashboard.uuard.com/?theme=homeassistant` for the original dark theme,
|
||||
or `?theme=skylight` for the default. Unknown themes fall back to Skylight. Theme
|
||||
selection uses the URL so the wall display remains free of interactive controls.
|
||||
Both themes show only Next; the backend retains the existing source integration.
|
||||
|
||||
Reference: https://skylight.zendesk.com/hc/en-us/articles/49738702477723-The-Home-Screen
|
||||
|
||||
## Run on ward-vm-apps-02
|
||||
|
||||
The existing Ansible SOPS secret-rendering flow provisions
|
||||
`/etc/home-ops/household-dashboard.json` before Compose starts. Its encrypted
|
||||
source is the matching `secret_files` entry in `secrets/sops/production.sops.yml`.
|
||||
The file is owned by root, readable by the container's group, and mounted read-only.
|
||||
|
||||
Once the secret has been rendered:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The file contains the existing read-only Stalwart dashboard account's credentials
|
||||
and a dedicated Home Assistant long-lived access token. The token belongs to
|
||||
`Household dashboard service`, a local-only account in `system-read-only`, with
|
||||
no interactive login credentials. It has read permission and no entity control
|
||||
permission; it is independent of the original dashboard user's browser sessions.
|
||||
The frontend receives household display data only, never passwords or tokens.
|
||||
No HA configuration changes or restart are required. The app runs as UID 65534 and
|
||||
exposes port 8124. Traefik applies TLS and local-network restrictions and injects
|
||||
a shared proxy credential; direct requests for household data are rejected. The
|
||||
proxy credential is also stored in SOPS and rendered into the edge stack environment.
|
||||
Credentials must be rendered on both hosts before recreating their services.
|
||||
|
||||
## Deployment ownership
|
||||
|
||||
The dashboard app, apps-02 Compose inventory entry, edge DNS record, Traefik route,
|
||||
proxy environment variable, encrypted credentials, and wall-display startup URL
|
||||
must all be included in the deployed revision. Ansible intentionally stashes host
|
||||
checkout changes, including untracked files, before checking out that revision;
|
||||
manual host edits are temporary and will disappear on the next deployment.
|
||||
|
||||
This repository owns the standalone application. Production infrastructure and
|
||||
SOPS credentials live in `nicholas/home-ops`; its current deployment builds the
|
||||
app copy at `stacks/apps/automation/household-dashboard`. Creating this repository
|
||||
does not yet change that deployment to consume this repository. Keep that app
|
||||
copy synchronized until deployment ownership is migrated.
|
||||
|
||||
The validation Action runs tests, validates Compose, and builds the image. It has
|
||||
no production credentials and does not automatically deploy. Infrastructure
|
||||
changes require deploying both apps-02 and edge-01 through home-ops.
|
||||
|
||||
The host inventory includes this Compose project. Routine source updates require
|
||||
`docker compose up -d --build` to rebuild the application image.
|
||||
|
||||
Gunicorn serves the app with one worker and four request threads. The container
|
||||
has a read-only filesystem and resource limits; runtime dependencies are pinned
|
||||
in `requirements.lock`. `/health/live` checks the process only, while authenticated
|
||||
`/health` reports readiness based on required source freshness. Upstream failures
|
||||
do not make process liveness fail. The cache is in memory.
|
||||
|
||||
The browser bounds fetch time, backs off on failure, refreshes at midnight, and
|
||||
reloads automatically when the application version changes.
|
||||
|
||||
## Local development and checks
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.lock
|
||||
.venv/bin/python -m unittest discover -s tests
|
||||
npm test
|
||||
```
|
||||
|
||||
Use the example in `config.example.json` to understand the server configuration.
|
||||
Supply actual credentials through the existing SOPS-managed file; never commit
|
||||
credential files or use frontend environment variables for secrets.
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Read-only household dashboard. Credentials stay behind the trusted edge proxy."""
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import caldav
|
||||
import requests
|
||||
import websocket
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
ZONE = ZoneInfo("America/Chicago")
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
REFRESH_SECONDS = 300
|
||||
STALE_SECONDS = 900
|
||||
CALENDARS = [("Personal Commitments", "#FF6B6B"), ("Work", "#4ECDC4"),
|
||||
("Focus", "#45B7D1"), ("Meal Plan", "#FFA07A"), ("Exercise", "#98D8C8")]
|
||||
TASK_LISTS = ["Inbox", "Next", "Waiting", "Someday"]
|
||||
SOURCES = {**{f"calendar:{n}": n for n, _ in CALENDARS},
|
||||
**{f"tasks:{n}": n for n in TASK_LISTS},
|
||||
"weather:hourly": "Hourly weather", "weather:daily": "Daily weather"}
|
||||
REQUIRED = set(SOURCES) - {"weather:daily"}
|
||||
FILES = {"/": ("index.html", "text/html"), "/app.js": ("app.js", "text/javascript"),
|
||||
"/style.css": ("style.css", "text/css"), "/skylight.css": ("skylight.css", "text/css"), "/favicon.svg": ("favicon.svg", "image/svg+xml")}
|
||||
VERSION = hashlib.sha256(Path(__file__).read_bytes() + b"".join((STATIC / f).read_bytes() for f, _ in FILES.values())).hexdigest()[:12]
|
||||
|
||||
|
||||
def local_iso(value):
|
||||
if isinstance(value, datetime):
|
||||
return (value.replace(tzinfo=ZONE) if value.tzinfo is None else value.astimezone(ZONE)).isoformat()
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def serialize_event(component, name, color):
|
||||
if str(component.get("STATUS", "")).upper() == "CANCELLED":
|
||||
return None
|
||||
start = component.decoded("DTSTART")
|
||||
if not isinstance(start, date):
|
||||
raise ValueError("Invalid event date")
|
||||
all_day = not isinstance(start, datetime)
|
||||
duration = timedelta(days=1) if all_day else timedelta(0)
|
||||
end = component.decoded("DTEND") if "DTEND" in component else start + component.decoded("DURATION", duration)
|
||||
if isinstance(end, datetime) != isinstance(start, datetime):
|
||||
raise ValueError("Invalid event interval")
|
||||
if all_day:
|
||||
before, equal = end < start, end == start
|
||||
else:
|
||||
start_epoch = (start.replace(tzinfo=ZONE) if start.tzinfo is None else start).timestamp()
|
||||
end_epoch = (end.replace(tzinfo=ZONE) if end.tzinfo is None else end).timestamp()
|
||||
before, equal = end_epoch < start_epoch, end_epoch == start_epoch
|
||||
if before:
|
||||
raise ValueError("Invalid event interval")
|
||||
return {"id": str(component.get("UID", "")), "title": str(component.get("SUMMARY", "Untitled"))[:500],
|
||||
"start": local_iso(start), "end": local_iso(end), "allDay": all_day, "point": not all_day and equal,
|
||||
"location": str(component.get("LOCATION", ""))[:500], "calendar": name, "color": color}
|
||||
|
||||
|
||||
def serialize_task(component):
|
||||
if str(component.get("STATUS", "")).upper() in ("COMPLETED", "CANCELLED") or int(component.get("PERCENT-COMPLETE", 0)) == 100:
|
||||
return None
|
||||
return {"id": str(component.get("UID", "")), "title": str(component.get("SUMMARY", "Untitled"))[:500],
|
||||
"description": str(component.get("DESCRIPTION", ""))[:2000],
|
||||
"due": local_iso(component.decoded("DUE")) if "DUE" in component else None}
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.records = {key: {"data": None, "updated": None, "error": None, "invalid": 0} for key in SOURCES}
|
||||
|
||||
def publish(self, key, data, invalid=0, now=None):
|
||||
with self.lock:
|
||||
self.records[key] = {"data": copy.deepcopy(data), "updated": time.time() if now is None else now,
|
||||
"error": None, "invalid": invalid}
|
||||
|
||||
def fail(self, key, error):
|
||||
with self.lock:
|
||||
self.records[key]["error"] = error
|
||||
|
||||
def snapshot(self, now=None):
|
||||
now = time.time() if now is None else now
|
||||
with self.lock:
|
||||
records = copy.deepcopy(self.records)
|
||||
events, tasks, sources, updated = [], {n: [] for n in TASK_LISTS}, {}, {}
|
||||
for key, record in records.items():
|
||||
stamp = record["updated"]
|
||||
age = max(0, int(now - stamp)) if stamp is not None else None
|
||||
state = "loading" if stamp is None else "fresh"
|
||||
if record["error"]:
|
||||
state = "unavailable" if stamp is None else "error"
|
||||
if age is not None and age > STALE_SECONDS:
|
||||
state = "stale"
|
||||
if state == "fresh" and record["invalid"]:
|
||||
state = "degraded"
|
||||
sources[key] = {"label": SOURCES[key], "updated": datetime.fromtimestamp(stamp, ZONE).isoformat() if stamp is not None else None,
|
||||
"ageSeconds": age, "status": state, "invalidItems": record["invalid"]}
|
||||
if stamp is not None:
|
||||
updated[key] = sources[key]["updated"]
|
||||
if key.startswith("calendar:"):
|
||||
events.extend(record["data"] or [])
|
||||
elif key.startswith("tasks:"):
|
||||
tasks[key.split(":", 1)[1]] = record["data"] or []
|
||||
hourly = records["weather:hourly"]["data"]
|
||||
weather = copy.deepcopy(hourly) if hourly is not None else None
|
||||
if weather is not None:
|
||||
weather["daily"] = records["weather:daily"]["data"] or []
|
||||
# Keep the old display compatible during rollout; current clients use per-source metadata.
|
||||
calendar_updates = [v for k, v in updated.items() if k.startswith(("calendar:", "tasks:"))]
|
||||
if calendar_updates:
|
||||
updated["calendar"] = min(calendar_updates)
|
||||
if "weather:hourly" in updated:
|
||||
updated["weather"] = updated["weather:hourly"]
|
||||
return {"version": VERSION, "timezone": str(ZONE), "generatedAt": datetime.fromtimestamp(now, ZONE).isoformat(),
|
||||
"staleAfterSeconds": STALE_SECONDS, "events": sorted(events, key=lambda e: (e["start"], e["title"])),
|
||||
"tasks": tasks, "weather": weather, "updated": updated, "sources": sources,
|
||||
"errors": [k for k, s in sources.items() if s["status"] != "fresh"]}
|
||||
|
||||
|
||||
class DavReader:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.discovery_lock = threading.Lock()
|
||||
self.urls = {}
|
||||
self.discovered = 0
|
||||
|
||||
def client(self):
|
||||
c = self.config
|
||||
return caldav.DAVClient(url=c["dav_url"], auth=HTTPBasicAuth(c["dav_user"], c["dav_password"]), timeout=15)
|
||||
|
||||
def collection_url(self, name):
|
||||
with self.discovery_lock:
|
||||
if name not in self.urls or time.monotonic() - self.discovered > 1800:
|
||||
self.discovered = time.monotonic()
|
||||
try:
|
||||
with self.client() as client:
|
||||
urls = {calendar.name: str(calendar.url) for calendar in client.principal().calendars()}
|
||||
self.urls = urls
|
||||
except Exception:
|
||||
if name not in self.urls:
|
||||
raise
|
||||
logging.warning("DAV discovery unavailable; retaining known collection URLs")
|
||||
if name not in self.urls:
|
||||
raise ValueError("Collection unavailable")
|
||||
return self.urls[name]
|
||||
|
||||
def fetch(self, name, color=None):
|
||||
url = self.collection_url(name)
|
||||
today = datetime.now(ZONE).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
with self.client() as client:
|
||||
calendar = caldav.Calendar(client=client, url=url)
|
||||
if color:
|
||||
# One extra day covers midnight between five-minute polls.
|
||||
results = calendar.search(start=today, end=today + timedelta(days=8), event=True, expand=True)
|
||||
else:
|
||||
results = calendar.search(todo=True, include_completed=True)
|
||||
items, invalid = [], 0
|
||||
for result in results:
|
||||
try:
|
||||
item = serialize_event(result.icalendar_component, name, color) if color else serialize_task(result.icalendar_component)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
except (ValueError, TypeError, KeyError, AttributeError, OverflowError):
|
||||
invalid += 1
|
||||
return items, invalid
|
||||
|
||||
|
||||
def receive(ws, deadline):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("Forecast deadline exceeded")
|
||||
ws.settimeout(min(15, remaining))
|
||||
message = json.loads(ws.recv())
|
||||
if not isinstance(message, dict):
|
||||
raise ValueError("Invalid weather message")
|
||||
return message
|
||||
|
||||
|
||||
def fetch_weather(config, kind="hourly"):
|
||||
base, token = config["ha_url"].rstrip("/"), config["ha_access_token"]
|
||||
attrs, condition = {}, None
|
||||
if kind == "hourly":
|
||||
with requests.get(base + "/api/states/weather.forecast_home", headers={"Authorization": "Bearer " + token}, timeout=(5, 15)) as response:
|
||||
response.raise_for_status()
|
||||
state = response.json()
|
||||
attrs, condition = state["attributes"], state["state"]
|
||||
deadline = time.monotonic() + 30
|
||||
with closing(websocket.create_connection(base.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket", timeout=10)) as ws:
|
||||
if receive(ws, deadline).get("type") != "auth_required":
|
||||
raise ValueError("Weather handshake failed")
|
||||
ws.send(json.dumps({"type": "auth", "access_token": token}))
|
||||
if receive(ws, deadline).get("type") != "auth_ok":
|
||||
raise ValueError("Weather authentication failed")
|
||||
ws.send(json.dumps({"id": 1, "type": "weather/subscribe_forecast", "entity_id": "weather.forecast_home", "forecast_type": kind}))
|
||||
while True:
|
||||
message = receive(ws, deadline)
|
||||
if message.get("id") != 1:
|
||||
continue
|
||||
if message.get("type") == "result" and not message.get("success"):
|
||||
raise ValueError("Weather forecast unavailable")
|
||||
if message.get("type") == "event":
|
||||
forecast = message["event"]["forecast"]
|
||||
break
|
||||
if not isinstance(forecast, list) or not forecast:
|
||||
raise ValueError("Empty forecast")
|
||||
valid, invalid = [], 0
|
||||
for item in forecast:
|
||||
try:
|
||||
if not math.isfinite(float(item["temperature"])):
|
||||
raise ValueError("Invalid temperature")
|
||||
stamp = datetime.fromisoformat(item["datetime"])
|
||||
if stamp.tzinfo is None:
|
||||
raise ValueError("Forecast timezone missing")
|
||||
# Expose only display fields, never arbitrary upstream attributes.
|
||||
clean = {"datetime": stamp.isoformat(), "temperature": float(item["temperature"]), "condition": str(item.get("condition", "cloudy"))}
|
||||
for key in ("templow", "precipitation_probability"):
|
||||
if item.get(key) is not None:
|
||||
value = float(item[key])
|
||||
if math.isfinite(value):
|
||||
clean[key] = value
|
||||
valid.append(clean)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
invalid += 1
|
||||
if not valid:
|
||||
raise ValueError("No usable forecast")
|
||||
valid.sort(key=lambda item: datetime.fromisoformat(item["datetime"]).timestamp())
|
||||
if kind == "daily":
|
||||
return valid, invalid
|
||||
return {"forecast": valid, "unit": attrs.get("temperature_unit", "°F"), "condition": condition}, invalid
|
||||
|
||||
|
||||
def refresh_source(key, fetch, store, stop):
|
||||
failures = 0
|
||||
while not stop.is_set():
|
||||
started = time.monotonic()
|
||||
try:
|
||||
data, invalid = fetch()
|
||||
store.publish(key, data, invalid)
|
||||
failures = 0
|
||||
logging.info("Source refreshed: %s (%d invalid items)", key, invalid)
|
||||
except Exception as exc:
|
||||
# Log types only: exception bodies and upstream responses may contain secrets.
|
||||
store.fail(key, type(exc).__name__)
|
||||
failures += 1
|
||||
logging.warning("Source refresh failed: %s (%s)", key, type(exc).__name__)
|
||||
interval = REFRESH_SECONDS if not failures else min(REFRESH_SECONDS, 30 * 2 ** min(failures - 1, 4))
|
||||
delay = max(1, interval - (time.monotonic() - started))
|
||||
stop.wait(delay + random.uniform(0, 5))
|
||||
|
||||
|
||||
def validate_config(config):
|
||||
for key in ("dav_url", "dav_user", "dav_password", "ha_url", "ha_access_token", "proxy_token"):
|
||||
if not isinstance(config.get(key), str) or not config[key].strip():
|
||||
raise ValueError("Required dashboard credential missing: " + key)
|
||||
for key in ("dav_url", "ha_url"):
|
||||
url = urlparse(config[key])
|
||||
if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password:
|
||||
raise ValueError("Invalid dashboard upstream URL: " + key)
|
||||
if len(config["proxy_token"]) < 32:
|
||||
raise ValueError("Proxy credential too short")
|
||||
|
||||
|
||||
class Application:
|
||||
def __init__(self, config, store):
|
||||
self.proxy_token, self.store = config["proxy_token"], store
|
||||
self.assets = {path: ((STATIC / filename).read_bytes(), mime) for path, (filename, mime) in FILES.items()}
|
||||
html, mime = self.assets["/"]
|
||||
html = html.replace(b'APP_VERSION', VERSION.encode()).replace(b'"/app.js"', f'"/app.js?v={VERSION}"'.encode()).replace(b'"/style.css"', f'"/style.css?v={VERSION}"'.encode())
|
||||
html = html.replace(b'"/skylight.css"', f'"/skylight.css?v={VERSION}"'.encode())
|
||||
self.assets["/"] = html, mime
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
path, method = environ.get("PATH_INFO", "/"), environ.get("REQUEST_METHOD", "GET")
|
||||
code, mime, content = 200, "application/json", b""
|
||||
if method not in ("GET", "HEAD"):
|
||||
code, content = 405, b'{"error":"Method not allowed"}'
|
||||
elif path == "/health/live":
|
||||
content = b'{"ok":true}'
|
||||
elif not hmac.compare_digest(environ.get("HTTP_X_DASHBOARD_KEY", "").encode(), self.proxy_token.encode()):
|
||||
code, content = 403, b'{"error":"Forbidden"}'
|
||||
elif path in ("/api/dashboard", "/health"):
|
||||
data = self.store.snapshot()
|
||||
if path == "/health":
|
||||
ready = all(data["sources"][key]["status"] == "fresh" for key in REQUIRED)
|
||||
code = 200 if ready else 503
|
||||
data = {"ok": ready, "sources": data["sources"]}
|
||||
content = json.dumps(data, allow_nan=False).encode()
|
||||
elif path in self.assets:
|
||||
content, mime = self.assets[path]
|
||||
else:
|
||||
code, content = 404, b'{"error":"Not found"}'
|
||||
names = {200: "OK", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 503: "Service Unavailable"}
|
||||
headers = [("Content-Type", mime + "; charset=utf-8"), ("Content-Length", str(len(content))),
|
||||
("Cache-Control", "no-store"), ("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "no-referrer"),
|
||||
("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")]
|
||||
if code == 405:
|
||||
headers.append(("Allow", "GET, HEAD"))
|
||||
start_response(f"{code} {names[code]}", headers)
|
||||
return [] if method == "HEAD" else [content]
|
||||
|
||||
|
||||
def create_app():
|
||||
config = json.loads(Path(os.environ.get("CONFIG_FILE", "/run/secrets/dashboard.json")).read_text())
|
||||
validate_config(config)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
store, stop, dav = Store(), threading.Event(), DavReader(config)
|
||||
jobs = [(f"calendar:{name}", lambda n=name, c=color: dav.fetch(n, c)) for name, color in CALENDARS]
|
||||
jobs.extend((f"tasks:{name}", lambda n=name: dav.fetch(n)) for name in TASK_LISTS)
|
||||
jobs.extend((f"weather:{kind}", lambda k=kind: fetch_weather(config, k)) for kind in ("hourly", "daily"))
|
||||
for key, fetch in jobs:
|
||||
threading.Thread(target=refresh_source, args=(key, fetch, store, stop), daemon=True, name=key).start()
|
||||
return Application(config, store)
|
||||
@@ -0,0 +1,33 @@
|
||||
name: household-dashboard
|
||||
services:
|
||||
dashboard:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
pids_limit: 128
|
||||
stop_grace_period: 25s
|
||||
environment:
|
||||
TZ: America/Chicago
|
||||
CONFIG_FILE: /run/secrets/dashboard.json
|
||||
ports:
|
||||
- "8124:8080"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /etc/home-ops/household-dashboard.json
|
||||
target: /run/secrets/dashboard.json
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dav_url": "https://stalwart.uuard.com/dav/cal/dashboard@uuard.com",
|
||||
"dav_user": "dashboard@uuard.com",
|
||||
"dav_password": "REPLACE_WITH_EXISTING_READ_ONLY_DAV_PASSWORD",
|
||||
"ha_url": "http://ward-vm-apps-02.hosts.uuard.com:8123",
|
||||
"ha_access_token": "REPLACE_WITH_DEDICATED_READ_ONLY_HA_TOKEN",
|
||||
"proxy_token": "REPLACE_WITH_SHARED_EDGE_TOKEN_AT_LEAST_32_CHARACTERS"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"household-dashboard","private":true,"type":"module","scripts":{"test":"node tests/layout.mjs"}}
|
||||
@@ -0,0 +1,16 @@
|
||||
caldav==2.1.0
|
||||
certifi==2026.7.22
|
||||
charset-normalizer==3.5.2
|
||||
click==8.5.0
|
||||
gunicorn==26.2.0
|
||||
icalendar==7.3.0
|
||||
idna==3.20
|
||||
lxml==6.1.3
|
||||
python-dateutil==2.9.0.post0
|
||||
recurring-ical-events==3.8.2
|
||||
requests==2.32.5
|
||||
six==1.17.0
|
||||
tzdata==2026.5
|
||||
urllib3==2.8.0
|
||||
websocket-client==1.8.0
|
||||
x-wr-timezone==2.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
caldav==2.1.0
|
||||
requests==2.32.5
|
||||
websocket-client==1.8.0
|
||||
gunicorn==26.2.0
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
const ZONE = 'America/Chicago';
|
||||
const TASKS = ['Next'];
|
||||
export function resolveTheme(value) {return value==='homeassistant'?'homeassistant':'skylight';}
|
||||
let theme='skylight';
|
||||
function eventColor(color) {
|
||||
if(theme==='homeassistant')return color;
|
||||
const hex=color.replace('#','');
|
||||
return '#'+[0,2,4].map(i=>Math.round(parseInt(hex.slice(i,i+2),16)*.28+255*.72).toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
const dayFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,year:'numeric',month:'2-digit',day:'2-digit'});
|
||||
const hourFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,hour:'2-digit',minute:'2-digit',hourCycle:'h23'});
|
||||
const clockFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,hour:'numeric',minute:'2-digit',hour12:true});
|
||||
const weekdayFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,weekday:'short'});
|
||||
export function dayKey(value) {
|
||||
const p=dayFormatter.formatToParts(new Date(value));
|
||||
return ['year','month','day'].map(k=>p.find(v=>v.type===k).value).join('-');
|
||||
}
|
||||
export function addDays(key,count) {
|
||||
const date=new Date(`${key}T12:00:00Z`);date.setUTCDate(date.getUTCDate()+count);
|
||||
return date.toISOString().slice(0,10);
|
||||
}
|
||||
export function wallHour(value,key) {
|
||||
const p=hourFormatter.formatToParts(new Date(value));
|
||||
const offset=(Date.parse(`${dayKey(value)}T12:00:00Z`)-Date.parse(`${key}T12:00:00Z`))/86400000;
|
||||
return offset*24+Number(p.find(v=>v.type==='hour').value)+Number(p.find(v=>v.type==='minute').value)/60;
|
||||
}
|
||||
export function layoutEvents(events,key,minimumDuration=.25) {
|
||||
const blocks=[];
|
||||
for(const event of events) {
|
||||
if(event.allDay)continue;
|
||||
try {
|
||||
let start=wallHour(event.start,key),end=wallHour(event.end,key);
|
||||
const elapsed=(Date.parse(event.end)-Date.parse(event.start))/3600000;
|
||||
if(elapsed>0&&end<=start)end=start+elapsed; // The repeated hour at fall DST transition.
|
||||
if(start>=24||end<0||(end===0&&start<0)||(event.point&&dayKey(event.start)!==key))continue;
|
||||
start=Math.max(0,start);end=Math.min(24,end);
|
||||
if(end<start||(!event.point&&end===start))continue;
|
||||
blocks.push({event,start,end,displayEnd:Math.min(24,Math.max(end,start+minimumDuration))});
|
||||
} catch { /* A malformed item must not blank the entire schedule. */ }
|
||||
}
|
||||
blocks.sort((a,b)=>a.start-b.start||b.displayEnd-a.displayEnd);
|
||||
let group=[],end=-1;
|
||||
function finish() {
|
||||
const columns=[];
|
||||
for(const block of group) {
|
||||
let col=columns.findIndex(lastEnd=>lastEnd<=block.start);
|
||||
if(col===-1)col=columns.length;
|
||||
columns[col]=block.displayEnd;block.column=col;
|
||||
}
|
||||
for(const block of group)block.columns=columns.length;
|
||||
}
|
||||
for(const block of blocks) {
|
||||
if(block.start>=end){finish();group=[];end=-1;}
|
||||
group.push(block);end=Math.max(end,block.displayEnd);
|
||||
}
|
||||
finish();return blocks;
|
||||
}
|
||||
export function paginateHeights(heights,available) {
|
||||
const pages=[];let page=[],used=0;
|
||||
for(let i=0;i<heights.length;i++) {
|
||||
if(page.length&&used+heights[i]>available){pages.push(page);page=[];used=0;}
|
||||
page.push(i);used+=heights[i];
|
||||
}
|
||||
if(page.length)pages.push(page);
|
||||
return pages;
|
||||
}
|
||||
export function sourceState(source,now,staleAfter=900) {
|
||||
if(!source?.updated)return source?.status||'loading';
|
||||
if(now-Date.parse(source.updated)>staleAfter*1000)return 'stale';
|
||||
return source.status;
|
||||
}
|
||||
const formatTime=value=>clockFormatter.format(new Date(value)).replace(':00','');
|
||||
const el=(tag,className,text)=>{const node=document.createElement(tag);if(className)node.className=className;if(text!==undefined)node.textContent=text;return node;};
|
||||
let data,failed=false,inFlight=false,retryCount=0,pollTimer,rotation=0,lastReceived=null,serverOffset=0,lastDay;
|
||||
const taskPages=new Map();
|
||||
function now(){return Date.now()+serverOffset;}
|
||||
function tickClock() {
|
||||
const p=clockFormatter.formatToParts(new Date());
|
||||
document.querySelector('#clock').textContent=p.filter(v=>['hour','minute','literal'].includes(v.type)).map(v=>v.value).join('').trim();
|
||||
document.querySelector('#period').textContent=p.find(v=>v.type==='dayPeriod').value;
|
||||
const dateNode=document.querySelector('#today-date');
|
||||
if(dateNode)dateNode.textContent=new Intl.DateTimeFormat('en-US',{timeZone:ZONE,weekday:'short',month:'short',day:'numeric'}).format(new Date());
|
||||
const day=dayKey(new Date());
|
||||
if(lastDay&&lastDay!==day){renderCalendar();refresh();}
|
||||
lastDay=day;
|
||||
}
|
||||
function eventTime(event) {return event.point?formatTime(event.start):`${formatTime(event.start)}–${formatTime(event.end)}`;}
|
||||
function contrast(color) {
|
||||
const hex=color.replace('#','');const rgb=[0,2,4].map(i=>parseInt(hex.slice(i,i+2),16));
|
||||
const linear=rgb.map(value=>value/255<=.04045?value/255/12.92:((value/255+.055)/1.055)**2.4);
|
||||
return linear[0]*.2126+linear[1]*.7152+linear[2]*.0722>.179?'#151515':'#fff';
|
||||
}
|
||||
function renderCalendar() {
|
||||
const container=document.querySelector('#calendar');
|
||||
const loaded=data&&Object.entries(data.sources).some(([key,s])=>key.startsWith('calendar:')&&s.updated);
|
||||
if(!loaded){container.replaceChildren(el('p','loading',data?'Calendar temporarily unavailable':'Loading household calendar…'));return;}
|
||||
const today=dayKey(new Date()),days=Array.from({length:7},(_,i)=>addDays(today,i));
|
||||
const raw=days.map(key=>layoutEvents(data.events,key,0)).flat();
|
||||
const first=Math.min(8,...raw.map(e=>Math.floor(e.start)));
|
||||
const last=Math.min(24,Math.max(21,...raw.map(e=>Math.floor(e.end)+1)));
|
||||
const head=el('div','schedule-head');head.append(el('div'));
|
||||
for(const key of days) {
|
||||
const date=new Date(`${key}T12:00:00Z`),heading=el('div',`day-heading${key===today?' today':''}`);
|
||||
heading.append(el('span','day-name',weekdayFormatter.format(date)));
|
||||
const summary=el('div','day-summary');summary.append(el('strong','',String(date.getUTCDate())));
|
||||
const daily=data.weather?.daily?.find(item=>dayKey(item.datetime)===key);
|
||||
if(daily){const weather=el('span','day-weather');weather.append(weatherIcon(daily.condition),el('span','',`${Math.round(daily.temperature)}°\n${daily.templow!=null?Math.round(daily.templow)+'°':''}`));summary.append(weather);}
|
||||
heading.append(summary);head.append(heading);
|
||||
}
|
||||
const allDay=el('div','all-day');allDay.append(el('div','all-day-label','All day'));
|
||||
const dayEvents=days.map(key=>data.events.filter(e=>e.allDay&&e.start<=key&&e.end>key));
|
||||
const bubbleHeight=window.innerWidth>=2400?34:24;
|
||||
const cap=Math.max(1,Math.floor((container.clientHeight*.18-18)/bubbleHeight));
|
||||
const maximum=Math.max(1,...dayEvents.map(events=>Math.min(cap,events.length)));
|
||||
allDay.style.height=`${maximum*bubbleHeight+18}px`;
|
||||
const dense=[];
|
||||
dayEvents.forEach(events=>{
|
||||
const cell=el('div','all-day-cell'),pages=Math.max(1,Math.ceil(events.length/cap)),offset=(rotation%pages)*cap;
|
||||
for(const event of events.slice(offset,offset+cap)) {
|
||||
const node=el('div','all-day-event',event.title);node.style.backgroundColor=eventColor(event.color);node.style.setProperty('--event-text',contrast(eventColor(event.color)));cell.append(node);
|
||||
}
|
||||
if(pages>1){cell.append(el('div','all-day-more',`${offset+1}–${Math.min(offset+cap,events.length)} of ${events.length}`));dense.push(...events);}
|
||||
allDay.append(cell);
|
||||
});
|
||||
const body=el('div','schedule-body'),detail=el('div','calendar-detail');
|
||||
container.replaceChildren(head,allDay,body,detail);
|
||||
const hourHeight=Math.max(1,body.clientHeight/(last-first));body.style.setProperty('--hour-height',`${hourHeight}px`);
|
||||
const hours=el('div','hours');
|
||||
for(let h=first;h<last;h++){const label=el('span','hour-label',`${h%12||12} ${h<12?'AM':'PM'}`);label.style.top=`${(h-first)*hourHeight}px`;hours.append(label);}
|
||||
body.append(hours);
|
||||
days.forEach(key=>{
|
||||
const column=el('div','day-column');
|
||||
for(const block of layoutEvents(data.events,key,26/hourHeight)) {
|
||||
const start=Math.max(first,block.start),end=Math.min(last,block.displayEnd);if(end<=start)continue;
|
||||
const height=(end-start)*hourHeight-2,compact=height<48;
|
||||
const node=el('div',`event${Date.parse(block.event.end)<now()?' past':''}${compact?' compact':''}`);
|
||||
Object.assign(node.style,{backgroundColor:eventColor(block.event.color),top:`${(start-first)*hourHeight}px`,height:`${Math.max(18,height)}px`,left:`calc(4px + (100% - 8px) * ${block.column/block.columns})`,width:`calc((100% - 8px) / ${block.columns} - 2px)`});
|
||||
node.style.setProperty('--event-text',contrast(eventColor(block.event.color)));
|
||||
node.append(el('div','event-title',block.event.title),el('div','event-time',eventTime(block.event)));
|
||||
if(block.event.location)node.append(el('div','event-location',block.event.location));
|
||||
column.append(node);
|
||||
if(compact||block.columns>1||block.event.title.length>30)dense.push(block.event);
|
||||
}
|
||||
if(key===today){const current=wallHour(new Date(),key);if(current>=first&¤t<last){const line=el('div','now-line');line.style.top=`${(current-first)*hourHeight}px`;column.append(line);}}
|
||||
body.append(column);
|
||||
});
|
||||
const unique=[...new Map(dense.map(e=>[`${e.id}:${e.start}`,e])).values()];
|
||||
if(unique.length){const event=unique[rotation%unique.length];detail.textContent=`${weekdayFormatter.format(new Date(event.allDay?event.start+'T12:00:00Z':event.start))} · ${event.allDay?'All day':eventTime(event)} · ${event.title}${event.location?' · '+event.location:''}`;}
|
||||
}
|
||||
function weatherIcon(condition) {
|
||||
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||||
svg.setAttribute('viewBox','0 0 32 32');svg.setAttribute('class','weather-icon');
|
||||
svg.setAttribute('role','img');svg.setAttribute('aria-label',condition||'Weather');
|
||||
const sun='<circle cx="16" cy="16" r="6" fill="#ffce54"/><path d="M16 2v4m0 20v4M2 16h4m20 0h4M6 6l3 3m14 14 3 3M6 26l3-3M23 9l3-3" stroke="#ffce54" stroke-width="2" stroke-linecap="round"/>';
|
||||
const cloud='<path d="M8 23h17a5 5 0 0 0 0-10h-1a8 8 0 0 0-15-2 6 6 0 0 0-1 12Z" fill="#c9d2dc"/>';
|
||||
const moon='<path d="M25 22A12 12 0 0 1 10 6a12 12 0 1 0 15 16Z" fill="#fff59d"/>';
|
||||
let shape=condition==='sunny'?sun:condition==='clear-night'?moon:cloud;
|
||||
if(condition==='partlycloudy')shape='<g transform="translate(-2 -4) scale(.85)">'+sun+'</g>'+cloud;
|
||||
if(['rainy','pouring','lightning-rainy','snowy-rainy'].includes(condition))shape+='<path d="m10 26-2 4m10-4-2 4m10-4-2 4" stroke="#55b8ff" stroke-width="2" stroke-linecap="round"/>';
|
||||
if(['lightning','lightning-rainy'].includes(condition))shape+='<path d="m18 17-6 9h5l-2 6 8-11h-6Z" fill="#ffce54"/>';
|
||||
if(['snowy','snowy-rainy','hail'].includes(condition))shape+='<path d="M10 25v6m-3-3h6m10-3v6m-3-3h6" stroke="#c4e8ff" stroke-width="2"/>';
|
||||
if(condition==='fog')shape+='<path d="M3 26h26M6 30h20" stroke="#b0bec5" stroke-width="2"/>';
|
||||
if(['windy','windy-variant'].includes(condition))shape='<path d="M2 12h21a4 4 0 1 0-4-4M2 18h25a4 4 0 1 1-4 4M2 24h12" fill="none" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>';
|
||||
svg.innerHTML=shape;return svg;
|
||||
}
|
||||
function renderWeather() {
|
||||
const container=document.querySelector('#weather');
|
||||
if(!data?.weather){container.replaceChildren(el('p','loading',data?'Weather temporarily unavailable':'Loading weather…'));return;}
|
||||
const cutoff=Math.floor(now()/3600000)*3600000;
|
||||
const items=data.weather.forecast.filter(item=>Date.parse(item.datetime)>=cutoff).slice(0,12);
|
||||
if(!items.length){container.replaceChildren(el('p','loading','Weather forecast is out of date'));return;}
|
||||
const forecast=el('div','forecast');
|
||||
for(const item of items){const slot=el('div','forecast-slot');slot.append(el('span','',formatTime(item.datetime)),weatherIcon(item.condition),el('span','temperature',`${Math.round(item.temperature)}°`),el('span','rain',item.precipitation_probability!=null?`${Math.round(item.precipitation_probability)}%`:''));forecast.append(slot);}
|
||||
container.replaceChildren(forecast);
|
||||
}
|
||||
function showTaskPages() {
|
||||
for(const entry of taskPages.values()) {
|
||||
const page=rotation%entry.pages.length,indices=entry.pages[page];
|
||||
const list=el('ul');for(const index of indices)list.append(entry.rows[index]);entry.window.replaceChildren(list);
|
||||
entry.label.textContent=entry.pages.length===1?`${entry.rows.length} ${entry.rows.length===1?'task':'tasks'}`:`${indices[0]+1}–${indices.at(-1)+1} of ${entry.rows.length} · ${page+1}/${entry.pages.length}`;
|
||||
}
|
||||
}
|
||||
function renderTasks() {
|
||||
taskPages.clear();const grid=document.querySelector('#tasks');grid.replaceChildren();
|
||||
for(const name of TASKS) {
|
||||
const card=el('section','card task-list');card.append(el('h2','',name));
|
||||
const window=el('div','task-window'),label=el('div','task-count');card.append(window,label);grid.append(card);
|
||||
if(!data?.sources[`tasks:${name}`]?.updated){window.append(el('p','empty',data?'Temporarily unavailable':'Loading…'));continue;}
|
||||
const tasks=data.tasks[name];
|
||||
if(!tasks.length){window.append(el('p','empty','No tasks'));continue;}
|
||||
const list=el('ul'),rows=[];
|
||||
for(const task of tasks) {
|
||||
const row=el('li'),text=el('span','task-text');text.append(el('span','task-title',task.title));
|
||||
if(task.description)text.append(el('small','task-description',task.description));
|
||||
row.append(el('span','task-dot'),text);list.append(row);rows.push(row);
|
||||
}
|
||||
window.append(list);
|
||||
const heights=rows.map(row=>row.getBoundingClientRect().height);
|
||||
taskPages.set(name,{window,label,rows,pages:paginateHeights(heights,Math.max(1,window.clientHeight-2))});
|
||||
}
|
||||
showTaskPages();
|
||||
}
|
||||
function status() {
|
||||
const node=document.querySelector('#status');
|
||||
if(!data){node.textContent=failed?'Unable to reach household data · retrying':'Connecting to household data…';node.classList.toggle('warning',failed);return;}
|
||||
const problems=Object.entries(data.sources).filter(([key,source])=>(!key.startsWith('tasks:')||TASKS.includes(key.slice(6)))&&sourceState(source,now(),data.staleAfterSeconds)!=='fresh');
|
||||
const updated=Object.values(data.sources).map(s=>s.updated).filter(Boolean).sort();
|
||||
let text=updated.length?`Updated ${formatTime(updated[0])}`:'Waiting for household data';
|
||||
if(failed)text=`Connection lost · last received ${lastReceived?formatTime(lastReceived):'unknown'}`;
|
||||
else if(problems.length){const [key,source]=problems[rotation%problems.length];const state=sourceState(source,now(),data.staleAfterSeconds);const reason=state==='degraded'?'some items unavailable':source.updated?'showing older data':'temporarily unavailable';text=`${source.label}: ${reason}${source.updated?' · last updated '+formatTime(source.updated):''}${problems.length>1?' · '+problems.length+' sources affected':''}`;}
|
||||
node.textContent=text;node.classList.toggle('warning',failed||problems.length>0);
|
||||
document.querySelector('.weather').classList.toggle('source-stale',sourceState(data.sources['weather:hourly'],now(),data.staleAfterSeconds)!=='fresh');
|
||||
for(const card of document.querySelectorAll('.task-list')){const name=card.querySelector('h2').textContent;card.classList.toggle('source-stale',sourceState(data.sources[`tasks:${name}`],now(),data.staleAfterSeconds)!=='fresh');}
|
||||
}
|
||||
function validSnapshot(value) {
|
||||
return value&&typeof value.version==='string'&&Array.isArray(value.events)&&value.tasks&&TASKS.every(name=>Array.isArray(value.tasks[name]))&&value.sources&&Number.isFinite(Date.parse(value.generatedAt))&&Number.isFinite(value.staleAfterSeconds);
|
||||
}
|
||||
async function refresh() {
|
||||
if(inFlight)return;
|
||||
inFlight=true;clearTimeout(pollTimer);
|
||||
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),10000);
|
||||
try {
|
||||
const response=await fetch('/api/dashboard',{cache:'no-store',signal:controller.signal});if(!response.ok)throw new Error('Unavailable');
|
||||
const next=await response.json();if(!validSnapshot(next))throw new Error('Invalid snapshot');
|
||||
const version=document.querySelector('meta[name="app-version"]')?.content;
|
||||
if(version&&version!=='APP_VERSION'&&next.version!==version){location.reload();return;}
|
||||
data=next;lastReceived=Date.now();serverOffset=Date.parse(next.generatedAt)-Date.now();failed=false;retryCount=0;
|
||||
renderCalendar();renderWeather();renderTasks();
|
||||
} catch {failed=true;retryCount++;}
|
||||
finally {clearTimeout(timeout);inFlight=false;status();pollTimer=setTimeout(refresh,failed?Math.min(60000,15000*2**Math.min(retryCount-1,2)):60000);}
|
||||
}
|
||||
if(typeof document!=='undefined') {
|
||||
theme=resolveTheme(new URLSearchParams(location.search).get('theme'));
|
||||
document.documentElement.dataset.theme=theme;
|
||||
tickClock();renderTasks();status();refresh();
|
||||
setInterval(tickClock,1000);
|
||||
setInterval(()=>{rotation++;if(data){renderCalendar();renderWeather();showTaskPages();}status();},20000);
|
||||
let resizeTimer;window.addEventListener('resize',()=>{clearTimeout(resizeTimer);resizeTimer=setTimeout(()=>{if(data){renderCalendar();renderTasks();}},150);});
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1c1c1c"/><path d="M6 9h20v17H6z" fill="none" stroke="#4ecdc4" stroke-width="2"/><path d="M6 14h20M11 6v6M21 6v6" stroke="#4ecdc4" stroke-width="2"/><path d="m11 20 3 3 7-6" fill="none" stroke="#fff" stroke-width="2"/></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="skylight">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Household</title><meta name="app-version" content="APP_VERSION"><link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/skylight.css"><script type="module" src="/app.js"></script></head>
|
||||
<body><header class="theme-header"><span id="today-date"></span><span class="theme-title">Household</span></header><div class="theme-rail" aria-hidden="true"><div class="rail-home">⌂<small>Home</small></div><div>▦<small>Calendar</small></div><div>✓<small>Tasks</small></div></div><main class="dashboard"><section class="card calendar" aria-label="Household calendar"><div id="calendar"><p class="loading">Loading household calendar…</p></div></section><aside><section class="card clock-card" aria-label="Current time"><time id="clock"></time><span id="period"></span></section><section class="card weather" aria-label="Hourly weather"><h2>Hourly weather</h2><div id="weather"><p class="loading">Loading weather…</p></div></section><section class="tasks" aria-label="Tasks"><h2>Tasks</h2><div id="tasks" class="task-grid"></div></section></aside></main><div id="status" role="status" aria-live="polite"></div></body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Skylight Home / Schedule reference. All overrides are isolated to this theme. */
|
||||
html[data-theme=skylight]{color-scheme:light;color:#292929;background:#fff;font-family:"Avenir Next",Avenir,"Trebuchet MS",Arial,sans-serif;--border:#e5e7e8;--muted:#757b80;--event-font:16px;--event-small:12px}
|
||||
[data-theme=skylight] body{padding:104px 24px 30px 116px}
|
||||
[data-theme=skylight] .theme-header{display:flex;position:absolute;top:0;left:124px;right:24px;height:88px;align-items:center;gap:24px}
|
||||
[data-theme=skylight] #today-date{font-family:Georgia,serif;font-size:44px;white-space:nowrap}
|
||||
[data-theme=skylight] .theme-title{margin-left:auto;color:#757b80;font-size:18px}
|
||||
[data-theme=skylight] .theme-rail{display:flex;position:absolute;top:0;bottom:0;left:0;width:92px;background:#e8f1f6;flex-direction:column;align-items:center;gap:44px;color:#747e83;font-size:30px;text-align:center;padding:20px 0}
|
||||
[data-theme=skylight] .theme-rail small{display:block;font-size:15px;margin-top:4px}
|
||||
[data-theme=skylight] .rail-home{color:#292929;background:white;width:92px;margin-top:-20px;padding:20px 0 18px}
|
||||
[data-theme=skylight] .dashboard{grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);height:calc(100dvh - 134px);gap:24px}
|
||||
[data-theme=skylight] .card{background:white;border-color:#e5e7e8;border-radius:20px}
|
||||
[data-theme=skylight] aside{gap:20px}
|
||||
[data-theme=skylight] .clock-card{position:absolute;top:0;left:440px;height:88px;min-height:0;border:0;border-radius:0;background:transparent;padding:0;gap:10px}
|
||||
[data-theme=skylight] #clock{font-family:Georgia,serif;font-size:42px;line-height:1;font-weight:400}
|
||||
[data-theme=skylight] #period{font-family:Georgia,serif;font-size:36px;opacity:1;align-self:center;margin:0}
|
||||
[data-theme=skylight] h2{font-weight:600;font-size:22px}
|
||||
[data-theme=skylight] .tasks>h2{display:none}
|
||||
[data-theme=skylight] .task-grid{grid-template-columns:1fr;grid-template-rows:1fr}
|
||||
[data-theme=skylight] .task-list h2{margin:24px}
|
||||
[data-theme=skylight] .task-list li{margin:8px 0;padding:18px 16px;border:0;border-radius:12px;background:#f0f4fa;gap:14px;font-size:18px}
|
||||
[data-theme=skylight] .task-list ul{padding:0 24px}
|
||||
[data-theme=skylight] .task-dot{border-radius:50%;border-color:#9faabd;width:22px;height:22px;flex-basis:22px}
|
||||
[data-theme=skylight] .calendar{padding:20px 16px 12px}
|
||||
[data-theme=skylight] .day-heading,[data-theme=skylight] .all-day-cell,[data-theme=skylight] .day-column{background-color:white;border-left-color:#eaedef}
|
||||
[data-theme=skylight] .day-heading{color:#65696e;font-size:14px}
|
||||
[data-theme=skylight] .day-heading strong,[data-theme=skylight] .day-weather{color:#292929}
|
||||
[data-theme=skylight] .day-heading.today strong{background:#ec8b36;color:white}
|
||||
[data-theme=skylight] .day-heading strong{font-size:26px}
|
||||
[data-theme=skylight] .day-column{background-image:repeating-linear-gradient(to bottom,transparent 0,transparent calc(var(--hour-height) - 1px),#eceeef calc(var(--hour-height) - 1px),#eceeef var(--hour-height))}
|
||||
[data-theme=skylight] .event{border-radius:9px;padding:6px 8px}
|
||||
[data-theme=skylight] .all-day-event{border-radius:6px}
|
||||
[data-theme=skylight] .event-title{font-weight:600}
|
||||
[data-theme=skylight] .now-line,[data-theme=skylight] .now-line:before{background:#ec8b36}
|
||||
[data-theme=skylight] .calendar-detail,[data-theme=skylight] .all-day-more{color:#747b80}
|
||||
[data-theme=skylight] #status{left:116px;color:#747b80}
|
||||
[data-theme=skylight] #status.warning{color:#966100}
|
||||
[data-theme=skylight] .source-stale{border-color:#bd933b}
|
||||
@media(min-width:2400px){[data-theme=skylight] body{padding:156px 36px 38px 164px}[data-theme=skylight] .dashboard{height:calc(100dvh - 194px);gap:32px}[data-theme=skylight] .theme-rail{width:128px;font-size:44px;gap:60px}[data-theme=skylight] .rail-home{width:128px}[data-theme=skylight] .theme-rail small{font-size:22px}[data-theme=skylight] .theme-header{left:164px;height:136px}[data-theme=skylight] #today-date{font-size:64px}[data-theme=skylight] .clock-card{left:640px;height:136px}[data-theme=skylight] #clock{font-size:64px}[data-theme=skylight] #period{font-size:54px}[data-theme=skylight] h2{font-size:32px}[data-theme=skylight] .task-list li{font-size:26px;padding:24px}[data-theme=skylight] .day-heading{font-size:22px}[data-theme=skylight] .day-heading strong{font-size:36px}[data-theme=skylight]{--event-font:22px;--event-small:16px}}
|
||||
@media(max-width:900px){[data-theme=skylight] body{padding:88px 12px 32px}[data-theme=skylight] .theme-rail{display:none}[data-theme=skylight] .theme-header{left:12px;height:76px}[data-theme=skylight] #today-date{font-size:24px}[data-theme=skylight] .theme-title{display:none}[data-theme=skylight] .clock-card{left:auto;right:12px;height:76px}[data-theme=skylight] #clock{font-size:28px}[data-theme=skylight] #period{font-size:22px}[data-theme=skylight] .dashboard{grid-template-columns:1fr;height:auto;gap:16px}[data-theme=skylight] .calendar{padding:12px 6px}[data-theme=skylight] .day-heading{font-size:11px}[data-theme=skylight] .day-heading strong{font-size:18px}[data-theme=skylight] #status{left:8px;background:#fffffff0}[data-theme=skylight]{--event-font:12px;--event-small:10px}}
|
||||
|
||||
[data-theme=skylight] .day-heading{position:relative;display:flex;align-items:flex-start;gap:6px;padding:10px 8px 34px;font-family:Georgia,serif;font-size:clamp(16px,1.35vw,38px);min-height:82px;white-space:nowrap}
|
||||
[data-theme=skylight] .day-heading strong{font-family:Georgia,serif;font-size:inherit;font-weight:400;line-height:1.15}
|
||||
[data-theme=skylight] .day-summary{min-height:0}
|
||||
[data-theme=skylight] .day-weather{position:absolute;bottom:6px;left:8px;font-family:"Trebuchet MS",Arial,sans-serif;font-size:12px;gap:4px}
|
||||
[data-theme=skylight] .day-heading.today strong{width:32px;height:32px;margin-top:-3px}
|
||||
@media(min-width:2400px){[data-theme=skylight] .day-heading{min-height:114px;padding-bottom:46px}[data-theme=skylight] .day-weather{font-size:18px}[data-theme=skylight] .day-heading.today strong{width:46px;height:46px}}
|
||||
@media(max-width:900px){[data-theme=skylight] .day-heading{font-size:14px;gap:2px;padding:6px 3px 34px;min-height:70px;flex-wrap:wrap}[data-theme=skylight] .day-weather{left:3px;font-size:10px}[data-theme=skylight] .day-heading.today strong{width:24px;height:24px}[data-theme=skylight] .day-summary{flex-direction:row}}
|
||||
@@ -0,0 +1,17 @@
|
||||
:root{color-scheme:dark;font-family:Roboto,Arial,sans-serif;color:#e1e1e1;background:#111;--border:#383838;--muted:#aaa;--event-font:16px;--event-small:12px}
|
||||
*{box-sizing:border-box}body{margin:0;padding:8px;overflow:hidden}.dashboard{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px;height:calc(100dvh - 42px);min-height:0}
|
||||
.card{background:#1c1c1c;border:1px solid var(--border);border-radius:12px;overflow:hidden}aside{min-width:0;min-height:0;display:flex;flex-direction:column;gap:8px}h2{font-size:20px;font-weight:400;margin:16px}
|
||||
.clock-card{display:flex;align-items:center;justify-content:center;padding:16px;min-height:144px;gap:16px;flex:none}#clock{font-size:128px;line-height:.85;font-weight:500;font-variant-numeric:tabular-nums}#period{font-size:48px;opacity:.6;align-self:flex-end;margin-bottom:8px}
|
||||
.weather{flex:none}.weather h2{margin-bottom:14px}.forecast{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;padding:0 12px 16px;text-align:center}.forecast-slot{display:flex;flex-direction:column;align-items:center;gap:8px;font-size:14px}.weather-icon{width:36px;height:36px;flex-shrink:0}.temperature{font-size:18px}.rain{color:var(--muted);font-size:12px;min-height:14px}
|
||||
.tasks{display:flex;flex-direction:column;flex:1;min-height:0}.tasks>h2{margin:12px 0}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:minmax(0,1fr) minmax(0,1fr);gap:8px;flex:1;min-height:0}.task-list{display:flex;flex-direction:column;min-height:0}.task-list h2{margin:16px;flex:none}.task-window{flex:1;min-height:0;overflow:hidden}.task-list ul{list-style:none;padding:0 16px;margin:0}.task-list li{display:flex;gap:12px;align-items:flex-start;padding:10px 0;line-height:1.35;border-bottom:1px solid #ffffff08;font-size:16px}.task-text{min-width:0;overflow-wrap:anywhere}.task-title{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.task-dot{width:18px;height:18px;flex:0 0 18px;border:2px solid #9b9b9b;border-radius:3px;margin-top:1px}.task-description{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:12px;color:var(--muted);margin-top:4px}.task-count{flex:none;min-height:24px;padding:4px 16px 8px;color:var(--muted);font-size:12px}
|
||||
.empty,.loading{color:var(--muted);font-size:14px;margin:20px 16px}.calendar{padding:16px 12px 12px;background:#30363f;min-height:0}#calendar{display:flex;flex-direction:column;height:100%;min-height:0}.schedule-head,.all-day,.schedule-body{display:grid;grid-template-columns:48px repeat(7,minmax(0,1fr))}.schedule-head{flex:none}.day-heading{text-align:center;padding:4px 0 8px;font-size:13px;color:var(--muted);background:#353c45;border-left:1px solid #46505c}.day-summary{display:flex;align-items:center;justify-content:center;gap:12px;min-height:44px}.day-heading strong{display:block;font-size:24px;font-weight:500;color:#e1e1e1}.day-heading.today strong{background:#3385ff;color:white;border-radius:50%;width:38px;height:38px;display:flex;align-items:center;justify-content:center}.day-weather{display:flex;gap:7px;align-items:center;white-space:pre-line;font-size:13px;color:#e1e1e1}.day-weather .weather-icon{width:20px;height:20px}
|
||||
.all-day{flex:none;border-bottom:1px solid var(--border);min-height:30px;max-height:20%}.all-day-label{font-size:11px;color:var(--muted);padding-top:8px}.all-day-cell{background:#353c45;border-left:1px solid #46505c;padding:3px;overflow:hidden}.all-day-event{border-radius:4px;padding:4px;font-size:12px;margin-bottom:3px;color:var(--event-text,#151515);overflow-wrap:anywhere}.all-day-more{font-size:10px;color:#ccc;min-height:12px}.schedule-body{flex:1;min-height:0}.hours{position:relative}.hour-label{position:absolute;left:0;right:5px;color:var(--muted);font-size:11px;text-align:right;transform:translateY(-50%)}.day-column{position:relative;min-height:0;border-left:1px solid #46505c;background-color:#353c45;background-image:repeating-linear-gradient(to bottom,transparent 0,transparent calc(var(--hour-height) - 1px),#ffffff15 calc(var(--hour-height) - 1px),#ffffff15 var(--hour-height))}
|
||||
.event{position:absolute;border-radius:5px;padding:4px 5px;color:var(--event-text,#151515);overflow:hidden;font-size:var(--event-font);line-height:1.2;overflow-wrap:anywhere}.event-title{font-weight:500;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.event-time,.event-location{font-size:var(--event-small);margin-top:3px}.event.compact{padding:3px 4px}.event.compact .event-title{-webkit-line-clamp:1;white-space:nowrap;text-overflow:ellipsis;display:block;overflow:hidden}.event.compact .event-time,.event.compact .event-location{display:none}.event.past{opacity:.55}.now-line{position:absolute;height:2px;background:#f44336;left:0;right:0;z-index:3}.now-line:before{content:"";position:absolute;width:8px;height:8px;border-radius:50%;background:#f44336;left:-4px;top:-3px}.calendar-detail{flex:none;min-height:22px;font-size:12px;color:#d4d8df;padding:6px 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#status{position:fixed;bottom:5px;left:16px;right:16px;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#status.warning{color:#ffd180}.card.source-stale{border-color:#8d7139}
|
||||
@media(min-width:2400px){:root{--event-font:20px;--event-small:15px}h2{font-size:32px}.forecast-slot{font-size:22px}.temperature{font-size:28px}.task-list li{font-size:20px}.task-list h2{margin:24px}.weather-icon{width:44px;height:44px}.all-day-event{font-size:20px}.day-heading{font-size:18px}.day-heading strong{font-size:32px}.day-weather{font-size:18px}.day-weather .weather-icon{width:24px;height:24px}.task-count,#status,.calendar-detail{font-size:16px}.all-day-more{font-size:14px}}
|
||||
@media(max-height:800px) and (min-width:901px){.clock-card{min-height:104px;padding:10px}#clock{font-size:96px}#period{font-size:32px}.weather h2{font-size:16px;margin:10px}.forecast-slot{gap:4px;font-size:12px}.weather-icon{width:26px;height:26px}.task-list h2{margin:10px 16px;font-size:18px}.tasks>h2{margin:8px 0}.task-list li{padding:6px 0;font-size:14px}}
|
||||
@media(max-width:900px){body{overflow:auto;padding-bottom:30px}.dashboard{grid-template-columns:1fr;height:auto}.calendar{height:75dvh;min-height:480px}aside{height:90dvh;min-height:650px}.forecast{gap:2px;padding-left:4px;padding-right:4px}.forecast-slot{font-size:11px}.weather-icon{width:23px;height:23px}.temperature{font-size:14px}:root{--event-font:12px;--event-small:10px}.schedule-head,.all-day,.schedule-body{grid-template-columns:36px repeat(7,minmax(0,1fr))}.day-summary{gap:3px;flex-direction:column}.day-weather{font-size:10px}.day-weather .weather-icon{width:12px;height:12px}.day-heading strong{font-size:18px}.day-heading.today strong{height:28px;width:28px}#clock{font-size:clamp(72px,18vw,128px)}#period{font-size:32px}#status{background:#111e;padding:4px;left:4px;right:4px}}
|
||||
.all-day-event{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}
|
||||
|
||||
.theme-header,.theme-rail{display:none}
|
||||
.task-grid{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {dayKey, addDays, wallHour, layoutEvents} from '../static/app.js';
|
||||
assert.equal(dayKey('2026-10-05T01:00:00Z'), '2026-10-04');
|
||||
assert.equal(addDays('2026-10-31', 2), '2026-11-02');
|
||||
assert.equal(wallHour('2026-11-02T15:00:00Z', '2026-11-02'), 9);
|
||||
const event=(start,end)=>({start:`2026-10-04T${start}:00-05:00`,end:`2026-10-04T${end}:00-05:00`,allDay:false});
|
||||
const blocks=layoutEvents([event('09:00','11:00'),event('10:00','12:00'),event('13:00','14:00')], '2026-10-04');
|
||||
assert.deepEqual(blocks.map(b=>[b.column,b.columns]), [[0,2],[1,2],[0,1]]);
|
||||
const overnight=layoutEvents([{start:'2026-10-03T23:00:00-05:00',end:'2026-10-04T02:00:00-05:00',allDay:false}], '2026-10-04');
|
||||
assert.equal(overnight[0].start, 0);assert.equal(overnight[0].end, 2);
|
||||
assert.equal(layoutEvents([event('09:00','10:00')], '2026-10-05').length, 0);
|
||||
console.log('Overlap, midnight clipping, rolling dates and DST checks passed.');
|
||||
const point=layoutEvents([{start:'2026-10-04T09:00:00-05:00',end:'2026-10-04T09:00:00-05:00',point:true,allDay:false}], '2026-10-04');
|
||||
assert.equal(point.length,1);assert.equal(point[0].end,9);assert.equal(point[0].displayEnd,9.25);
|
||||
assert.equal(layoutEvents([{start:'2026-10-03T09:00:00-05:00',end:'2026-10-03T09:00:00-05:00',point:true,allDay:false}], '2026-10-04').length,0);
|
||||
const fold=layoutEvents([{start:'2026-11-01T01:45:00-05:00',end:'2026-11-01T01:15:00-06:00',allDay:false}], '2026-11-01');
|
||||
assert.equal(fold.length,1);assert.equal(fold[0].end-fold[0].start,.5);
|
||||
const {paginateHeights,sourceState}=await import('../static/app.js');
|
||||
assert.deepEqual(paginateHeights([30,60,40,80],100),[[0,1],[2],[3]]);
|
||||
assert.deepEqual(paginateHeights([150,20],100),[[0],[1]]);
|
||||
assert.equal(sourceState({updated:'2000-01-01T00:00:00Z',status:'fresh'},Date.parse('2026-10-04T00:00:00Z')),'stale');
|
||||
assert.equal(sourceState({updated:null,status:'unavailable'},Date.now()),'unavailable');
|
||||
console.log('Point events, repeated DST hour, paging and stale-data checks passed.');
|
||||
|
||||
const {resolveTheme}=await import('../static/app.js');
|
||||
assert.equal(resolveTheme('homeassistant'),'homeassistant');
|
||||
assert.equal(resolveTheme('skylight'),'skylight');
|
||||
assert.equal(resolveTheme(null),'skylight');
|
||||
assert.equal(resolveTheme('<script>'),'skylight');
|
||||
console.log('Theme selection and fallback checks passed.');
|
||||
@@ -0,0 +1,200 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from icalendar import Calendar, Event
|
||||
import recurring_ical_events
|
||||
|
||||
spec = importlib.util.spec_from_file_location('dashboard', Path(__file__).parents[1] / 'app.py')
|
||||
app = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(app)
|
||||
TOKEN = 'test-proxy-credential-that-is-long-enough'
|
||||
CONFIG = {'dav_url': 'https://dav.example/dav', 'dav_user': 'reader', 'dav_password': 'private-dav',
|
||||
'ha_url': 'http://ha', 'ha_access_token': 'private-ha', 'proxy_token': TOKEN}
|
||||
|
||||
|
||||
def request(application, path='/api/dashboard', method='GET', token=TOKEN):
|
||||
result = {}
|
||||
def start_response(status, headers):
|
||||
result['status'] = int(status.split()[0])
|
||||
result['headers'] = dict(headers)
|
||||
result['body'] = b''.join(application({'PATH_INFO': path, 'REQUEST_METHOD': method, 'HTTP_X_DASHBOARD_KEY': token}, start_response))
|
||||
return result
|
||||
|
||||
|
||||
class Events(unittest.TestCase):
|
||||
def event(self, text):
|
||||
return Event.from_ical('BEGIN:VEVENT\r\nUID:test\r\nSUMMARY:Test\r\n' + text + '\r\nEND:VEVENT')
|
||||
|
||||
def test_exclusive_all_day_end(self):
|
||||
e = app.serialize_event(self.event('DTSTART;VALUE=DATE:20261004\r\nDTEND;VALUE=DATE:20261006'), 'Work', '#fff')
|
||||
self.assertTrue(e['allDay'])
|
||||
self.assertEqual((e['start'], e['end']), ('2026-10-04', '2026-10-06'))
|
||||
|
||||
def test_floating_time_and_duration(self):
|
||||
e = app.serialize_event(self.event('DTSTART:20261004T090000\r\nDURATION:PT30M'), 'Work', '#fff')
|
||||
self.assertEqual(e['start'], '2026-10-04T09:00:00-05:00')
|
||||
self.assertEqual(e['end'], '2026-10-04T09:30:00-05:00')
|
||||
|
||||
def test_point_event_preserves_zero_duration(self):
|
||||
e = app.serialize_event(self.event('DTSTART:20261004T090000'), 'Work', '#fff')
|
||||
self.assertEqual(e['start'], e['end'])
|
||||
self.assertTrue(e['point'])
|
||||
|
||||
def test_default_all_day_duration_is_one_day(self):
|
||||
e = app.serialize_event(self.event('DTSTART;VALUE=DATE:20261004'), 'Work', '#fff')
|
||||
self.assertEqual(e['end'], '2026-10-05')
|
||||
|
||||
def test_repeated_dst_hour_is_not_a_negative_interval(self):
|
||||
e = app.serialize_event(self.event('DTSTART:20261101T064500Z\r\nDTEND:20261101T071500Z'), 'Work', '#fff')
|
||||
self.assertEqual(e['start'], '2026-11-01T01:45:00-05:00')
|
||||
self.assertEqual(e['end'], '2026-11-01T01:15:00-06:00')
|
||||
self.assertFalse(e['point'])
|
||||
|
||||
def test_invalid_intervals_are_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
app.serialize_event(self.event('DTSTART:20261004T090000\r\nDTEND:20261004T080000'), 'Work', '#fff')
|
||||
|
||||
def test_cancelled_event_hidden(self):
|
||||
self.assertIsNone(app.serialize_event(self.event('STATUS:CANCELLED\r\nDTSTART:20261004T090000'), 'Work', '#fff'))
|
||||
|
||||
def test_recurrence_exclusion_and_moved_instance(self):
|
||||
calendar = Calendar.from_ical('''BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:recurring
|
||||
SUMMARY:Daily
|
||||
DTSTART:20261004T140000Z
|
||||
DTEND:20261004T150000Z
|
||||
RRULE:FREQ=DAILY;COUNT=4
|
||||
EXDATE:20261005T140000Z
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:recurring
|
||||
RECURRENCE-ID:20261006T140000Z
|
||||
SUMMARY:Moved
|
||||
DTSTART:20261006T160000Z
|
||||
DTEND:20261006T170000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR''')
|
||||
occurrences = recurring_ical_events.of(calendar).between((2026, 10, 4), (2026, 10, 8))
|
||||
events = [app.serialize_event(c, 'Work', '#fff') for c in occurrences]
|
||||
self.assertEqual(len(events), 3)
|
||||
self.assertEqual(next(e for e in events if e['title'] == 'Moved')['start'], '2026-10-06T11:00:00-05:00')
|
||||
self.assertFalse(any(e['start'].startswith('2026-10-05') for e in events))
|
||||
|
||||
|
||||
class Sources(unittest.TestCase):
|
||||
def test_failed_collection_preserves_data_and_does_not_block_another(self):
|
||||
store = app.Store()
|
||||
store.publish('tasks:Inbox', [{'title': 'old'}], now=100)
|
||||
store.fail('tasks:Inbox', 'TimeoutError')
|
||||
store.publish('tasks:Next', [{'title': 'new'}], now=200)
|
||||
data = store.snapshot(now=201)
|
||||
self.assertEqual(data['tasks']['Inbox'][0]['title'], 'old')
|
||||
self.assertEqual(data['tasks']['Next'][0]['title'], 'new')
|
||||
self.assertEqual(data['sources']['tasks:Inbox']['status'], 'error')
|
||||
self.assertEqual(data['sources']['tasks:Next']['status'], 'fresh')
|
||||
|
||||
def test_staleness_is_derived_without_a_worker_error(self):
|
||||
store = app.Store(); store.publish('tasks:Inbox', [], now=100)
|
||||
self.assertEqual(store.snapshot(now=1000)['sources']['tasks:Inbox']['status'], 'fresh')
|
||||
self.assertEqual(store.snapshot(now=1001)['sources']['tasks:Inbox']['status'], 'stale')
|
||||
|
||||
def test_malformed_item_does_not_discard_good_collection_items(self):
|
||||
reader = app.DavReader(CONFIG)
|
||||
good = Mock(); good.icalendar_component = Event.from_ical('BEGIN:VEVENT\nUID:x\nDTSTART:20261004T090000\nSUMMARY:Good\nEND:VEVENT')
|
||||
bad = Mock(); bad.icalendar_component = Event.from_ical('BEGIN:VEVENT\nUID:y\nSUMMARY:No date\nEND:VEVENT')
|
||||
client = MagicMock(); calendar = Mock(); calendar.search.return_value = [good, bad]
|
||||
with patch.object(reader, 'collection_url', return_value='https://dav.example/work'), patch.object(reader, 'client', return_value=client), patch.object(app.caldav, 'Calendar', return_value=calendar):
|
||||
items, invalid = reader.fetch('Work', '#ffffff')
|
||||
self.assertEqual(len(items), 1); self.assertEqual(invalid, 1)
|
||||
self.assertTrue(calendar.search.call_args.kwargs['expand'])
|
||||
|
||||
def test_failure_retry_keeps_last_snapshot(self):
|
||||
store = app.Store(); store.publish('tasks:Inbox', [{'title': 'cached'}], now=100)
|
||||
stop = Mock(); stop.is_set.side_effect = [False, True]
|
||||
with patch.object(app.random, 'uniform', return_value=0):
|
||||
app.refresh_source('tasks:Inbox', Mock(side_effect=TimeoutError), store, stop)
|
||||
self.assertEqual(store.snapshot(now=101)['tasks']['Inbox'][0]['title'], 'cached')
|
||||
self.assertLessEqual(stop.wait.call_args.args[0], 30)
|
||||
|
||||
|
||||
class Routes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.store = app.Store(); self.application = app.Application(CONFIG, self.store)
|
||||
|
||||
def test_direct_access_is_denied_including_non_ascii_header(self):
|
||||
for token in ('', 'wrong', 'é' * 32):
|
||||
response = request(self.application, token=token)
|
||||
self.assertEqual(response['status'], 403)
|
||||
self.assertNotIn(b'events', response['body'])
|
||||
|
||||
def test_only_read_methods_and_fixed_routes(self):
|
||||
self.assertEqual(request(self.application, method='POST')['status'], 405)
|
||||
self.assertEqual(request(self.application, '/../app.py')['status'], 404)
|
||||
self.assertEqual(request(self.application, '/run/secrets/dashboard.json')['status'], 404)
|
||||
head = request(self.application, '/app.js', method='HEAD')
|
||||
self.assertEqual(head['status'], 200); self.assertEqual(head['body'], b'')
|
||||
self.assertGreater(int(head['headers']['Content-Length']), 0)
|
||||
|
||||
def test_snapshot_and_assets_never_contain_credentials(self):
|
||||
for path in ('/api/dashboard', '/', '/app.js', '/style.css'):
|
||||
body = request(self.application, path)['body']
|
||||
for value in ('private-ha', 'private-dav', TOKEN):
|
||||
self.assertNotIn(value.encode(), body)
|
||||
|
||||
def test_liveness_remains_up_and_readiness_rejects_stale_data(self):
|
||||
self.assertEqual(request(self.application, '/health/live', token='')['status'], 200)
|
||||
for key in app.REQUIRED:
|
||||
self.store.publish(key, {'forecast': [], 'unit': '°F'} if key == 'weather:hourly' else [], now=100)
|
||||
with patch.object(app.time, 'time', return_value=200):
|
||||
self.assertEqual(request(self.application, '/health')['status'], 200)
|
||||
with patch.object(app.time, 'time', return_value=1001):
|
||||
self.assertEqual(request(self.application, '/health')['status'], 503)
|
||||
self.assertEqual(request(self.application, '/health/live')['status'], 200)
|
||||
|
||||
def test_asset_version_in_html_matches_snapshot(self):
|
||||
self.assertIn(app.VERSION.encode(), request(self.application, '/')['body'])
|
||||
self.assertEqual(json.loads(request(self.application)['body'])['version'], app.VERSION)
|
||||
|
||||
def test_config_rejects_old_browser_session_and_missing_proxy_key(self):
|
||||
app.validate_config(CONFIG)
|
||||
old = dict(CONFIG); old.pop('ha_access_token'); old['ha_refresh_token'] = 'borrowed'
|
||||
with self.assertRaises(ValueError): app.validate_config(old)
|
||||
missing = dict(CONFIG); missing.pop('proxy_token')
|
||||
with self.assertRaises(ValueError): app.validate_config(missing)
|
||||
|
||||
|
||||
class Weather(unittest.TestCase):
|
||||
def socket(self):
|
||||
socket = Mock()
|
||||
socket.recv.side_effect = [json.dumps({'type': 'auth_required'}), json.dumps({'type': 'auth_ok'}),
|
||||
json.dumps({'id': 1, 'type': 'result', 'success': True}),
|
||||
json.dumps({'id': 99, 'type': 'event', 'event': {'forecast': []}}),
|
||||
json.dumps({'id': 1, 'type': 'event', 'event': {'forecast': [{'datetime': '2026-10-04T19:00:00-05:00', 'temperature': 70}]}})]
|
||||
return socket
|
||||
|
||||
def test_hourly_is_independent_of_daily_and_uses_only_read_requests(self):
|
||||
socket = self.socket(); response = MagicMock(); response.__enter__.return_value = response
|
||||
response.json.return_value = {'state': 'sunny', 'attributes': {'temperature_unit': '°F'}}
|
||||
with patch.object(app.requests, 'post') as post, patch.object(app.requests, 'get', return_value=response), patch.object(app.websocket, 'create_connection', return_value=socket):
|
||||
result, invalid = app.fetch_weather(CONFIG, 'hourly')
|
||||
post.assert_not_called(); self.assertEqual(len(result['forecast']), 1); self.assertEqual(invalid, 0)
|
||||
messages = [json.loads(call.args[0]) for call in socket.send.call_args_list]
|
||||
self.assertEqual([m['type'] for m in messages], ['auth', 'weather/subscribe_forecast'])
|
||||
self.assertEqual(messages[-1]['forecast_type'], 'hourly')
|
||||
socket.close.assert_called_once()
|
||||
|
||||
def test_daily_does_not_depend_on_hourly_state_endpoint(self):
|
||||
with patch.object(app.requests, 'get') as get, patch.object(app.websocket, 'create_connection', return_value=self.socket()):
|
||||
result, invalid = app.fetch_weather(CONFIG, 'daily')
|
||||
get.assert_not_called(); self.assertEqual(len(result), 1)
|
||||
|
||||
def test_absolute_deadline_cannot_be_extended_by_unrelated_messages(self):
|
||||
socket = Mock()
|
||||
with patch.object(app.time, 'monotonic', return_value=31):
|
||||
with self.assertRaises(TimeoutError): app.receive(socket, deadline=30)
|
||||
socket.recv.assert_not_called()
|
||||
Reference in new issue
Block a user