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 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
+ )