2026-08-30 11:01:19 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-08-30 12:01:46 +02:00
|
|
|
from logging.handlers import RotatingFileHandler
|
2026-08-30 11:01:19 +02:00
|
|
|
import os
|
2026-08-30 12:01:46 +02:00
|
|
|
import sys
|
|
|
|
|
import threading
|
2026-08-30 11:01:19 +02:00
|
|
|
|
2026-08-30 11:33:13 +02:00
|
|
|
from flask import Flask, Response, abort, jsonify, render_template, url_for
|
2026-08-30 12:01:46 +02:00
|
|
|
from werkzeug.exceptions import HTTPException
|
2026-08-30 11:01:19 +02:00
|
|
|
|
|
|
|
|
from .collector import Collector
|
|
|
|
|
from .config import Config
|
|
|
|
|
from .netatmo import NetatmoClient
|
|
|
|
|
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 11:33:13 +02:00
|
|
|
class PrefixMiddleware:
|
|
|
|
|
"""Mount a WSGI application below a fixed URL path."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, application, prefix: str):
|
|
|
|
|
self.application = application
|
|
|
|
|
self.prefix = prefix
|
|
|
|
|
|
|
|
|
|
def __call__(self, environ, start_response):
|
|
|
|
|
path = environ.get("PATH_INFO", "")
|
|
|
|
|
if path == self.prefix or path.startswith(f"{self.prefix}/"):
|
|
|
|
|
environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + self.prefix
|
|
|
|
|
environ["PATH_INFO"] = path[len(self.prefix):] or "/"
|
|
|
|
|
return self.application(environ, start_response)
|
|
|
|
|
start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")])
|
|
|
|
|
return [b"Not Found\n"]
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 12:01:46 +02:00
|
|
|
def configure_logging(app: Flask) -> None:
|
|
|
|
|
log_path = app.config["LOG_FILE"]
|
|
|
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
handler = RotatingFileHandler(
|
|
|
|
|
log_path,
|
|
|
|
|
maxBytes=app.config["LOG_MAX_BYTES"],
|
|
|
|
|
backupCount=app.config["LOG_BACKUP_COUNT"],
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
handler.setFormatter(logging.Formatter(
|
|
|
|
|
"%(asctime)s %(levelname)s %(name)s [%(threadName)s] %(message)s"
|
|
|
|
|
))
|
|
|
|
|
root = logging.getLogger()
|
|
|
|
|
root.setLevel(app.config["LOG_LEVEL"])
|
|
|
|
|
# Avoid duplicate handlers when an app factory is called repeatedly in tests.
|
|
|
|
|
target = str(log_path)
|
|
|
|
|
if not any(
|
|
|
|
|
isinstance(item, RotatingFileHandler)
|
|
|
|
|
and getattr(item, "baseFilename", None) == target
|
|
|
|
|
for item in root.handlers
|
|
|
|
|
):
|
|
|
|
|
root.addHandler(handler)
|
|
|
|
|
else:
|
|
|
|
|
handler.close()
|
|
|
|
|
|
|
|
|
|
def uncaught_exception(exception_type, exception, traceback):
|
|
|
|
|
if issubclass(exception_type, KeyboardInterrupt):
|
|
|
|
|
return sys.__excepthook__(exception_type, exception, traceback)
|
|
|
|
|
logging.getLogger("netatmo_service.uncaught").critical(
|
|
|
|
|
"Uncaught Python exception", exc_info=(exception_type, exception, traceback)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def uncaught_thread_exception(arguments):
|
|
|
|
|
logging.getLogger("netatmo_service.uncaught").critical(
|
|
|
|
|
"Uncaught exception in thread %s",
|
|
|
|
|
arguments.thread.name if arguments.thread else "unknown",
|
|
|
|
|
exc_info=(arguments.exc_type, arguments.exc_value, arguments.exc_traceback),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
sys.excepthook = uncaught_exception
|
|
|
|
|
threading.excepthook = uncaught_thread_exception
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 11:01:19 +02:00
|
|
|
def create_app(test_config: dict | None = None) -> Flask:
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
app.config.from_object(Config)
|
|
|
|
|
if test_config:
|
|
|
|
|
app.config.update(test_config)
|
|
|
|
|
|
2026-08-30 11:33:13 +02:00
|
|
|
prefix = app.config["URL_PREFIX"]
|
|
|
|
|
app.config["APPLICATION_ROOT"] = prefix or "/"
|
|
|
|
|
if prefix:
|
|
|
|
|
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix)
|
|
|
|
|
|
2026-08-30 12:01:46 +02:00
|
|
|
configure_logging(app)
|
|
|
|
|
app.logger.info("Starting GetNetatmoData v2 (URL prefix=%s)", prefix or "/")
|
2026-08-30 11:01:19 +02:00
|
|
|
store = app.config.get("RRD_STORE") or RRDStore(
|
|
|
|
|
app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"]
|
|
|
|
|
)
|
|
|
|
|
store.ensure_all()
|
|
|
|
|
client = app.config.get("NETATMO_CLIENT") or NetatmoClient(app.config)
|
|
|
|
|
names = {key: app.config[f"MODULE_{key.upper()}"] for key in SCHEMAS}
|
|
|
|
|
collector = Collector(client, store, names, app.config["POLL_INTERVAL"])
|
|
|
|
|
app.extensions["rrd_store"] = store
|
|
|
|
|
app.extensions["netatmo_collector"] = collector
|
|
|
|
|
|
|
|
|
|
# Flask's debug reloader imports twice. Only its serving child starts a poller.
|
|
|
|
|
should_start = app.config["START_COLLECTOR"] and client.configured
|
|
|
|
|
if should_start and (not app.debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true"):
|
|
|
|
|
collector.start()
|
|
|
|
|
elif app.config["START_COLLECTOR"] and not client.configured:
|
|
|
|
|
app.logger.warning("Collector disabled: configure NETATMO_ACCESS_TOKEN or OAuth refresh credentials")
|
|
|
|
|
|
|
|
|
|
@app.get("/")
|
|
|
|
|
def dashboard():
|
2026-08-30 11:33:13 +02:00
|
|
|
return render_template(
|
|
|
|
|
"dashboard.html",
|
|
|
|
|
rrd_names=list(SCHEMAS),
|
|
|
|
|
periods=list(PERIODS),
|
|
|
|
|
graph_url_template=url_for("graph", rrd_name="__name__", period="__period__"),
|
|
|
|
|
)
|
2026-08-30 11:01:19 +02:00
|
|
|
|
|
|
|
|
@app.get("/health")
|
|
|
|
|
def health():
|
|
|
|
|
return jsonify(status="ok", collector_configured=client.configured)
|
|
|
|
|
|
2026-08-30 12:01:46 +02:00
|
|
|
@app.errorhandler(Exception)
|
|
|
|
|
def unexpected_error(error):
|
|
|
|
|
if isinstance(error, HTTPException):
|
|
|
|
|
return error
|
|
|
|
|
app.logger.exception("Unhandled Flask request error")
|
|
|
|
|
return jsonify(error="Internal server error"), 500
|
|
|
|
|
|
2026-08-30 11:01:19 +02:00
|
|
|
@app.get("/last/<rrd_name>/<data_point>")
|
|
|
|
|
def last(rrd_name: str, data_point: str):
|
|
|
|
|
try:
|
|
|
|
|
return jsonify(store.last(rrd_name.lower(), data_point.lower()))
|
|
|
|
|
except KeyError:
|
|
|
|
|
abort(404, description="Unknown RRD or data point")
|
|
|
|
|
except (OSError, RuntimeError, RRD_ERROR):
|
|
|
|
|
app.logger.exception("Could not read RRD")
|
|
|
|
|
abort(503, description="RRD data is unavailable")
|
|
|
|
|
|
|
|
|
|
@app.get("/graph/<rrd_name>/<period>")
|
|
|
|
|
def graph(rrd_name: str, period: str):
|
|
|
|
|
try:
|
|
|
|
|
image = store.graph(rrd_name.lower(), ALIASES.get(period.lower(), period.lower()))
|
|
|
|
|
return Response(image, mimetype="image/png", headers={"Cache-Control": "no-cache, max-age=0"})
|
|
|
|
|
except KeyError:
|
|
|
|
|
abort(404, description="Unknown RRD or period")
|
|
|
|
|
except (OSError, RuntimeError, RRD_ERROR):
|
|
|
|
|
app.logger.exception("Could not render graph")
|
|
|
|
|
abort(503, description="RRD graph is unavailable")
|
|
|
|
|
|
|
|
|
|
# Compatibility with the compact form requested as /graph/[rrdname][period].
|
|
|
|
|
@app.get("/graph/<rrd_and_period>")
|
|
|
|
|
def compact_graph(rrd_and_period: str):
|
|
|
|
|
for name in SCHEMAS:
|
|
|
|
|
if rrd_and_period.startswith(name):
|
|
|
|
|
return graph(name, rrd_and_period[len(name):].lstrip("-_"))
|
|
|
|
|
abort(404, description="Unknown RRD or period")
|
|
|
|
|
|
|
|
|
|
@app.cli.command("collect-now")
|
|
|
|
|
def collect_now():
|
|
|
|
|
"""Fetch Netatmo and update every RRD immediately."""
|
|
|
|
|
count = collector.collect_once()
|
|
|
|
|
print(f"Updated {count} RRDs")
|
|
|
|
|
|
|
|
|
|
return app
|