sensors is separate API with td
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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/<int:sensor_id>/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/<int:sensor_id>/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/<int:sensor_id>/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/<int:sensor_id>/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)
|
||||
Reference in New Issue
Block a user