Ajout du backend
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
import config
|
||||
|
||||
# --- Stockage ---------------------------------------------------------------
|
||||
if config.STORE_BACKEND == "firestore":
|
||||
from store_firestore import FirestoreStore as Store
|
||||
store = Store(project_id=config.PROJECT_ID) # noqa: F401 (voir config)
|
||||
else:
|
||||
from store_file import FileStore as Store
|
||||
store = Store(config.DATA_DIR)
|
||||
|
||||
# --- Logging ----------------------------------------------------------------
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||
log = logging.getLogger("iot-api")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
# --- Aide : réponses génériques ---------------------------------
|
||||
def err(status, code):
|
||||
return jsonify({"error": code}), status
|
||||
|
||||
|
||||
# --- Authentification dispositif ------------------------------
|
||||
def auth_device(headers):
|
||||
"""Retourne (device_id, error) — résout l'identité du device ou renvoie l'erreur."""
|
||||
device_id = headers.get("X-Device-ID")
|
||||
secret = headers.get("X-Device-Secret")
|
||||
|
||||
if not device_id:
|
||||
return None, err(401, "unauthorized")
|
||||
entry = config.DEVICE_SECRETS.get(device_id)
|
||||
if not entry or entry["secret"] != secret:
|
||||
log.warning("ingest refused bad-secret device=%s", device_id)
|
||||
return None, err(401, "unauthorized")
|
||||
|
||||
return device_id, None
|
||||
|
||||
|
||||
# --- Authentification utilisateur (mode démo ; Firebase en extension) --------
|
||||
def auth_user(headers):
|
||||
"""Retourne (username, error). Mode démo : token = nom d'utilisateur."""
|
||||
auth = headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return None, err(401, "unauthorized")
|
||||
token = auth[len("Bearer "):]
|
||||
if token not in config.DEMO_USERS:
|
||||
return None, err(401, "unauthorized")
|
||||
return token, None
|
||||
|
||||
|
||||
# --- Validation payload ------------------------------------------
|
||||
def validate_payload(payload, device_id):
|
||||
"""Valide le payload. Retourne (measure, error)."""
|
||||
if not isinstance(payload, dict):
|
||||
return None, err(400, "invalid-payload")
|
||||
|
||||
pid = payload.get("device_id")
|
||||
if pid != device_id:
|
||||
# L'identité déclarée dans le body doit correspondre à l'en-tête.
|
||||
return None, err(400, "invalid-payload")
|
||||
|
||||
ts = payload.get("timestamp")
|
||||
try:
|
||||
datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return None, err(400, "invalid-payload")
|
||||
|
||||
light = payload.get("light_value")
|
||||
if not isinstance(light, int):
|
||||
return None, err(400, "invalid-payload")
|
||||
if not (config.LIGHT_MIN <= light <= config.LIGHT_MAX):
|
||||
log.warning(
|
||||
"ingest refused invalid-payload device=%s (light out of range)",
|
||||
device_id,
|
||||
)
|
||||
return None, err(400, "invalid-payload")
|
||||
|
||||
return {"device_id": pid, "timestamp": ts, "light_value": light}, None
|
||||
|
||||
|
||||
# --- Endpoints ----------------------------------------------------------------
|
||||
@app.post("/api/ingest")
|
||||
def ingest():
|
||||
device_id, aerr = auth_device(request.headers)
|
||||
if aerr is not None:
|
||||
return aerr
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
measure, verr = validate_payload(payload, device_id)
|
||||
if verr is not None:
|
||||
return verr
|
||||
|
||||
store.add_measure(measure)
|
||||
log.info("ingest ok device=%s", device_id)
|
||||
return jsonify({"ok": True}), 201
|
||||
|
||||
|
||||
@app.get("/api/measures")
|
||||
def list_measures():
|
||||
username, aerr = auth_user(request.headers)
|
||||
if aerr is not None:
|
||||
return aerr
|
||||
|
||||
device_id = request.args.get("device_id", "")
|
||||
|
||||
# Autorisation SUR LA RESSOURCE: l'utilisateur ne lit
|
||||
# que les devices dont il est propriétaire.
|
||||
if device_id not in config.DEMO_USERS[username]["devices"]:
|
||||
log.warning("measures refused user=%s device=%s", username, device_id)
|
||||
return err(403, "forbidden")
|
||||
|
||||
measures = store.list_measures(device_id)
|
||||
log.info("measures ok user=%s device=%s", username, device_id)
|
||||
return jsonify(measures)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host=config.HOST, port=config.PORT)
|
||||
@@ -0,0 +1,30 @@
|
||||
DEVICE_SECRETS = {
|
||||
"esp32-01": {"secret": "demo-secret-esp32-01", "owner": "demo-user-alice"},
|
||||
"esp32-02": {"secret": "demo-secret-esp32-02", "owner": "demo-user-bob"},
|
||||
}
|
||||
|
||||
# Utilisateurs de démonstration (mode local, sans Firebase).
|
||||
# En production : tokens émis par le fournisseur d'identité.
|
||||
DEMO_USERS = {
|
||||
"demo-user-alice": {"devices": ["esp32-01"]},
|
||||
"demo-user-bob": {"devices": ["esp32-02"]},
|
||||
}
|
||||
|
||||
# --- Stockage ---
|
||||
# "file" : fichier JSON local (aucun compte cloud).
|
||||
# "firestore" : extension documentée (store_firestore.py).
|
||||
STORE_BACKEND = "file"
|
||||
DATA_DIR = "data"
|
||||
|
||||
# --- Validation ---
|
||||
LIGHT_MIN = 0
|
||||
LIGHT_MAX = 100000
|
||||
|
||||
# --- Sécurité ---
|
||||
# Secret du compte de service Firestore (si STORE_BACKEND=firestore) :
|
||||
|
||||
# --- Divers ---
|
||||
# 0.0.0.0 : accessible depuis le réseau local (nécessaire pour l'ESP32).
|
||||
#
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 5000
|
||||
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
|
||||
class FileStore:
|
||||
def __init__(self, data_dir):
|
||||
self.path = os.path.join(data_dir, "measures.json")
|
||||
self._lock = threading.Lock()
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
if not os.path.exists(self.path):
|
||||
self._write([])
|
||||
|
||||
def _read(self):
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def _write(self, data):
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def add_measure(self, measure):
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
data.append(measure)
|
||||
self._write(data)
|
||||
|
||||
def list_measures(self, device_id, limit=50):
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
return [m for m in data if m["device_id"] == device_id][-limit:]
|
||||
@@ -0,0 +1,18 @@
|
||||
class FirestoreStore:
|
||||
def __init__(self, project_id, collection="measures"):
|
||||
from google.cloud import firestore # import tardif : optionnel
|
||||
|
||||
self.db = firestore.Client(project=project_id)
|
||||
self.collection = collection
|
||||
|
||||
def add_measure(self, measure):
|
||||
self.db.collection(self.collection).add(measure)
|
||||
|
||||
def list_measures(self, device_id, limit=50):
|
||||
docs = (
|
||||
self.db.collection(self.collection)
|
||||
.where("device_id", "==", device_id)
|
||||
.limit(limit)
|
||||
.stream()
|
||||
)
|
||||
return [d.to_dict() for d in docs]
|
||||
Reference in New Issue
Block a user