from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from flask import Flask, jsonify, request from config import sensors_database_uri from models import Sensor, SensorReadout, db def create_app(): app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = sensors_database_uri() app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) # --- API: device endpoints (intentionally public) --- # The sensor firmware has no browser session, but it needs the sensor # list to resolve a name to an id before pushing readouts. @app.route("/api/sensors") def api_sensors(): sensors = Sensor.query.order_by(Sensor.name).all() return jsonify([{"id": s.id, "name": s.name} for s in sensors]) @app.route("/api/sensors", methods=["POST"]) def api_create_sensor(): data = request.get_json(silent=True) if not data or "name" not in data: return jsonify({"error": "name is required"}), 400 name = data["name"].strip() if not name: return jsonify({"error": "name cannot be empty"}), 400 if Sensor.query.filter_by(name=name).first(): return jsonify({"error": "sensor already exists"}), 409 sensor = Sensor(name=name) db.session.add(sensor) db.session.commit() return jsonify({"id": sensor.id, "name": sensor.name}), 201 @app.route("/api/sensors//readouts", methods=["POST"]) def api_create_readout(sensor_id): sensor = db.session.get(Sensor, sensor_id) if not sensor: return jsonify({"error": "sensor not found"}), 404 data = request.get_json(silent=True) if not data: return jsonify({"error": "request body is required"}), 400 missing = [f for f in ("type", "value") if f not in data] if missing: return jsonify({"error": f"missing fields: {', '.join(missing)}"}), 400 try: value = float(data["value"]) except (TypeError, ValueError): return jsonify({"error": "value must be a number"}), 400 readout = SensorReadout( sensor_id=sensor_id, type=str(data["type"]).strip(), value=value, ) db.session.add(readout) db.session.commit() return jsonify( { "id": readout.id, "sensor_id": readout.sensor_id, "type": readout.type, "value": readout.value, "timestamp": readout.timestamp.isoformat() + "Z", } ), 201 # --- API: dashboard reads (public on the local machine) --- @app.route("/api/sensors//types") def api_sensor_types(sensor_id): types = ( db.session.query(SensorReadout.type) .filter_by(sensor_id=sensor_id) .distinct() .order_by(SensorReadout.type) .all() ) return jsonify([t[0] for t in types]) @app.route("/api/sensors//readouts") def api_sensor_readouts(sensor_id): type_ = request.args.get("type") query = SensorReadout.query.filter_by(sensor_id=sensor_id) if type_: query = query.filter_by(type=type_) readouts = query.order_by(SensorReadout.timestamp.desc()).limit(300).all() return jsonify( [ {"value": r.value, "timestamp": r.timestamp.isoformat() + "Z"} for r in readouts ] ) @app.route("/api/sensors//readouts/hourly") def api_sensor_readouts_hourly(sensor_id): type_ = request.args.get("type") range_ = request.args.get("range", "all") # The frontend sends the browser timezone (e.g. Europe/Warsaw), so # "today" means "since midnight local time", not UTC. tz_name = request.args.get("tz", "Europe/Warsaw") or "Europe/Warsaw" try: local_tz = ZoneInfo(tz_name) except (ZoneInfoNotFoundError, ValueError): return jsonify({"error": f"unknown timezone: {tz_name}"}), 400 # Timestamps are stored as naive UTC, so the cutoff must be naive UTC too. now_utc = datetime.now(timezone.utc) now_local = now_utc.astimezone(local_tz) if range_ == "today": midnight_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) cutoff = midnight_local.astimezone(timezone.utc).replace(tzinfo=None) elif range_ == "24h": cutoff = (now_utc - timedelta(hours=24)).replace(tzinfo=None) elif range_ == "week": cutoff = (now_utc - timedelta(days=7)).replace(tzinfo=None) elif range_ == "month": cutoff = (now_utc - timedelta(days=30)).replace(tzinfo=None) elif range_ == "all": cutoff = None else: return jsonify({"error": "range must be one of: today, 24h, week, month, all"}), 400 query = db.session.query(SensorReadout.timestamp, SensorReadout.value).filter( SensorReadout.sensor_id == sensor_id ) if cutoff is not None: query = query.filter(SensorReadout.timestamp >= cutoff) if type_: query = query.filter(SensorReadout.type == type_) rows = query.order_by(SensorReadout.timestamp).all() # today/24h return every measurement by default; the "hourly average" # checkbox forces hourly averages. averaged = range_ not in ("today", "24h") or request.args.get( "avg", "0" ).lower() in ("1", "true", "yes") if not averaged: return jsonify( [ { "timestamp": ts.strftime("%Y-%m-%dT%H:%M:%S"), "value": value, } for ts, value in rows ] ) # Other ranges: hourly average over the full local hour (correct # across DST changes), timestamp is the start of the hour in UTC # (the frontend appends "Z"). buckets: dict[datetime, list[float]] = {} for ts, value in rows: ts_utc = ts.replace(tzinfo=timezone.utc) hour_local = ts_utc.astimezone(local_tz).replace( minute=0, second=0, microsecond=0 ) hour_utc = hour_local.astimezone(timezone.utc).replace(tzinfo=None) buckets.setdefault(hour_utc, []).append(value) result = [ { "timestamp": hour.strftime("%Y-%m-%dT%H:00:00"), "value": round(sum(values) / len(values), 2), } for hour, values in sorted(buckets.items()) ] return jsonify(result) return app if __name__ == "__main__": app = create_app() app.run(debug=True, host='127.0.0.1', port=8001)