Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""SwitchBot temperature dashboard."""
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import config
|
||||
from app.db import init_db, session_scope
|
||||
from app.models import Device, Reading
|
||||
from app.switchbot import SwitchBotClient, SwitchBotError
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_DEVICE_TYPES = {"WoIOSensor"}
|
||||
|
||||
|
||||
def upsert_device(device_data: dict[str, Any]) -> None:
|
||||
device_id = device_data["deviceId"]
|
||||
with session_scope() as session:
|
||||
device = session.get(Device, device_id)
|
||||
if device is None:
|
||||
device = Device(id=device_id, name=device_data.get("deviceName", device_id), device_type="")
|
||||
session.add(device)
|
||||
|
||||
device.name = device_data.get("deviceName", device_id)
|
||||
device.device_type = device_data.get("deviceType", "")
|
||||
device.enable_cloud_service = bool(device_data.get("enableCloudService"))
|
||||
device.hub_device_id = device_data.get("hubDeviceId")
|
||||
device.last_seen_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def sync_devices(client: SwitchBotClient) -> list[Device]:
|
||||
logger.info("Syncing SwitchBot device list")
|
||||
devices = client.devices()
|
||||
logger.info("SwitchBot returned %s devices", len(devices))
|
||||
for device_data in devices:
|
||||
upsert_device(device_data)
|
||||
|
||||
with session_scope() as session:
|
||||
return list(session.scalars(select(Device).order_by(Device.name)))
|
||||
|
||||
|
||||
def save_reading(device: Device, status: dict[str, Any]) -> None:
|
||||
if "temperature" not in status and "humidity" not in status:
|
||||
logger.info("Skipping %s (%s): status has no temperature/humidity", device.name, device.id)
|
||||
return
|
||||
|
||||
reading = Reading(
|
||||
device_id=device.id,
|
||||
recorded_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
temperature=status.get("temperature"),
|
||||
humidity=status.get("humidity"),
|
||||
battery=status.get("battery"),
|
||||
version=status.get("version"),
|
||||
)
|
||||
with session_scope() as session:
|
||||
session.add(reading)
|
||||
|
||||
logger.info(
|
||||
"Recorded %s: temp=%s humidity=%s battery=%s",
|
||||
device.name,
|
||||
reading.temperature,
|
||||
reading.humidity,
|
||||
reading.battery,
|
||||
)
|
||||
|
||||
|
||||
def collect_once(client: SwitchBotClient) -> None:
|
||||
logger.info("Starting collection cycle")
|
||||
devices = sync_devices(client)
|
||||
for device in devices:
|
||||
if device.device_type not in SENSOR_DEVICE_TYPES:
|
||||
logger.info("Skipping %s (%s): %s is not a sensor", device.name, device.id, device.device_type)
|
||||
continue
|
||||
if not device.enable_cloud_service:
|
||||
logger.info("Skipping %s (%s): cloud service disabled", device.name, device.id)
|
||||
continue
|
||||
try:
|
||||
status = client.status(device.id)
|
||||
except SwitchBotError as exc:
|
||||
logger.warning("Could not read %s (%s): %s", device.name, device.id, exc)
|
||||
continue
|
||||
save_reading(device, status)
|
||||
logger.info("Collection cycle complete")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not config.switchbot_token or not config.switchbot_secret:
|
||||
logger.error("SWITCHBOT_TOKEN and SWITCHBOT_SECRET are required")
|
||||
return 2
|
||||
|
||||
init_db()
|
||||
client = SwitchBotClient(config.switchbot_token, config.switchbot_secret)
|
||||
interval = max(60, config.collect_interval_seconds)
|
||||
logger.info("Collector started with %s second interval", interval)
|
||||
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
collect_once(client)
|
||||
except Exception:
|
||||
logger.exception("Collector cycle failed")
|
||||
elapsed = time.monotonic() - started
|
||||
time.sleep(max(1, interval - elapsed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
database_url: str = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"mysql+pymysql://switchbot:switchbot_password@localhost:3306/switchbot",
|
||||
)
|
||||
switchbot_token: str | None = os.getenv("SWITCHBOT_TOKEN")
|
||||
switchbot_secret: str | None = os.getenv("SWITCHBOT_SECRET")
|
||||
collect_interval_seconds: int = int(os.getenv("COLLECT_INTERVAL_SECONDS", "900"))
|
||||
app_timezone: str = os.getenv("APP_TIMEZONE", "Europe/London")
|
||||
flask_secret_key: str = os.getenv("FLASK_SECRET_KEY", "dev-only-secret")
|
||||
|
||||
|
||||
config = Config()
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import config
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(config.database_url, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
from app import models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
device_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
enable_cloud_service: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
hub_device_id: Mapped[str | None] = mapped_column(String(64))
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
readings: Mapped[list["Reading"]] = relationship(back_populates="device")
|
||||
|
||||
|
||||
class Reading(Base):
|
||||
__tablename__ = "readings"
|
||||
__table_args__ = (
|
||||
Index("ix_readings_device_recorded", "device_id", "recorded_at"),
|
||||
Index("ix_readings_recorded", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[str] = mapped_column(ForeignKey("devices.id"), nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
temperature: Mapped[float | None] = mapped_column(Float)
|
||||
humidity: Mapped[float | None] = mapped_column(Float)
|
||||
battery: Mapped[int | None] = mapped_column(Integer)
|
||||
version: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
device: Mapped[Device] = relationship(back_populates="readings")
|
||||
@@ -0,0 +1,110 @@
|
||||
(function () {
|
||||
const dataEl = document.getElementById("chart-data");
|
||||
const canvas = document.getElementById("temperatureChart");
|
||||
if (!dataEl || !canvas) return;
|
||||
|
||||
const series = JSON.parse(dataEl.textContent || "[]");
|
||||
const colors = ["#166b5b", "#cf5a24", "#355c9a", "#7a4e9f", "#9a6235"];
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
function resizeCanvas() {
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.max(320, Math.floor(rect.width * ratio));
|
||||
canvas.height = Math.floor(360 * ratio);
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
resizeCanvas();
|
||||
const width = canvas.clientWidth;
|
||||
const height = 360;
|
||||
const pad = { top: 24, right: 24, bottom: 44, left: 54 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const points = series.flatMap((item) =>
|
||||
item.points.map((point) => ({
|
||||
...point,
|
||||
date: new Date(point.time),
|
||||
}))
|
||||
);
|
||||
|
||||
if (!points.length) {
|
||||
ctx.fillStyle = "#64706b";
|
||||
ctx.font = "16px system-ui, sans-serif";
|
||||
ctx.fillText("Waiting for readings for today.", pad.left, pad.top + 40);
|
||||
return;
|
||||
}
|
||||
|
||||
const minTime = Math.min(...points.map((point) => point.date.getTime()));
|
||||
const maxTime = Math.max(...points.map((point) => point.date.getTime()));
|
||||
const temps = points.map((point) => Number(point.temperature));
|
||||
const minTemp = Math.floor(Math.min(...temps) - 1);
|
||||
const maxTemp = Math.ceil(Math.max(...temps) + 1);
|
||||
const timeSpan = Math.max(1, maxTime - minTime);
|
||||
const tempSpan = Math.max(1, maxTemp - minTemp);
|
||||
|
||||
function xFor(date) {
|
||||
return pad.left + ((date.getTime() - minTime) / timeSpan) * plotW;
|
||||
}
|
||||
|
||||
function yFor(temp) {
|
||||
return pad.top + plotH - ((temp - minTemp) / tempSpan) * plotH;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "#dfe5df";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = "#64706b";
|
||||
ctx.font = "12px system-ui, sans-serif";
|
||||
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const y = pad.top + (plotH / 4) * i;
|
||||
const temp = maxTemp - (tempSpan / 4) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, y);
|
||||
ctx.lineTo(width - pad.right, y);
|
||||
ctx.stroke();
|
||||
ctx.fillText(`${temp.toFixed(1)}C`, 8, y + 4);
|
||||
}
|
||||
|
||||
series.forEach((item, index) => {
|
||||
const color = colors[index % colors.length];
|
||||
const sorted = item.points
|
||||
.map((point) => ({ ...point, date: new Date(point.time) }))
|
||||
.sort((a, b) => a.date - b.date);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
sorted.forEach((point, pointIndex) => {
|
||||
const x = xFor(point.date);
|
||||
const y = yFor(Number(point.temperature));
|
||||
if (pointIndex === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
sorted.forEach((point) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(xFor(point.date), yFor(Number(point.temperature)), 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
});
|
||||
|
||||
const legendY = height - 18;
|
||||
series.forEach((item, index) => {
|
||||
const x = pad.left + index * 150;
|
||||
ctx.fillStyle = colors[index % colors.length];
|
||||
ctx.fillRect(x, legendY - 9, 10, 10);
|
||||
ctx.fillStyle = "#1d2522";
|
||||
ctx.font = "12px system-ui, sans-serif";
|
||||
ctx.fillText(item.name, x + 16, legendY);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("resize", draw);
|
||||
draw();
|
||||
})();
|
||||
@@ -0,0 +1,327 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f4;
|
||||
--panel: #ffffff;
|
||||
--ink: #1d2522;
|
||||
--muted: #64706b;
|
||||
--line: #dfe5df;
|
||||
--brand: #166b5b;
|
||||
--brand-strong: #0d4d41;
|
||||
--accent: #cf5a24;
|
||||
--shadow: 0 10px 30px rgba(22, 37, 32, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px clamp(16px, 4vw, 44px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(246, 247, 244, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav a[aria-current="page"] {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 6px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.panel,
|
||||
.empty-state {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card-heading span {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
background: #edf3ef;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reading-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.reading-row strong {
|
||||
font-size: 2.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.reading-row span {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mini-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mini-stats dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mini-stats dd {
|
||||
margin: 2px 0 0;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
#temperatureChart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, auto));
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.report-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.topbar,
|
||||
.hero,
|
||||
.panel-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cards,
|
||||
.report-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reading-row strong {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_URL = "https://api.switch-bot.com/v1.1"
|
||||
|
||||
|
||||
class SwitchBotError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SwitchBotClient:
|
||||
def __init__(self, token: str, secret: str) -> None:
|
||||
self.token = token
|
||||
self.secret = secret
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
timestamp_ms = str(int(time.time() * 1000))
|
||||
nonce = str(uuid.uuid4())
|
||||
message = f"{self.token}{timestamp_ms}{nonce}".encode("utf-8")
|
||||
digest = hmac.new(self.secret.encode("utf-8"), message, hashlib.sha256).digest()
|
||||
signature = base64.b64encode(digest).decode("utf-8")
|
||||
return {
|
||||
"Authorization": self.token,
|
||||
"Content-Type": "application/json; charset=utf8",
|
||||
"sign": signature,
|
||||
"t": timestamp_ms,
|
||||
"nonce": nonce,
|
||||
}
|
||||
|
||||
def get(self, path: str) -> dict[str, Any]:
|
||||
endpoint = path if path.startswith("/") else f"/{path}"
|
||||
request = urllib.request.Request(
|
||||
f"{BASE_URL}{endpoint}",
|
||||
headers=self._headers(),
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
details = exc.read().decode("utf-8", errors="replace")
|
||||
raise SwitchBotError(f"HTTP {exc.code}: {details}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SwitchBotError(f"Could not reach SwitchBot API: {exc.reason}") from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SwitchBotError(f"SwitchBot returned invalid JSON: {body}") from exc
|
||||
|
||||
if payload.get("statusCode") != 100:
|
||||
raise SwitchBotError(f"SwitchBot API error: {payload}")
|
||||
return payload
|
||||
|
||||
def devices(self) -> list[dict[str, Any]]:
|
||||
payload = self.get("/devices")
|
||||
body = payload.get("body", {})
|
||||
return body.get("deviceList", [])
|
||||
|
||||
def status(self, device_id: str) -> dict[str, Any]:
|
||||
payload = self.get(f"/devices/{device_id}/status")
|
||||
return payload.get("body", {})
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}SwitchBot Temps{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">SwitchBot Temps</a>
|
||||
<nav class="nav">
|
||||
<a href="/" {% if request.path == "/" %}aria-current="page"{% endif %}>Dashboard</a>
|
||||
<a href="/reports" {% if request.path == "/reports" %}aria-current="page"{% endif %}>Reports</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="page">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - SwitchBot Temps{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Today so far</p>
|
||||
<h1>Temperature dashboard</h1>
|
||||
<p class="muted">Local timezone: {{ timezone }}. Collector interval: {{ collect_interval_seconds // 60 }} min.</p>
|
||||
</div>
|
||||
<a class="button" href="/reports">Make report</a>
|
||||
</section>
|
||||
|
||||
{% if devices %}
|
||||
<section class="cards">
|
||||
{% for device in devices %}
|
||||
{% set reading = latest.get(device.id) %}
|
||||
{% set stat = stats.get(device.id) %}
|
||||
<article class="metric-card">
|
||||
<div class="card-heading">
|
||||
<h2>{{ device.name }}</h2>
|
||||
<span>{{ device.device_type }}</span>
|
||||
</div>
|
||||
{% if reading %}
|
||||
<div class="reading-row">
|
||||
<strong>{{ "%.1f"|format(reading.temperature) if reading.temperature is not none else "n/a" }}°C</strong>
|
||||
<span>{{ reading.humidity if reading.humidity is not none else "n/a" }}% RH</span>
|
||||
</div>
|
||||
<dl class="mini-stats">
|
||||
<div>
|
||||
<dt>Day low</dt>
|
||||
<dd>{{ "%.1f"|format(stat.low) if stat else "n/a" }}°C</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Day high</dt>
|
||||
<dd>{{ "%.1f"|format(stat.high) if stat else "n/a" }}°C</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Battery</dt>
|
||||
<dd>{{ reading.battery if reading.battery is not none else "n/a" }}%</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% else %}
|
||||
<p class="empty">Waiting for the first reading.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Day graph</h2>
|
||||
<p class="muted">Temperature readings from midnight to now.</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>
|
||||
{% else %}
|
||||
<section class="empty-state">
|
||||
<h2>No devices yet</h2>
|
||||
<p>Start the collector and it will populate device names from the SwitchBot API automatically.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reports - SwitchBot Temps{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Reports</p>
|
||||
<h1>Build a temperature report</h1>
|
||||
<p class="muted">Choose a date range and export the summary as CSV.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="report-form" method="get" action="/reports">
|
||||
<label>
|
||||
Start
|
||||
<input type="date" name="start" value="{{ start }}">
|
||||
</label>
|
||||
<label>
|
||||
End
|
||||
<input type="date" name="end" value="{{ end }}">
|
||||
</label>
|
||||
<label>
|
||||
Device
|
||||
<select name="device_id">
|
||||
<option value="">All devices</option>
|
||||
{% for device in devices %}
|
||||
<option value="{{ device.id }}" {% if device.id == device_id %}selected{% endif %}>{{ device.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Run report</button>
|
||||
<a class="button secondary" href="/reports.csv?start={{ start }}&end={{ end }}&device_id={{ device_id }}">Download CSV</a>
|
||||
</form>
|
||||
|
||||
<section class="panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Samples</th>
|
||||
<th>Low temp</th>
|
||||
<th>High temp</th>
|
||||
<th>Avg temp</th>
|
||||
<th>Low RH</th>
|
||||
<th>High RH</th>
|
||||
<th>Avg RH</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row.device_name }}</td>
|
||||
<td>{{ row.samples }}</td>
|
||||
<td>{{ row.low_temp if row.low_temp is not none else "n/a" }}°C</td>
|
||||
<td>{{ row.high_temp if row.high_temp is not none else "n/a" }}°C</td>
|
||||
<td>{{ row.avg_temp if row.avg_temp is not none else "n/a" }}°C</td>
|
||||
<td>{{ row.low_humidity if row.low_humidity is not none else "n/a" }}%</td>
|
||||
<td>{{ row.high_humidity if row.high_humidity is not none else "n/a" }}%</td>
|
||||
<td>{{ row.avg_humidity if row.avg_humidity is not none else "n/a" }}%</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="empty">No readings found for this range.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import Flask, Response, render_template, request
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.config import config
|
||||
from app.db import SessionLocal, init_db
|
||||
from app.models import Device, Reading
|
||||
|
||||
SENSOR_DEVICE_TYPES = {"WoIOSensor"}
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.secret_key = config.flask_secret_key
|
||||
init_db()
|
||||
|
||||
@app.get("/")
|
||||
def dashboard() -> str:
|
||||
tz = ZoneInfo(config.app_timezone)
|
||||
today = datetime.now(tz).date()
|
||||
start_utc, end_utc = local_date_range_to_utc(today, today, tz)
|
||||
|
||||
with SessionLocal() as session:
|
||||
devices = list(
|
||||
session.scalars(
|
||||
select(Device)
|
||||
.where(Device.device_type.in_(SENSOR_DEVICE_TYPES))
|
||||
.order_by(Device.name)
|
||||
)
|
||||
)
|
||||
readings = load_readings(session, start_utc, end_utc)
|
||||
latest = latest_by_device(session)
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
devices=devices,
|
||||
latest=latest,
|
||||
stats=stats_by_device(readings),
|
||||
chart_json=json.dumps(chart_payload(readings, tz)),
|
||||
timezone=config.app_timezone,
|
||||
collect_interval_seconds=config.collect_interval_seconds,
|
||||
)
|
||||
|
||||
@app.get("/reports")
|
||||
def reports() -> str:
|
||||
tz = ZoneInfo(config.app_timezone)
|
||||
start_text = request.args.get("start") or datetime.now(tz).date().isoformat()
|
||||
end_text = request.args.get("end") or start_text
|
||||
device_id = request.args.get("device_id") or ""
|
||||
|
||||
start_date = date.fromisoformat(start_text)
|
||||
end_date = date.fromisoformat(end_text)
|
||||
start_utc, end_utc = local_date_range_to_utc(start_date, end_date, tz)
|
||||
|
||||
with SessionLocal() as session:
|
||||
devices = list(
|
||||
session.scalars(
|
||||
select(Device)
|
||||
.where(Device.device_type.in_(SENSOR_DEVICE_TYPES))
|
||||
.order_by(Device.name)
|
||||
)
|
||||
)
|
||||
rows = report_rows(session, start_utc, end_utc, device_id)
|
||||
|
||||
return render_template(
|
||||
"reports.html",
|
||||
devices=devices,
|
||||
rows=rows,
|
||||
start=start_text,
|
||||
end=end_text,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
@app.get("/reports.csv")
|
||||
def reports_csv() -> Response:
|
||||
tz = ZoneInfo(config.app_timezone)
|
||||
start_date = date.fromisoformat(request.args.get("start") or datetime.now(tz).date().isoformat())
|
||||
end_date = date.fromisoformat(request.args.get("end") or start_date.isoformat())
|
||||
device_id = request.args.get("device_id") or ""
|
||||
start_utc, end_utc = local_date_range_to_utc(start_date, end_date, tz)
|
||||
|
||||
with SessionLocal() as session:
|
||||
rows = report_rows(session, start_utc, end_utc, device_id)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"device",
|
||||
"samples",
|
||||
"low_temp",
|
||||
"high_temp",
|
||||
"avg_temp",
|
||||
"low_humidity",
|
||||
"high_humidity",
|
||||
"avg_humidity",
|
||||
]
|
||||
)
|
||||
for row in rows:
|
||||
writer.writerow(
|
||||
[
|
||||
row["device_name"],
|
||||
row["samples"],
|
||||
row["low_temp"],
|
||||
row["high_temp"],
|
||||
row["avg_temp"],
|
||||
row["low_humidity"],
|
||||
row["high_humidity"],
|
||||
row["avg_humidity"],
|
||||
]
|
||||
)
|
||||
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=switchbot-report.csv"},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def local_date_range_to_utc(start: date, end: date, tz: ZoneInfo) -> tuple[datetime, datetime]:
|
||||
local_start = datetime.combine(start, time.min, tzinfo=tz)
|
||||
local_end = datetime.combine(end + timedelta(days=1), time.min, tzinfo=tz)
|
||||
return (
|
||||
local_start.astimezone(timezone.utc).replace(tzinfo=None),
|
||||
local_end.astimezone(timezone.utc).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
|
||||
def load_readings(session: Session, start_utc: datetime, end_utc: datetime) -> list[Reading]:
|
||||
stmt = (
|
||||
select(Reading)
|
||||
.options(joinedload(Reading.device))
|
||||
.join(Reading.device)
|
||||
.where(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"))
|
||||
.group_by(Reading.device_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = session.scalars(
|
||||
select(Reading)
|
||||
.join(subquery, (Reading.device_id == subquery.c.device_id) & (Reading.recorded_at == subquery.c.latest_at))
|
||||
.order_by(Reading.device_id)
|
||||
)
|
||||
return {reading.device_id: reading for reading in rows}
|
||||
|
||||
|
||||
def stats_by_device(readings: list[Reading]) -> dict[str, dict[str, float | int]]:
|
||||
stats: dict[str, dict[str, float | int]] = {}
|
||||
for reading in readings:
|
||||
if reading.temperature is None:
|
||||
continue
|
||||
item = stats.setdefault(
|
||||
reading.device_id,
|
||||
{
|
||||
"low": reading.temperature,
|
||||
"high": reading.temperature,
|
||||
"samples": 0,
|
||||
},
|
||||
)
|
||||
item["low"] = min(float(item["low"]), reading.temperature)
|
||||
item["high"] = max(float(item["high"]), reading.temperature)
|
||||
item["samples"] = int(item["samples"]) + 1
|
||||
return stats
|
||||
|
||||
|
||||
def chart_payload(readings: list[Reading], tz: ZoneInfo) -> list[dict[str, object]]:
|
||||
series: dict[str, dict[str, object]] = {}
|
||||
for reading in readings:
|
||||
if reading.temperature is None:
|
||||
continue
|
||||
item = series.setdefault(
|
||||
reading.device_id,
|
||||
{
|
||||
"deviceId": reading.device_id,
|
||||
"name": reading.device.name,
|
||||
"points": [],
|
||||
},
|
||||
)
|
||||
local_time = reading.recorded_at.replace(tzinfo=timezone.utc).astimezone(tz)
|
||||
item["points"].append(
|
||||
{
|
||||
"time": local_time.isoformat(timespec="minutes"),
|
||||
"temperature": reading.temperature,
|
||||
"humidity": reading.humidity,
|
||||
}
|
||||
)
|
||||
return list(series.values())
|
||||
|
||||
|
||||
def report_rows(
|
||||
session: Session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
device_id: str = "",
|
||||
) -> list[dict[str, object]]:
|
||||
stmt: Select[tuple] = (
|
||||
select(
|
||||
Device.name,
|
||||
func.count(Reading.id),
|
||||
func.min(Reading.temperature),
|
||||
func.max(Reading.temperature),
|
||||
func.avg(Reading.temperature),
|
||||
func.min(Reading.humidity),
|
||||
func.max(Reading.humidity),
|
||||
func.avg(Reading.humidity),
|
||||
)
|
||||
.join(Reading.device)
|
||||
.where(Reading.recorded_at >= start_utc, Reading.recorded_at < end_utc)
|
||||
.group_by(Device.id, Device.name)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
if device_id:
|
||||
stmt = stmt.where(Device.id == device_id)
|
||||
|
||||
rows = []
|
||||
for row in session.execute(stmt):
|
||||
rows.append(
|
||||
{
|
||||
"device_name": row[0],
|
||||
"samples": row[1],
|
||||
"low_temp": rounded(row[2]),
|
||||
"high_temp": rounded(row[3]),
|
||||
"avg_temp": rounded(row[4]),
|
||||
"low_humidity": rounded(row[5]),
|
||||
"high_humidity": rounded(row[6]),
|
||||
"avg_humidity": rounded(row[7]),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def rounded(value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
return round(float(value), 1)
|
||||
|
||||
|
||||
app = create_app()
|
||||
Reference in New Issue
Block a user