258 lines
8.2 KiB
Python
258 lines
8.2 KiB
Python
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from flask import Flask, jsonify, request
|
|
try:
|
|
from flask_cors import CORS
|
|
except Exception: # pragma: no cover
|
|
CORS = None # type: ignore
|
|
|
|
from db import Database
|
|
|
|
|
|
def _get_env(name: str, default: Optional[str] = None) -> str:
|
|
value = os.environ.get(name)
|
|
return value if value is not None else (default if default is not None else "")
|
|
|
|
|
|
def _normalize_iso8601(timestamp_str: str) -> str:
|
|
if not isinstance(timestamp_str, str) or not timestamp_str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
ts = timestamp_str.strip()
|
|
if ts.endswith("Z"):
|
|
ts = ts[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(ts)
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc).isoformat()
|
|
except Exception:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _validate_reading_payload(payload: Dict[str, Any], partial: bool = False) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
allowed_keys = {"device_id", "timestamp", "pressure", "voltage"}
|
|
if not isinstance(payload, dict):
|
|
return None, "Invalid JSON body"
|
|
|
|
for key in payload.keys():
|
|
if key not in allowed_keys:
|
|
return None, f"Unexpected field: {key}"
|
|
|
|
if not partial:
|
|
missing = [k for k in ["device_id", "pressure", "voltage"] if k not in payload]
|
|
if missing:
|
|
return None, f"Missing required fields: {', '.join(missing)}"
|
|
|
|
result: Dict[str, Any] = {}
|
|
|
|
if "device_id" in payload:
|
|
if not isinstance(payload["device_id"], str) or not payload["device_id"].strip():
|
|
return None, "device_id must be a non-empty string"
|
|
result["device_id"] = payload["device_id"].strip()
|
|
|
|
if "timestamp" in payload:
|
|
result["timestamp"] = _normalize_iso8601(str(payload["timestamp"]))
|
|
elif not partial:
|
|
result["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
if "pressure" in payload:
|
|
try:
|
|
result["pressure"] = float(payload["pressure"])
|
|
except Exception:
|
|
return None, "pressure must be a number"
|
|
|
|
if "voltage" in payload:
|
|
try:
|
|
result["voltage"] = float(payload["voltage"])
|
|
except Exception:
|
|
return None, "voltage must be a number"
|
|
|
|
return result, None
|
|
|
|
|
|
app = Flask(__name__)
|
|
if CORS is not None:
|
|
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=False)
|
|
|
|
DB_PATH = _get_env("DB_PATH", os.path.join(os.path.dirname(__file__), "readings.db"))
|
|
database = Database(DB_PATH)
|
|
database.init_db()
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> Any:
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.post("/data")
|
|
def ingest_from_device() -> Any:
|
|
payload = request.get_json(silent=True) or {}
|
|
data, error = _validate_reading_payload(payload, partial=False)
|
|
if error:
|
|
return jsonify({"ok": False, "error": error}), 400
|
|
new_id = database.insert_reading(data)
|
|
return jsonify({"ok": True, "id": new_id}), 201
|
|
|
|
|
|
@app.get("/api/readings")
|
|
def list_readings() -> Any:
|
|
device_id = request.args.get("device_id")
|
|
start = request.args.get("start")
|
|
end = request.args.get("end")
|
|
sort = (request.args.get("sort") or "desc").lower()
|
|
sort = "asc" if sort == "asc" else "desc"
|
|
|
|
try:
|
|
page = int(request.args.get("page", 1))
|
|
page_size = int(request.args.get("page_size", 50))
|
|
except Exception:
|
|
return jsonify({"ok": False, "error": "page and page_size must be integers"}), 400
|
|
|
|
page = max(1, page)
|
|
page_size = min(max(1, page_size), 500)
|
|
|
|
filters: Dict[str, Any] = {}
|
|
if device_id:
|
|
filters["device_id"] = device_id
|
|
if start:
|
|
filters["start"] = _normalize_iso8601(start)
|
|
if end:
|
|
filters["end"] = _normalize_iso8601(end)
|
|
# 可选压力范围过滤,用于图表或报警检索
|
|
min_p = request.args.get("min_pressure")
|
|
max_p = request.args.get("max_pressure")
|
|
if min_p is not None:
|
|
try:
|
|
filters["min_pressure"] = float(min_p)
|
|
except Exception:
|
|
return jsonify({"ok": False, "error": "min_pressure must be a number"}), 400
|
|
if max_p is not None:
|
|
try:
|
|
filters["max_pressure"] = float(max_p)
|
|
except Exception:
|
|
return jsonify({"ok": False, "error": "max_pressure must be a number"}), 400
|
|
|
|
total = database.count_readings(filters)
|
|
items = database.list_readings(filters, sort=sort, limit=page_size, offset=(page - 1) * page_size)
|
|
|
|
return jsonify({
|
|
"ok": True,
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": items,
|
|
})
|
|
|
|
|
|
@app.get("/api/readings/latest")
|
|
def latest_reading() -> Any:
|
|
device_id = request.args.get("device_id")
|
|
row = database.get_latest_reading(device_id)
|
|
if not row:
|
|
return jsonify({"ok": False, "error": "Not found"}), 404
|
|
return jsonify({"ok": True, "item": row})
|
|
|
|
|
|
@app.get("/api/alarms")
|
|
def list_alarms() -> Any:
|
|
device_id = request.args.get("device_id")
|
|
start = request.args.get("start")
|
|
end = request.args.get("end")
|
|
min_p = request.args.get("min_pressure")
|
|
max_p = request.args.get("max_pressure")
|
|
|
|
if min_p is None and max_p is None:
|
|
return jsonify({"ok": False, "error": "min_pressure or max_pressure is required"}), 400
|
|
|
|
try:
|
|
page = int(request.args.get("page", 1))
|
|
page_size = int(request.args.get("page_size", 50))
|
|
except Exception:
|
|
return jsonify({"ok": False, "error": "page and page_size must be integers"}), 400
|
|
|
|
page = max(1, page)
|
|
page_size = min(max(1, page_size), 500)
|
|
|
|
filters: Dict[str, Any] = {}
|
|
if device_id:
|
|
filters["device_id"] = device_id
|
|
if start:
|
|
filters["start"] = _normalize_iso8601(start)
|
|
if end:
|
|
filters["end"] = _normalize_iso8601(end)
|
|
if min_p is not None:
|
|
try:
|
|
filters["min_pressure"] = float(min_p)
|
|
except Exception:
|
|
return jsonify({"ok": False, "error": "min_pressure must be a number"}), 400
|
|
if max_p is not None:
|
|
try:
|
|
filters["max_pressure"] = float(max_p)
|
|
except Exception:
|
|
return jsonify({"ok": False, "error": "max_pressure must be a number"}), 400
|
|
|
|
total = database.count_readings(filters)
|
|
items = database.list_readings(filters, sort="desc", limit=page_size, offset=(page - 1) * page_size)
|
|
|
|
return jsonify({
|
|
"ok": True,
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": items,
|
|
})
|
|
|
|
|
|
@app.get("/api/readings/<int:reading_id>")
|
|
def get_reading(reading_id: int) -> Any:
|
|
row = database.get_reading(reading_id)
|
|
if not row:
|
|
return jsonify({"ok": False, "error": "Not found"}), 404
|
|
return jsonify({"ok": True, "item": row})
|
|
|
|
|
|
@app.post("/api/readings")
|
|
def create_reading() -> Any:
|
|
payload = request.get_json(silent=True) or {}
|
|
data, error = _validate_reading_payload(payload, partial=False)
|
|
if error:
|
|
return jsonify({"ok": False, "error": error}), 400
|
|
new_id = database.insert_reading(data)
|
|
return jsonify({"ok": True, "id": new_id}), 201
|
|
|
|
|
|
@app.put("/api/readings/<int:reading_id>")
|
|
def update_reading(reading_id: int) -> Any:
|
|
payload = request.get_json(silent=True) or {}
|
|
data, error = _validate_reading_payload(payload, partial=True)
|
|
if error:
|
|
return jsonify({"ok": False, "error": error}), 400
|
|
if not data:
|
|
return jsonify({"ok": False, "error": "Empty body"}), 400
|
|
updated = database.update_reading(reading_id, data)
|
|
if not updated:
|
|
return jsonify({"ok": False, "error": "Not found"}), 404
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.delete("/api/readings/<int:reading_id>")
|
|
def delete_reading(reading_id: int) -> Any:
|
|
deleted = database.delete_reading(reading_id)
|
|
if not deleted:
|
|
return jsonify({"ok": False, "error": "Not found"}), 404
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
host = _get_env("HOST", "0.0.0.0")
|
|
try:
|
|
port = int(_get_env("PORT", "5000"))
|
|
except Exception:
|
|
port = 5000
|
|
# 注意:根据用户要求,不在此处自动启动,只提供可运行入口
|
|
app.run(host=host, port=port)
|
|
|
|
|