From 9607eac2249b0799b11ee02fb0d4c719b3f046de Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 1 Jun 2026 21:08:01 +0100 Subject: [PATCH] About to push --- PROJECT_CONTEXT.md | 130 +++++++++++++++++++++++++++++++++++ app/static/styles.css | 27 +++++++- app/templates/dashboard.html | 4 +- app/templates/device.html | 92 +++++++++++++++++++++++++ app/templates/not_found.html | 11 +++ app/web.py | 58 ++++++++++++++++ 6 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 PROJECT_CONTEXT.md create mode 100644 app/templates/device.html create mode 100644 app/templates/not_found.html diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md new file mode 100644 index 0000000..d9e9fc1 --- /dev/null +++ b/PROJECT_CONTEXT.md @@ -0,0 +1,130 @@ +# Project Context + +This project is a Dockerised SwitchBot temperature dashboard built from an +initial SwitchBot API v1.1 Python proof of concept. + +## Current Shape + +- `docker-compose.yml` starts three services: + - `db`: MySQL 8.4 with the `mysql_data` volume. + - `web`: Flask app served by Gunicorn on port 8000. + - `collector`: Python polling loop. +- `app/switchbot.py` contains the SwitchBot v1.1 signed request client. +- `app/collector.py` syncs devices from SwitchBot and records sensor readings. +- `app/web.py` serves the dashboard, reports, CSV export, and per-device views. +- `switchbot_poc.py` remains as a low-level API sanity-check script. + +## Configuration + +Runtime config is provided by `.env`, copied from `.env.example`. + +Required values: + +```env +SWITCHBOT_TOKEN=... +SWITCHBOT_SECRET=... +FLASK_SECRET_KEY=... +``` + +Important: do not commit `.env`; it is intentionally ignored. + +The default polling interval is: + +```env +COLLECT_INTERVAL_SECONDS=900 +``` + +The default timezone is: + +```env +APP_TIMEZONE=Europe/London +``` + +## SwitchBot Notes + +The SwitchBot API only exposes devices owned by the account that generated the +token/secret. Devices merely shared with the account do not appear in +`/devices`. + +Working discovered devices were: + +- `CB11A9610CFB`: `Hub Mini FB`, `Hub Mini2` +- `EE2E01862061`: `Meds Cupboard`, `WoIOSensor` +- `EE2E0446360E`: `Fridge Med`, `WoIOSensor` +- `EE2E05C6434C`: `Main Room`, `WoIOSensor` + +`Hub Mini2` is treated as infrastructure. It is stored in the database when +discovered, but hidden from the dashboard/reports and skipped by the collector +for readings. + +Current sensor allow-list: + +```python +SENSOR_DEVICE_TYPES = {"WoIOSensor"} +``` + +## Features Implemented + +- Responsive dashboard at `/`. +- Sensor cards show latest reading, day low, day high, and battery. +- Hub devices are suppressed from the dashboard. +- Day-so-far graph rendered with local canvas JavaScript, no CDN. +- Clicking a sensor card opens `/devices/`. +- Per-device page shows: + - selected day + - samples + - low/high temperature + - graph for that device/day + - timestamped readings table +- Reports at `/reports`. +- CSV export at `/reports.csv`. + +## Common Commands + +Start or restart: + +```sh +docker compose up --build -d +``` + +Follow collector logs: + +```sh +docker compose logs -f collector +``` + +Check row counts: + +```sh +docker compose exec -T db mysql -uswitchbot -pswitchbot_password switchbot \ + -e "select count(*) as devices from devices; select count(*) as readings from readings;" +``` + +Run the original POC: + +```sh +python3 switchbot_poc.py +python3 switchbot_poc.py --endpoint /devices/EE2E01862061/status +``` + +## Verification Performed + +- Python syntax checks pass for the app modules. +- Docker build succeeded. +- MySQL became healthy. +- Web app responded with `HTTP 200 OK`. +- Reports and CSV endpoints responded with `HTTP 200 OK`. +- Collector successfully recorded readings for the three `WoIOSensor` devices. +- Browser verification confirmed: + - dashboard shows only the three sensors + - Hub Mini is hidden + - clicking a tile opens the device/day readings page + - timestamped readings display in local time + +## Likely Next Improvements + +- Add migrations instead of using `Base.metadata.create_all`. +- Add a manual "collect now" endpoint/button. +- Add CSV export for a single device/day readings table. +- Add alert thresholds for fridge/medicine storage temperatures. +- Add authentication if exposed beyond a trusted local network. diff --git a/app/static/styles.css b/app/static/styles.css index de1d9ba..5632f4b 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -151,6 +151,21 @@ h2 { padding: 16px; } +.metric-link { + color: inherit; + text-decoration: none; + transition: border-color 0.15s ease, transform 0.15s ease; +} + +.metric-link:hover { + border-color: var(--brand); + transform: translateY(-1px); +} + +.device-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .card-heading { display: flex; align-items: start; @@ -243,6 +258,10 @@ h2 { margin-bottom: 18px; } +.report-form.compact { + grid-template-columns: minmax(180px, 240px) auto; +} + .report-form label { display: grid; gap: 6px; @@ -293,6 +312,10 @@ th { .report-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .device-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } @media (max-width: 620px) { @@ -313,7 +336,9 @@ th { } .cards, - .report-form { + .report-form, + .report-form.compact, + .device-summary { grid-template-columns: 1fr; } diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index f6fb4ba..4f69c63 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -17,7 +17,7 @@ {% for device in devices %} {% set reading = latest.get(device.id) %} {% set stat = stats.get(device.id) %} - + {% endfor %} diff --git a/app/templates/device.html b/app/templates/device.html new file mode 100644 index 0000000..b7aed8e --- /dev/null +++ b/app/templates/device.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block title %}{{ device.name }} - SwitchBot Temps{% endblock %} + +{% block content %} +
+
+

Device readings

+

{{ device.name }}

+

{{ device.device_type }}. Local timezone: {{ timezone }}.

+
+ Back to dashboard +
+ +
+ + +
+ +
+
+

Samples

+
+ {{ readings|length }} +
+
+
+

Low temp

+
+ {{ "%.1f"|format(stats.low) if stats else "n/a" }}°C +
+
+
+

High temp

+
+ {{ "%.1f"|format(stats.high) if stats else "n/a" }}°C +
+
+
+ +
+
+
+

Day graph

+

Temperature readings for {{ date }}.

+
+
+
+ +
+ + +
+ +
+
+
+

Readings

+

Timestamped readings for this device and day.

+
+
+
+ + + + + + + + + + + {% for reading in local_readings %} + + + + + + + {% else %} + + + + {% endfor %} + +
TimestampTemperatureHumidityBattery
{{ reading.timestamp }}{{ "%.1f"|format(reading.temperature) if reading.temperature is not none else "n/a" }}°C{{ reading.humidity if reading.humidity is not none else "n/a" }}%{{ reading.battery if reading.battery is not none else "n/a" }}%
No readings found for this day.
+
+
+{% endblock %} diff --git a/app/templates/not_found.html b/app/templates/not_found.html new file mode 100644 index 0000000..3c896ed --- /dev/null +++ b/app/templates/not_found.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Not found - SwitchBot Temps{% endblock %} + +{% block content %} +
+

Device not found

+

That device is not available as a temperature sensor.

+ Back to dashboard +
+{% endblock %} diff --git a/app/web.py b/app/web.py index 949e58b..4335c99 100644 --- a/app/web.py +++ b/app/web.py @@ -49,6 +49,30 @@ def create_app() -> Flask: collect_interval_seconds=config.collect_interval_seconds, ) + @app.get("/devices/") + def device_detail(device_id: str) -> str: + tz = ZoneInfo(config.app_timezone) + selected_date_text = request.args.get("date") or datetime.now(tz).date().isoformat() + selected_date = date.fromisoformat(selected_date_text) + start_utc, end_utc = local_date_range_to_utc(selected_date, selected_date, tz) + + with SessionLocal() as session: + device = session.get(Device, device_id) + if device is None or device.device_type not in SENSOR_DEVICE_TYPES: + return render_template("not_found.html"), 404 + readings = load_device_readings(session, device_id, start_utc, end_utc) + + return render_template( + "device.html", + device=device, + date=selected_date_text, + readings=readings, + stats=stats_by_device(readings).get(device_id), + chart_json=json.dumps(chart_payload(readings, tz)), + timezone=config.app_timezone, + local_readings=readings_for_display(readings, tz), + ) + @app.get("/reports") def reports() -> str: tz = ZoneInfo(config.app_timezone) @@ -147,6 +171,25 @@ def load_readings(session: Session, start_utc: datetime, end_utc: datetime) -> l return list(session.scalars(stmt)) +def load_device_readings( + session: Session, + device_id: str, + start_utc: datetime, + end_utc: datetime, +) -> list[Reading]: + stmt = ( + select(Reading) + .options(joinedload(Reading.device)) + .where( + Reading.device_id == device_id, + Reading.recorded_at >= start_utc, + Reading.recorded_at < end_utc, + ) + .order_by(Reading.recorded_at) + ) + return list(session.scalars(stmt)) + + def latest_by_device(session: Session) -> dict[str, Reading]: subquery = ( select(Reading.device_id, func.max(Reading.recorded_at).label("latest_at")) @@ -204,6 +247,21 @@ def chart_payload(readings: list[Reading], tz: ZoneInfo) -> list[dict[str, objec return list(series.values()) +def readings_for_display(readings: list[Reading], tz: ZoneInfo) -> list[dict[str, object]]: + rows = [] + for reading in readings: + local_time = reading.recorded_at.replace(tzinfo=timezone.utc).astimezone(tz) + rows.append( + { + "timestamp": local_time.strftime("%Y-%m-%d %H:%M"), + "temperature": reading.temperature, + "humidity": reading.humidity, + "battery": reading.battery, + } + ) + return rows + + def report_rows( session: Session, start_utc: datetime,