solisdash 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solisdash/__init__.py +1 -0
- solisdash/alarms.py +64 -0
- solisdash/app.py +595 -0
- solisdash/auth.py +111 -0
- solisdash/cli.py +252 -0
- solisdash/client.py +364 -0
- solisdash/config.py +40 -0
- solisdash/db.py +90 -0
- solisdash/history.py +148 -0
- solisdash/poller.py +263 -0
- solisdash/ratelimit.py +67 -0
- solisdash/scheduler.py +69 -0
- solisdash/signing.py +83 -0
- solisdash/static/history.js +142 -0
- solisdash/static/icon-128.png +0 -0
- solisdash/static/icon.png +0 -0
- solisdash/static/style.css +79 -0
- solisdash/templates/_tiles.html +40 -0
- solisdash/templates/alarms.html +86 -0
- solisdash/templates/base.html +97 -0
- solisdash/templates/history.html +78 -0
- solisdash/templates/home.html +20 -0
- solisdash/templates/login.html +38 -0
- solisdash/tiles.py +231 -0
- solisdash-0.3.1.dist-info/METADATA +138 -0
- solisdash-0.3.1.dist-info/RECORD +29 -0
- solisdash-0.3.1.dist-info/WHEEL +4 -0
- solisdash-0.3.1.dist-info/entry_points.txt +2 -0
- solisdash-0.3.1.dist-info/licenses/LICENSE +662 -0
solisdash/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
solisdash/alarms.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Alarm-feed queries — MongoDB only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pymongo.asynchronous.database import AsyncDatabase
|
|
9
|
+
|
|
10
|
+
ALARM_STATE_LABELS = {
|
|
11
|
+
"0": "pending",
|
|
12
|
+
"1": "processed",
|
|
13
|
+
"2": "restored",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AlarmPage:
|
|
19
|
+
"""One paginated slice of the `alarms` collection."""
|
|
20
|
+
|
|
21
|
+
rows: list[dict[str, Any]]
|
|
22
|
+
total: int
|
|
23
|
+
page_no: int
|
|
24
|
+
page_size: int
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def has_next(self) -> bool:
|
|
28
|
+
return self.page_no * self.page_size < self.total
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AlarmService:
|
|
32
|
+
"""Read-only listing over the `alarms` collection."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, db: AsyncDatabase[dict[str, Any]]) -> None:
|
|
35
|
+
self._db = db
|
|
36
|
+
|
|
37
|
+
async def list_alarms(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
page_no: int = 1,
|
|
41
|
+
page_size: int = 25,
|
|
42
|
+
station_id: str | None = None,
|
|
43
|
+
state: str | None = None,
|
|
44
|
+
) -> AlarmPage:
|
|
45
|
+
page_no = max(1, page_no)
|
|
46
|
+
page_size = max(1, min(page_size, 100))
|
|
47
|
+
|
|
48
|
+
query: dict[str, Any] = {}
|
|
49
|
+
if station_id:
|
|
50
|
+
query["station_id"] = station_id
|
|
51
|
+
if state:
|
|
52
|
+
query["state"] = state
|
|
53
|
+
|
|
54
|
+
total = await self._db["alarms"].count_documents(query)
|
|
55
|
+
cursor = (
|
|
56
|
+
self._db["alarms"]
|
|
57
|
+
.find(query, sort=[("alarm_begin_time", -1)])
|
|
58
|
+
.skip((page_no - 1) * page_size)
|
|
59
|
+
.limit(page_size)
|
|
60
|
+
)
|
|
61
|
+
rows = [doc async for doc in cursor]
|
|
62
|
+
return AlarmPage(
|
|
63
|
+
rows=rows, total=total, page_no=page_no, page_size=page_size
|
|
64
|
+
)
|
solisdash/app.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Solisdash FastAPI app: session auth, Jinja shell, lazy-connected Mongo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from datetime import UTC, date, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, Response
|
|
15
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
16
|
+
from fastapi.staticfiles import StaticFiles
|
|
17
|
+
from fastapi.templating import Jinja2Templates
|
|
18
|
+
from pymongo import AsyncMongoClient
|
|
19
|
+
from pymongo.asynchronous.database import AsyncDatabase
|
|
20
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
21
|
+
|
|
22
|
+
from solisdash import __version__
|
|
23
|
+
from solisdash.alarms import ALARM_STATE_LABELS, AlarmService
|
|
24
|
+
from solisdash.auth import (
|
|
25
|
+
authenticate,
|
|
26
|
+
get_current_user,
|
|
27
|
+
redirect_to,
|
|
28
|
+
require_user,
|
|
29
|
+
session_login,
|
|
30
|
+
session_logout,
|
|
31
|
+
)
|
|
32
|
+
from solisdash.client import SolisAPIError, SolisClient
|
|
33
|
+
from solisdash.config import get_settings
|
|
34
|
+
from solisdash.db import ensure_indexes
|
|
35
|
+
from solisdash.history import HistoryService, Series, parse_month
|
|
36
|
+
from solisdash.poller import Poller
|
|
37
|
+
from solisdash.ratelimit import TokenBucket
|
|
38
|
+
from solisdash.scheduler import build_scheduler
|
|
39
|
+
from solisdash.tiles import LiveTilesService, TilesData
|
|
40
|
+
|
|
41
|
+
# Uvicorn only configures its own named loggers, leaving the root logger
|
|
42
|
+
# without handlers. `basicConfig` here gives `solisdash.*` loggers a default
|
|
43
|
+
# stderr handler at INFO so the scheduler/poller logs land in `uvicorn.log`.
|
|
44
|
+
# `basicConfig` is a no-op if the root logger already has handlers, so this
|
|
45
|
+
# stays out of the way when uvicorn (or pytest) has wired up something else.
|
|
46
|
+
logging.basicConfig(
|
|
47
|
+
level=logging.INFO,
|
|
48
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
HERE = Path(__file__).parent
|
|
52
|
+
TEMPLATES_DIR = HERE / "templates"
|
|
53
|
+
STATIC_DIR = HERE / "static"
|
|
54
|
+
|
|
55
|
+
# 1x1 transparent PNG, just to silence /favicon.ico 404s during dev.
|
|
56
|
+
_FAVICON_PNG = base64.b64decode(
|
|
57
|
+
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
61
|
+
templates.env.globals["version"] = __version__
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
66
|
+
"""Initialise lazy-init slots; close everything on shutdown.
|
|
67
|
+
|
|
68
|
+
When `RUN_SCHEDULER` is set, also start an `AsyncIOScheduler` that
|
|
69
|
+
pumps SolisCloud data into MongoDB on a cron. Tests and CI default
|
|
70
|
+
to off so they never call out to the real API.
|
|
71
|
+
"""
|
|
72
|
+
app.state.mongo_client = None
|
|
73
|
+
app.state.solis_client = None
|
|
74
|
+
app.state.tiles_service = None
|
|
75
|
+
app.state.scheduler = None
|
|
76
|
+
app.state.poller = None
|
|
77
|
+
app.state.rate_limiter = None
|
|
78
|
+
|
|
79
|
+
settings = get_settings()
|
|
80
|
+
if settings.RUN_SCHEDULER and settings.SOLIS_KEY_ID and settings.SOLIS_MONGODB_URI:
|
|
81
|
+
app.state.rate_limiter = TokenBucket(
|
|
82
|
+
rate=settings.SCHEDULER_RATE_PER_SEC,
|
|
83
|
+
capacity=settings.SCHEDULER_RATE_PER_SEC * 2,
|
|
84
|
+
)
|
|
85
|
+
solis = SolisClient(
|
|
86
|
+
base_url=settings.SOLIS_API_URL,
|
|
87
|
+
key_id=settings.SOLIS_KEY_ID,
|
|
88
|
+
key_secret=settings.SOLIS_KEYSECRET,
|
|
89
|
+
)
|
|
90
|
+
mongo: AsyncMongoClient[dict[str, Any]] = AsyncMongoClient(
|
|
91
|
+
settings.SOLIS_MONGODB_URI
|
|
92
|
+
)
|
|
93
|
+
await ensure_indexes(mongo[settings.SOLIS_MONGODB_DB])
|
|
94
|
+
app.state.solis_client = solis
|
|
95
|
+
app.state.mongo_client = mongo
|
|
96
|
+
app.state.poller = Poller(
|
|
97
|
+
solis=solis,
|
|
98
|
+
db=mongo[settings.SOLIS_MONGODB_DB],
|
|
99
|
+
rate_limiter=app.state.rate_limiter,
|
|
100
|
+
)
|
|
101
|
+
app.state.scheduler = build_scheduler(app.state.poller, settings)
|
|
102
|
+
app.state.scheduler.start()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
yield
|
|
106
|
+
finally:
|
|
107
|
+
scheduler = app.state.scheduler
|
|
108
|
+
if scheduler is not None:
|
|
109
|
+
scheduler.shutdown(wait=False)
|
|
110
|
+
mongo_cli: AsyncMongoClient[dict[str, Any]] | None = app.state.mongo_client
|
|
111
|
+
if mongo_cli is not None:
|
|
112
|
+
await mongo_cli.close()
|
|
113
|
+
solis_cli: SolisClient | None = app.state.solis_client
|
|
114
|
+
if solis_cli is not None:
|
|
115
|
+
await solis_cli.aclose()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
app = FastAPI(title="Solisdash", version=__version__, lifespan=lifespan)
|
|
119
|
+
app.add_middleware(
|
|
120
|
+
SessionMiddleware,
|
|
121
|
+
secret_key=get_settings().SESSION_SECRET or "dev-only-do-not-use-in-prod",
|
|
122
|
+
same_site="lax",
|
|
123
|
+
https_only=False,
|
|
124
|
+
)
|
|
125
|
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def get_db(request: Request) -> AsyncDatabase[dict[str, Any]]:
|
|
129
|
+
"""Lazily open the Mongo client on first use; reuse for the app lifetime."""
|
|
130
|
+
settings = get_settings()
|
|
131
|
+
if request.app.state.mongo_client is None:
|
|
132
|
+
if not settings.SOLIS_MONGODB_URI:
|
|
133
|
+
raise RuntimeError("SOLIS_MONGODB_URI is not configured")
|
|
134
|
+
client: AsyncMongoClient[dict[str, Any]] = AsyncMongoClient(
|
|
135
|
+
settings.SOLIS_MONGODB_URI
|
|
136
|
+
)
|
|
137
|
+
await ensure_indexes(client[settings.SOLIS_MONGODB_DB])
|
|
138
|
+
request.app.state.mongo_client = client
|
|
139
|
+
cached: AsyncMongoClient[dict[str, Any]] = request.app.state.mongo_client
|
|
140
|
+
return cached[settings.SOLIS_MONGODB_DB]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def get_solis_client(request: Request) -> SolisClient:
|
|
144
|
+
"""Lazily open one shared `SolisClient`. Closed on app shutdown."""
|
|
145
|
+
if request.app.state.solis_client is None:
|
|
146
|
+
settings = get_settings()
|
|
147
|
+
if not settings.SOLIS_KEY_ID or not settings.SOLIS_KEYSECRET:
|
|
148
|
+
raise RuntimeError("SOLIS_KEY_ID / SOLIS_KEYSECRET not configured")
|
|
149
|
+
request.app.state.solis_client = SolisClient(
|
|
150
|
+
base_url=settings.SOLIS_API_URL,
|
|
151
|
+
key_id=settings.SOLIS_KEY_ID,
|
|
152
|
+
key_secret=settings.SOLIS_KEYSECRET,
|
|
153
|
+
)
|
|
154
|
+
client: SolisClient = request.app.state.solis_client
|
|
155
|
+
return client
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def get_tiles_service(
|
|
159
|
+
request: Request,
|
|
160
|
+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
|
|
161
|
+
solis: SolisClient = Depends(get_solis_client),
|
|
162
|
+
) -> LiveTilesService:
|
|
163
|
+
"""Single `LiveTilesService` per app, so the in-memory TTL cache persists."""
|
|
164
|
+
if request.app.state.tiles_service is None:
|
|
165
|
+
settings = get_settings()
|
|
166
|
+
request.app.state.tiles_service = LiveTilesService(
|
|
167
|
+
solis=solis,
|
|
168
|
+
db=db,
|
|
169
|
+
default_station_id=settings.SOLIS_STATION_ID or None,
|
|
170
|
+
)
|
|
171
|
+
service: LiveTilesService = request.app.state.tiles_service
|
|
172
|
+
return service
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def get_history_service(
|
|
176
|
+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
|
|
177
|
+
) -> HistoryService:
|
|
178
|
+
"""A fresh HistoryService is cheap; no in-memory state to share."""
|
|
179
|
+
return HistoryService(db)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def get_alarm_service(
|
|
183
|
+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
|
|
184
|
+
) -> AlarmService:
|
|
185
|
+
return AlarmService(db)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _resolve_station_id(
|
|
189
|
+
history: HistoryService, requested: str | None
|
|
190
|
+
) -> str | None:
|
|
191
|
+
"""Use `requested` if given, else fall back to the first stored station."""
|
|
192
|
+
if requested:
|
|
193
|
+
return requested
|
|
194
|
+
stations = await history.list_stations()
|
|
195
|
+
return stations[0]["id"] if stations else None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.get("/health")
|
|
199
|
+
async def health() -> dict[str, str]:
|
|
200
|
+
return {"status": "ok", "version": __version__}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.get("/ready")
|
|
204
|
+
async def ready(
|
|
205
|
+
request: Request,
|
|
206
|
+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
|
|
207
|
+
) -> JSONResponse:
|
|
208
|
+
"""Readiness probe: Mongo reachable, scheduler healthy (if enabled).
|
|
209
|
+
|
|
210
|
+
Always returns JSON. 200 when all checks pass, 503 otherwise. Liveness
|
|
211
|
+
(`/health`) only proves the process is alive; this proves the app can
|
|
212
|
+
actually serve work.
|
|
213
|
+
"""
|
|
214
|
+
settings = get_settings()
|
|
215
|
+
checks: dict[str, Any] = {}
|
|
216
|
+
ok = True
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
await db.command("ping")
|
|
220
|
+
checks["mongo"] = {"ok": True}
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
checks["mongo"] = {"ok": False, "detail": str(exc)}
|
|
223
|
+
ok = False
|
|
224
|
+
|
|
225
|
+
if settings.RUN_SCHEDULER:
|
|
226
|
+
scheduler = request.app.state.scheduler
|
|
227
|
+
if scheduler is None or not scheduler.running:
|
|
228
|
+
checks["scheduler"] = {"ok": False, "detail": "not running"}
|
|
229
|
+
ok = False
|
|
230
|
+
else:
|
|
231
|
+
try:
|
|
232
|
+
latest = await db["station_samples"].find_one(
|
|
233
|
+
sort=[("polled_at", -1)]
|
|
234
|
+
)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
checks["scheduler"] = {"ok": False, "detail": str(exc)}
|
|
237
|
+
ok = False
|
|
238
|
+
else:
|
|
239
|
+
if latest is None:
|
|
240
|
+
checks["scheduler"] = {
|
|
241
|
+
"ok": False,
|
|
242
|
+
"detail": "no station_samples yet",
|
|
243
|
+
}
|
|
244
|
+
ok = False
|
|
245
|
+
else:
|
|
246
|
+
polled_at = latest.get("polled_at")
|
|
247
|
+
if isinstance(polled_at, datetime):
|
|
248
|
+
# Mongo's BSON dates come back naive (UTC) by default.
|
|
249
|
+
if polled_at.tzinfo is None:
|
|
250
|
+
polled_at = polled_at.replace(tzinfo=UTC)
|
|
251
|
+
age_s: float | None = (
|
|
252
|
+
datetime.now(UTC) - polled_at
|
|
253
|
+
).total_seconds()
|
|
254
|
+
else:
|
|
255
|
+
age_s = None
|
|
256
|
+
stale_after = settings.SCHEDULER_SAMPLE_MINUTES * 60 * 3
|
|
257
|
+
healthy = age_s is not None and age_s < stale_after
|
|
258
|
+
checks["scheduler"] = {
|
|
259
|
+
"ok": healthy,
|
|
260
|
+
"last_sample_age_s": age_s,
|
|
261
|
+
"stale_after_s": stale_after,
|
|
262
|
+
}
|
|
263
|
+
if not healthy:
|
|
264
|
+
ok = False
|
|
265
|
+
else:
|
|
266
|
+
checks["scheduler"] = {"ok": True, "detail": "RUN_SCHEDULER disabled"}
|
|
267
|
+
|
|
268
|
+
return JSONResponse(
|
|
269
|
+
{"ready": ok, "version": __version__, "checks": checks},
|
|
270
|
+
status_code=200 if ok else 503,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.get("/favicon.ico", include_in_schema=False)
|
|
275
|
+
async def favicon() -> Response:
|
|
276
|
+
"""Empty 1x1 transparent PNG so browsers stop 404-ing during dev."""
|
|
277
|
+
return Response(
|
|
278
|
+
content=_FAVICON_PNG,
|
|
279
|
+
media_type="image/png",
|
|
280
|
+
headers={"Cache-Control": "public, max-age=86400"},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _resolve_tiles(
|
|
285
|
+
tiles_service: LiveTilesService,
|
|
286
|
+
) -> tuple[TilesData | None, str | None]:
|
|
287
|
+
"""Fetch the default station's tiles. Return (data, error_message).
|
|
288
|
+
|
|
289
|
+
Catches the expected failure modes (SolisCloud envelope errors, httpx
|
|
290
|
+
transport errors, and `RuntimeError` from unconfigured settings) and
|
|
291
|
+
renders them as friendly alerts. Anything else propagates so genuine
|
|
292
|
+
bugs surface in logs.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
station_id = await tiles_service.default_station_id()
|
|
296
|
+
except SolisAPIError as exc:
|
|
297
|
+
return None, f"SolisCloud rejected the call: {exc}"
|
|
298
|
+
except (httpx.HTTPError, RuntimeError) as exc:
|
|
299
|
+
return None, f"Could not reach SolisCloud: {exc}"
|
|
300
|
+
if not station_id:
|
|
301
|
+
return None, "No stations found on this SolisCloud account."
|
|
302
|
+
try:
|
|
303
|
+
return await tiles_service.get_tiles(station_id), None
|
|
304
|
+
except SolisAPIError as exc:
|
|
305
|
+
return None, f"SolisCloud rejected the call: {exc}"
|
|
306
|
+
except (httpx.HTTPError, RuntimeError) as exc:
|
|
307
|
+
return None, f"Could not reach SolisCloud: {exc}"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.get("/", response_class=HTMLResponse, response_model=None)
|
|
311
|
+
async def home(
|
|
312
|
+
request: Request,
|
|
313
|
+
user: dict[str, Any] = Depends(require_user),
|
|
314
|
+
tiles_service: LiveTilesService = Depends(get_tiles_service),
|
|
315
|
+
) -> HTMLResponse:
|
|
316
|
+
tiles, error = await _resolve_tiles(tiles_service)
|
|
317
|
+
return templates.TemplateResponse(
|
|
318
|
+
request,
|
|
319
|
+
"home.html",
|
|
320
|
+
{"user": user, "tiles": tiles, "error": error},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.get("/tiles", response_class=HTMLResponse, response_model=None)
|
|
325
|
+
async def tiles_fragment(
|
|
326
|
+
request: Request,
|
|
327
|
+
user: dict[str, Any] = Depends(require_user),
|
|
328
|
+
tiles_service: LiveTilesService = Depends(get_tiles_service),
|
|
329
|
+
) -> HTMLResponse:
|
|
330
|
+
"""HTML fragment for HTMX swaps on the home page."""
|
|
331
|
+
tiles, error = await _resolve_tiles(tiles_service)
|
|
332
|
+
return templates.TemplateResponse(
|
|
333
|
+
request, "_tiles.html", {"tiles": tiles, "error": error}
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@app.get("/login", response_class=HTMLResponse, response_model=None)
|
|
338
|
+
async def login_form(
|
|
339
|
+
request: Request,
|
|
340
|
+
user: dict[str, Any] | None = Depends(get_current_user),
|
|
341
|
+
) -> HTMLResponse | RedirectResponse:
|
|
342
|
+
if user is not None:
|
|
343
|
+
return redirect_to("/")
|
|
344
|
+
return templates.TemplateResponse(request, "login.html", {"error": None})
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@app.post("/login", response_class=HTMLResponse, response_model=None)
|
|
348
|
+
async def login_submit(
|
|
349
|
+
request: Request,
|
|
350
|
+
username: str = Form(...),
|
|
351
|
+
password: str = Form(...),
|
|
352
|
+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
|
|
353
|
+
) -> HTMLResponse | RedirectResponse:
|
|
354
|
+
user = await authenticate(db, username, password)
|
|
355
|
+
if user is None:
|
|
356
|
+
return templates.TemplateResponse(
|
|
357
|
+
request,
|
|
358
|
+
"login.html",
|
|
359
|
+
{"error": "Invalid username or password.", "username": username},
|
|
360
|
+
status_code=401,
|
|
361
|
+
)
|
|
362
|
+
session_login(request, user)
|
|
363
|
+
return redirect_to("/")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.post("/logout")
|
|
367
|
+
async def logout(request: Request) -> RedirectResponse:
|
|
368
|
+
session_logout(request)
|
|
369
|
+
return redirect_to("/login")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# --- History page -----------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@app.get("/history", response_class=HTMLResponse, response_model=None)
|
|
376
|
+
async def history_page(
|
|
377
|
+
request: Request,
|
|
378
|
+
user: dict[str, Any] = Depends(require_user),
|
|
379
|
+
history: HistoryService = Depends(get_history_service),
|
|
380
|
+
) -> HTMLResponse:
|
|
381
|
+
stations = await history.list_stations()
|
|
382
|
+
selected = stations[0]["id"] if stations else None
|
|
383
|
+
today = datetime.now(UTC).date().isoformat()
|
|
384
|
+
return templates.TemplateResponse(
|
|
385
|
+
request,
|
|
386
|
+
"history.html",
|
|
387
|
+
{
|
|
388
|
+
"user": user,
|
|
389
|
+
"stations": stations,
|
|
390
|
+
"selected_station_id": selected,
|
|
391
|
+
"today": today,
|
|
392
|
+
"current_month": today[:7],
|
|
393
|
+
"current_year": today[:4],
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@app.get("/history/day.json")
|
|
399
|
+
async def history_day_json(
|
|
400
|
+
station_id: str | None = Query(None),
|
|
401
|
+
when: str = Query(..., description="YYYY-MM-DD"),
|
|
402
|
+
history: HistoryService = Depends(get_history_service),
|
|
403
|
+
user: dict[str, Any] = Depends(require_user),
|
|
404
|
+
) -> JSONResponse:
|
|
405
|
+
sid = await _resolve_station_id(history, station_id)
|
|
406
|
+
if sid is None:
|
|
407
|
+
return JSONResponse(_empty_series_response("Power", "kW", station_id=None))
|
|
408
|
+
try:
|
|
409
|
+
when_date = date.fromisoformat(when)
|
|
410
|
+
except ValueError as exc:
|
|
411
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
412
|
+
series = await history.day_series(sid, when_date)
|
|
413
|
+
return JSONResponse({"station_id": sid, **series.to_json()})
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.get("/history/month.json")
|
|
417
|
+
async def history_month_json(
|
|
418
|
+
station_id: str | None = Query(None),
|
|
419
|
+
month: str = Query(..., description="YYYY-MM"),
|
|
420
|
+
history: HistoryService = Depends(get_history_service),
|
|
421
|
+
user: dict[str, Any] = Depends(require_user),
|
|
422
|
+
) -> JSONResponse:
|
|
423
|
+
sid = await _resolve_station_id(history, station_id)
|
|
424
|
+
if sid is None:
|
|
425
|
+
return JSONResponse(_empty_series_response("Daily energy", "kWh", station_id=None))
|
|
426
|
+
try:
|
|
427
|
+
parse_month(month)
|
|
428
|
+
except ValueError as exc:
|
|
429
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
430
|
+
series = await history.month_daily(sid, month)
|
|
431
|
+
return JSONResponse({"station_id": sid, **series.to_json()})
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@app.get("/history/year.json")
|
|
435
|
+
async def history_year_json(
|
|
436
|
+
station_id: str | None = Query(None),
|
|
437
|
+
year: int = Query(...),
|
|
438
|
+
history: HistoryService = Depends(get_history_service),
|
|
439
|
+
user: dict[str, Any] = Depends(require_user),
|
|
440
|
+
) -> JSONResponse:
|
|
441
|
+
sid = await _resolve_station_id(history, station_id)
|
|
442
|
+
if sid is None:
|
|
443
|
+
return JSONResponse(
|
|
444
|
+
_empty_series_response("Monthly energy", "kWh", station_id=None)
|
|
445
|
+
)
|
|
446
|
+
series = await history.year_monthly(sid, year)
|
|
447
|
+
return JSONResponse({"station_id": sid, **series.to_json()})
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@app.get("/history/all.json")
|
|
451
|
+
async def history_all_json(
|
|
452
|
+
station_id: str | None = Query(None),
|
|
453
|
+
history: HistoryService = Depends(get_history_service),
|
|
454
|
+
user: dict[str, Any] = Depends(require_user),
|
|
455
|
+
) -> JSONResponse:
|
|
456
|
+
sid = await _resolve_station_id(history, station_id)
|
|
457
|
+
if sid is None:
|
|
458
|
+
return JSONResponse(
|
|
459
|
+
_empty_series_response("Annual energy", "kWh", station_id=None)
|
|
460
|
+
)
|
|
461
|
+
series = await history.all_time(sid)
|
|
462
|
+
return JSONResponse({"station_id": sid, **series.to_json()})
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _empty_series_response(
|
|
466
|
+
label: str, unit: str, *, station_id: str | None
|
|
467
|
+
) -> dict[str, Any]:
|
|
468
|
+
return {"station_id": station_id, "label": label, "unit": unit, "points": []}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _series_to_csv(series: Series, *, x_header: str, y_header: str) -> str:
|
|
472
|
+
"""Render a Series as a tiny RFC-4180 CSV."""
|
|
473
|
+
lines = [f"{x_header},{y_header} ({series.unit})"]
|
|
474
|
+
for p in series.points:
|
|
475
|
+
v = "" if p.v is None else f"{p.v}"
|
|
476
|
+
lines.append(f"{p.t},{v}")
|
|
477
|
+
return "\n".join(lines) + "\n"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _csv_response(body: str, filename: str) -> Response:
|
|
481
|
+
return Response(
|
|
482
|
+
content=body,
|
|
483
|
+
media_type="text/csv",
|
|
484
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.get("/history/day.csv")
|
|
489
|
+
async def history_day_csv(
|
|
490
|
+
station_id: str | None = Query(None),
|
|
491
|
+
when: str = Query(..., description="YYYY-MM-DD"),
|
|
492
|
+
history: HistoryService = Depends(get_history_service),
|
|
493
|
+
user: dict[str, Any] = Depends(require_user),
|
|
494
|
+
) -> Response:
|
|
495
|
+
sid = await _resolve_station_id(history, station_id)
|
|
496
|
+
if sid is None:
|
|
497
|
+
return _csv_response("timestamp_ms,power\n", "history-day.csv")
|
|
498
|
+
try:
|
|
499
|
+
when_date = date.fromisoformat(when)
|
|
500
|
+
except ValueError as exc:
|
|
501
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
502
|
+
series = await history.day_series(sid, when_date)
|
|
503
|
+
return _csv_response(
|
|
504
|
+
_series_to_csv(series, x_header="timestamp_ms", y_header="power"),
|
|
505
|
+
f"history-day-{sid}-{when}.csv",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@app.get("/history/month.csv")
|
|
510
|
+
async def history_month_csv(
|
|
511
|
+
station_id: str | None = Query(None),
|
|
512
|
+
month: str = Query(..., description="YYYY-MM"),
|
|
513
|
+
history: HistoryService = Depends(get_history_service),
|
|
514
|
+
user: dict[str, Any] = Depends(require_user),
|
|
515
|
+
) -> Response:
|
|
516
|
+
sid = await _resolve_station_id(history, station_id)
|
|
517
|
+
if sid is None:
|
|
518
|
+
return _csv_response("date,energy\n", "history-month.csv")
|
|
519
|
+
try:
|
|
520
|
+
parse_month(month)
|
|
521
|
+
except ValueError as exc:
|
|
522
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
523
|
+
series = await history.month_daily(sid, month)
|
|
524
|
+
return _csv_response(
|
|
525
|
+
_series_to_csv(series, x_header="date", y_header="energy"),
|
|
526
|
+
f"history-month-{sid}-{month}.csv",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@app.get("/history/year.csv")
|
|
531
|
+
async def history_year_csv(
|
|
532
|
+
station_id: str | None = Query(None),
|
|
533
|
+
year: int = Query(...),
|
|
534
|
+
history: HistoryService = Depends(get_history_service),
|
|
535
|
+
user: dict[str, Any] = Depends(require_user),
|
|
536
|
+
) -> Response:
|
|
537
|
+
sid = await _resolve_station_id(history, station_id)
|
|
538
|
+
if sid is None:
|
|
539
|
+
return _csv_response("month,energy\n", "history-year.csv")
|
|
540
|
+
series = await history.year_monthly(sid, year)
|
|
541
|
+
return _csv_response(
|
|
542
|
+
_series_to_csv(series, x_header="month", y_header="energy"),
|
|
543
|
+
f"history-year-{sid}-{year}.csv",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@app.get("/history/all.csv")
|
|
548
|
+
async def history_all_csv(
|
|
549
|
+
station_id: str | None = Query(None),
|
|
550
|
+
history: HistoryService = Depends(get_history_service),
|
|
551
|
+
user: dict[str, Any] = Depends(require_user),
|
|
552
|
+
) -> Response:
|
|
553
|
+
sid = await _resolve_station_id(history, station_id)
|
|
554
|
+
if sid is None:
|
|
555
|
+
return _csv_response("year,energy\n", "history-all.csv")
|
|
556
|
+
series = await history.all_time(sid)
|
|
557
|
+
return _csv_response(
|
|
558
|
+
_series_to_csv(series, x_header="year", y_header="energy"),
|
|
559
|
+
f"history-all-{sid}.csv",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# --- Alarms page ------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@app.get("/alarms", response_class=HTMLResponse, response_model=None)
|
|
567
|
+
async def alarms_page(
|
|
568
|
+
request: Request,
|
|
569
|
+
user: dict[str, Any] = Depends(require_user),
|
|
570
|
+
history: HistoryService = Depends(get_history_service),
|
|
571
|
+
alarms_service: AlarmService = Depends(get_alarm_service),
|
|
572
|
+
page: int = Query(1, ge=1),
|
|
573
|
+
page_size: int = Query(25, ge=1, le=100),
|
|
574
|
+
station_id: str | None = Query(None),
|
|
575
|
+
state: str | None = Query(None),
|
|
576
|
+
) -> HTMLResponse:
|
|
577
|
+
stations = await history.list_stations()
|
|
578
|
+
page_data = await alarms_service.list_alarms(
|
|
579
|
+
page_no=page,
|
|
580
|
+
page_size=page_size,
|
|
581
|
+
station_id=station_id or None,
|
|
582
|
+
state=state or None,
|
|
583
|
+
)
|
|
584
|
+
return templates.TemplateResponse(
|
|
585
|
+
request,
|
|
586
|
+
"alarms.html",
|
|
587
|
+
{
|
|
588
|
+
"user": user,
|
|
589
|
+
"stations": stations,
|
|
590
|
+
"selected_station_id": station_id or "",
|
|
591
|
+
"selected_state": state or "",
|
|
592
|
+
"state_labels": ALARM_STATE_LABELS,
|
|
593
|
+
"alarms": page_data,
|
|
594
|
+
},
|
|
595
|
+
)
|