About to push
This commit is contained in:
@@ -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/<device_id>`.
|
||||
- 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.
|
||||
+26
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{% for device in devices %}
|
||||
{% set reading = latest.get(device.id) %}
|
||||
{% set stat = stats.get(device.id) %}
|
||||
<article class="metric-card">
|
||||
<a class="metric-card metric-link" href="/devices/{{ device.id }}">
|
||||
<div class="card-heading">
|
||||
<h2>{{ device.name }}</h2>
|
||||
<span>{{ device.device_type }}</span>
|
||||
@@ -44,7 +44,7 @@
|
||||
{% else %}
|
||||
<p class="empty">Waiting for the first reading.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ device.name }} - SwitchBot Temps{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Device readings</p>
|
||||
<h1>{{ device.name }}</h1>
|
||||
<p class="muted">{{ device.device_type }}. Local timezone: {{ timezone }}.</p>
|
||||
</div>
|
||||
<a class="button secondary" href="/">Back to dashboard</a>
|
||||
</section>
|
||||
|
||||
<form class="report-form compact" method="get" action="/devices/{{ device.id }}">
|
||||
<label>
|
||||
Day
|
||||
<input type="date" name="date" value="{{ date }}">
|
||||
</label>
|
||||
<button class="button" type="submit">View day</button>
|
||||
</form>
|
||||
|
||||
<section class="cards device-summary">
|
||||
<article class="metric-card">
|
||||
<h2>Samples</h2>
|
||||
<div class="reading-row">
|
||||
<strong>{{ readings|length }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Low temp</h2>
|
||||
<div class="reading-row">
|
||||
<strong>{{ "%.1f"|format(stats.low) if stats else "n/a" }}°C</strong>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>High temp</h2>
|
||||
<div class="reading-row">
|
||||
<strong>{{ "%.1f"|format(stats.high) if stats else "n/a" }}°C</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Day graph</h2>
|
||||
<p class="muted">Temperature readings for {{ date }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="temperatureChart" height="360"></canvas>
|
||||
</div>
|
||||
<script id="chart-data" type="application/json">{{ chart_json|safe }}</script>
|
||||
<script src="{{ url_for('static', filename='chart.js') }}"></script>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Readings</h2>
|
||||
<p class="muted">Timestamped readings for this device and day.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Temperature</th>
|
||||
<th>Humidity</th>
|
||||
<th>Battery</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reading in local_readings %}
|
||||
<tr>
|
||||
<td>{{ reading.timestamp }}</td>
|
||||
<td>{{ "%.1f"|format(reading.temperature) if reading.temperature is not none else "n/a" }}°C</td>
|
||||
<td>{{ reading.humidity if reading.humidity is not none else "n/a" }}%</td>
|
||||
<td>{{ reading.battery if reading.battery is not none else "n/a" }}%</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="empty">No readings found for this day.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Not found - SwitchBot Temps{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="empty-state">
|
||||
<h1>Device not found</h1>
|
||||
<p class="muted">That device is not available as a temperature sensor.</p>
|
||||
<a class="button" href="/">Back to dashboard</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
+58
@@ -49,6 +49,30 @@ def create_app() -> Flask:
|
||||
collect_interval_seconds=config.collect_interval_seconds,
|
||||
)
|
||||
|
||||
@app.get("/devices/<device_id>")
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user