from __future__ import annotations import logging from logging.handlers import RotatingFileHandler import os import sys import threading from flask import Flask, Response, abort, jsonify, render_template, url_for from werkzeug.exceptions import HTTPException from .collector import Collector from .config import Config from .netatmo import NetatmoClient from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS 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"] 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 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) prefix = app.config["URL_PREFIX"] app.config["APPLICATION_ROOT"] = prefix or "/" if prefix: app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix) configure_logging(app) app.logger.info("Starting GetNetatmoData v2 (URL prefix=%s)", prefix or "/") 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(): return render_template( "dashboard.html", rrd_names=list(SCHEMAS), periods=list(PERIODS), graph_url_template=url_for("graph", rrd_name="__name__", period="__period__"), ) @app.get("/health") def health(): return jsonify(status="ok", collector_configured=client.configured) @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 @app.get("/last//") 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//") 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/") 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