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 _parse_readout_ts(raw: str): value = (raw or "").strip() if not value: return None if value.endswith("Z"): value = value[:-1] try: parsed = datetime.fromisoformat(value) except ValueError: return None if parsed.tzinfo is not None: parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) return parsed 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 @app.route("/api/sensors//readouts/range") def api_sensor_readouts_range(sensor_id): sensor = db.session.get(Sensor, sensor_id) if not sensor: return jsonify({"error": "sensor not found"}), 404 from_raw = request.args.get("from") to_raw = request.args.get("to") from_ts = _parse_readout_ts(from_raw) if from_raw else None to_ts = _parse_readout_ts(to_raw) if to_raw else None if from_ts is None or to_ts is None or to_ts < from_ts: return jsonify({"error": "from and to are required and valid"}), 400 rows = ( db.session.query( SensorReadout.id, SensorReadout.sensor_id, SensorReadout.type, SensorReadout.value, SensorReadout.timestamp, ) .filter(SensorReadout.sensor_id == sensor_id) .filter(SensorReadout.timestamp >= from_ts, SensorReadout.timestamp < to_ts) .order_by(SensorReadout.timestamp) .all() ) return jsonify( [ { "id": row[0], "sensor_id": row[1], "type": row[2], "value": row[3], "timestamp": row[4].isoformat() + "Z", } for row in rows ] ) # --- 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)