refacto: reorganize files to easily add an API service

This commit is contained in:
Alexis Fourmaux 2026-05-09 18:21:45 +02:00
parent 5c480db410
commit 9c883a8eca
22 changed files with 26 additions and 23 deletions

1
server/app/.dockerignore Normal file
View file

@ -0,0 +1 @@
Dockerfile

6
server/app/Dockerfile Normal file
View file

@ -0,0 +1,6 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "consumer.py"]

View file

View file

@ -0,0 +1,65 @@
import json
import logging
from typing import Callable
import paho.mqtt.client as mqtt
from paho.mqtt.client import ConnectFlags
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode
from domain.exceptions import MessageBrokerError, InfrastructureError
from domain.entities import UplinkEvent
from ports import MessageBroker
log = logging.getLogger(__name__)
class PahoMqttBroker(MessageBroker):
def __init__(self, host: str, port: int, topic: str):
self._host = host
self._port = port
self._topic = topic
def start(self, on_uplink: Callable[[UplinkEvent], None]) -> None:
_client = mqtt.Client(CallbackAPIVersion.VERSION2)
def _on_connect(
client: mqtt.Client,
userdata: None,
flags: ConnectFlags,
reason_code: ReasonCode,
props: Properties | None,
) -> None:
log.info("MQTT connecté (code=%s)", reason_code)
client.subscribe(self._topic, qos=1)
def _on_message(client: mqtt.Client, userdata: None, msg: mqtt.MQTTMessage) -> None:
try:
body = json.loads(msg.payload)
except json.JSONDecodeError as e:
log.error("Payload JSON invalide sur %s : %s", msg.topic, e)
return
try:
dev_eui = body["deviceInfo"]["devEui"]
pulse_count = int(body["object"]["pulse_count"])
except (KeyError, ValueError, TypeError) as e:
log.error("Champs manquants ou invalides : %s | body=%s", e, body)
return
try:
on_uplink(UplinkEvent(dev_eui=dev_eui, pulse_count=pulse_count))
except InfrastructureError as e:
log.error("Erreur infrastructure pour %s : %s", dev_eui, e)
_client.on_connect = _on_connect
_client.on_message = _on_message
_client.reconnect_delay_set(min_delay=1, max_delay=30)
try:
_client.connect(self._host, self._port, keepalive=60)
except OSError as e:
raise MessageBrokerError(f"Impossible de joindre {self._host}:{self._port} : {e}") from e
_client.loop_forever()

View file

@ -0,0 +1,63 @@
import logging
import time
import psycopg2
from psycopg2.extensions import connection
from ports import DeviceRepository, ReadingRepository
from domain.exceptions import DatabaseConnectionError, DatabaseError
log = logging.getLogger(__name__)
def connect(uri: str, retries: int = 10, base_delay: float = 1.0) -> connection:
for attempt in range(retries):
try:
conn = psycopg2.connect(uri)
conn.autocommit = True
log.info("PostgreSQL connecté")
return conn
except psycopg2.OperationalError as e:
delay = min(base_delay * 2 ** attempt, 30.0)
log.warning("Attente PostgreSQL (tentative %d/%d) : %s", attempt + 1, retries, e)
time.sleep(delay)
raise DatabaseConnectionError(f"Impossible de se connecter après {retries} tentatives")
class PgDeviceRepository(DeviceRepository):
def __init__(self, conn: connection):
self._conn = conn
def get_or_create_device_id(self, dev_eui: str) -> str:
try:
with self._conn.cursor() as cur:
cur.execute(
"""
INSERT INTO device (device_eui)
VALUES (%s)
ON CONFLICT (device_eui) DO NOTHING
""",
(dev_eui,),
)
cur.execute(
"SELECT device_id FROM device WHERE device_eui = %s", (dev_eui,)
)
return str(cur.fetchone()[0])
except psycopg2.DatabaseError as e:
raise DatabaseError(f"Erreur de création du device {dev_eui}") from e
class PgReadingRepository(ReadingRepository):
def __init__(self, conn: connection):
self._conn = conn
def insert_reading(self, device_id: str, pulse_count: int) -> None:
try:
with self._conn.cursor() as cur:
cur.execute(
"""
INSERT INTO reading (device_id, date, pulses)
VALUES (%s, NOW(), %s)
""",
(device_id, pulse_count),
)
except psycopg2.DatabaseError as e:
raise DatabaseError(f"Erreur d'enregistrement de la télérelève sur le device {device_id}") from e

31
server/app/consumer.py Normal file
View file

@ -0,0 +1,31 @@
import logging
import sys
import os
from adapters.postgres import connect, PgDeviceRepository, PgReadingRepository
from adapters.mqtt import PahoMqttBroker
from services.uplink_service import UplinkService
MQTT_HOST = os.getenv("MQTT_HOST", "mosquitto")
MQTT_PORT = int(os.getenv("MQTT_PORT", 1883))
MQTT_TOPIC = os.getenv("MQTT_TOPIC", "application/+/device/+/event/up")
DB_URI = os.getenv("DATABASE_URL", "postgresql://simugaz:simugaz@db/simugaz")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
stream=sys.stdout,
force=True,
)
if __name__ == "__main__":
conn = connect(DB_URI)
broker = PahoMqttBroker(MQTT_HOST, MQTT_PORT, MQTT_TOPIC)
devices = PgDeviceRepository(conn)
readings = PgReadingRepository(conn)
uplink = UplinkService(devices, readings)
broker.start(on_uplink=uplink.handle)

View file

View file

@ -0,0 +1,3 @@
from .uplink_event import UplinkEvent
__all__ = ["UplinkEvent"]

View file

@ -0,0 +1,6 @@
from dataclasses import dataclass
@dataclass
class UplinkEvent:
dev_eui: str
pulse_count: int

View file

@ -0,0 +1,13 @@
# domain/exceptions.py (ou dans domain.py directement)
class InfrastructureError(Exception):
"""Erreur technique levée par un adapter"""
class DatabaseConnectionError(InfrastructureError):
"""Impossible de se connecter à la db"""
class DatabaseError(InfrastructureError):
"""Erreur lors d'une opération en base de données."""
class MessageBrokerError(InfrastructureError):
"""Impossible de se connecter au broker MQTT"""

View file

@ -0,0 +1,5 @@
from .device_repository import DeviceRepository
from .reading_repository import ReadingRepository
from .message_broker import MessageBroker
__all__ = ["DeviceRepository", "ReadingRepository", "MessageBroker"]

View file

@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
class DeviceRepository(ABC):
@abstractmethod
def get_or_create_device_id(self, dev_eui: str) -> str:
"""Retourne le device_id, crée le device s'il est inconnu"""
...

View file

@ -0,0 +1,10 @@
from abc import ABC, abstractmethod
from typing import Callable
from domain.entities import UplinkEvent
class MessageBroker(ABC):
@abstractmethod
def start(self, on_uplink: Callable[[UplinkEvent], None]) -> None:
"""Démarre l'écoute et appelle on_uplink(UplinkEvent) à chaque message"""
...

View file

@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
class ReadingRepository(ABC):
@abstractmethod
def insert_reading(self, device_id: str, pulse_count: int) -> None:
"""Persiste un relevé"""
...

View file

@ -0,0 +1,2 @@
paho-mqtt==v2.1.0
psycopg2-binary

View file

View file

@ -0,0 +1,18 @@
import logging
from ports import DeviceRepository, ReadingRepository
from domain.entities import UplinkEvent
log = logging.getLogger(__name__)
class UplinkService:
def __init__(self, devices: DeviceRepository, readings: ReadingRepository):
self._devices = devices
self._readings = readings
def handle(self, event: UplinkEvent) -> None:
device_id = self._devices.get_or_create_device_id(event.dev_eui)
self._readings.insert_reading(device_id, event.pulse_count)
log.info(
"[UP] dev_eui=%s | device_id=%s | pulses=%d",
event.dev_eui, device_id, event.pulse_count
)

44
server/docker-compose.yml Normal file
View file

@ -0,0 +1,44 @@
services:
consumer:
build: ./app
restart: unless-stopped
networks:
- lora-gateway_mqtt
- database
db:
image: postgres:18-alpine
restart: unless-stopped
volumes:
- ./initdb:/docker-entrypoint-initdb.d:ro
- db:/var/lib/postgresql
networks:
- database
environment:
- POSTGRES_USER=simugaz
- POSTGRES_PASSWORD=simugaz
- POSTGRES_DB=simugaz
pgadmin:
image: dpage/pgadmin4:latest
restart: unless-stopped
ports:
- 8081:80
networks:
- public
- database
volumes:
- ./servers.json:/pgadmin4/servers.json
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: changeme
volumes:
db:
networks:
public:
database:
internal: true
lora-gateway_mqtt:
external: true

View file

@ -0,0 +1,63 @@
CREATE TABLE IF NOT EXISTS "device" (
"device_id" UUID NOT NULL DEFAULT uuidv4(),
"device_eui" VARCHAR(255) NOT NULL UNIQUE,
"site_id" UUID,
PRIMARY KEY("device_id")
);
CREATE TABLE IF NOT EXISTS "site" (
"site_id" UUID NOT NULL DEFAULT uuidv4(),
"pce" VARCHAR(255) NOT NULL UNIQUE,
"address_1" VARCHAR(255) NOT NULL,
"address_2" VARCHAR(255),
"postal_code" VARCHAR(255) NOT NULL,
"city" VARCHAR(255) NOT NULL,
PRIMARY KEY("site_id")
);
CREATE TABLE IF NOT EXISTS "user" (
"user_id" UUID NOT NULL DEFAULT uuidv4(),
"name" VARCHAR(255) NOT NULL,
"first_name" VARCHAR(255) NOT NULL,
"email" VARCHAR(255) NOT NULL UNIQUE,
"password_hash" VARCHAR(255),
"user_type_id" UUID NOT NULL,
PRIMARY KEY("user_id")
);
CREATE TABLE IF NOT EXISTS "reading" (
"reading_id" UUID NOT NULL DEFAULT uuidv7(),
"device_id" UUID NOT NULL,
"date" TIMESTAMP NOT NULL,
"pulses" INTEGER NOT NULL,
PRIMARY KEY("reading_id")
);
CREATE TABLE IF NOT EXISTS "subscription" (
"subscription_id" UUID NOT NULL DEFAULT uuidv4(),
"site_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
PRIMARY KEY("subscription_id")
);
CREATE TABLE IF NOT EXISTS "user_type" (
"user_type_id" UUID NOT NULL DEFAULT uuidv4(),
"label" VARCHAR(255) NOT NULL,
PRIMARY KEY("user_type_id")
);
ALTER TABLE "device"
ADD FOREIGN KEY("site_id") REFERENCES "site"("site_id")
ON UPDATE NO ACTION ON DELETE NO ACTION;
ALTER TABLE "subscription"
ADD FOREIGN KEY("site_id") REFERENCES "site"("site_id")
ON UPDATE NO ACTION ON DELETE NO ACTION;
ALTER TABLE "subscription"
ADD FOREIGN KEY("user_id") REFERENCES "user"("user_id")
ON UPDATE NO ACTION ON DELETE NO ACTION;
ALTER TABLE "reading"
ADD FOREIGN KEY("device_id") REFERENCES "device"("device_id")
ON UPDATE NO ACTION ON DELETE NO ACTION;
ALTER TABLE "user"
ADD FOREIGN KEY("user_type_id") REFERENCES "user_type"("user_type_id")
ON UPDATE NO ACTION ON DELETE NO ACTION;

View file

@ -0,0 +1,43 @@
INSERT INTO "user_type" ("label") VALUES
('admin'),
('subscriber');
INSERT INTO "site" ("pce", "address_1", "address_2", "postal_code", "city") VALUES
('GI123456789', '12 Rue de la Paix', NULL, '75001', 'Paris'),
('GI987654321', '5 Avenue Foch', 'Bât B', '69003', 'Lyon'),
('GI111222333', '8 Rue du Moulin', NULL, '59000', 'Lille'),
('GI444555666', '27 Boulevard Victor', 'Apt 12', '33000', 'Bordeaux');
-- Mots de passe d'exemple : (hash générés avec `mkpasswd --method=bcrypt --rounds=12 <mdp>`)
-- AdminPass123!
-- BobPass456!
-- ClairePass789!
-- DavidPass012
INSERT INTO "user" ("name", "first_name", "email", "password_hash", "user_type_id") VALUES
('Dupont', 'Alice', 'alice.dupont@example.com', '$2b$12$qdHcvSLkbflmHn45gokjX.zm27JxamMBplA/l4y4D2GuykDvjJll.', (SELECT "user_type_id" FROM "user_type" WHERE "label" = 'admin')),
('Martin', 'Bernard', 'bernard.martin@example.com', '$2b$12$3ulCb.7b9LeQv2edkmju2uwtn8bA/1jpj4K5n51DxH6HYDme0Gbfq', (SELECT "user_type_id" FROM "user_type" WHERE "label" = 'subscriber')),
('Durand', 'Claire', 'claire.durand@example.com', '$2b$12$nzYUQG/SHV9uvOxtYJ5XWOnUT1bgiUS0FejgFl.Y57Pz0LB9U5ia6', (SELECT "user_type_id" FROM "user_type" WHERE "label" = 'subscriber')),
('Leroy', 'David', 'david.leroy@example.com', '$2b$12$Yy/K3.kghkrYtflPUGjuM.cj6pbCP/Bc4sasLlkbA7RgdlK1wzR2u', (SELECT "user_type_id" FROM "user_type" WHERE "label" = 'subscriber'));
INSERT INTO "subscription" ("site_id", "user_id") VALUES
(
(SELECT "site_id" FROM "site" WHERE "pce" = 'GI123456789'),
(SELECT "user_id" FROM "user" WHERE "email" = 'bernard.martin@example.com')),
(
(SELECT "site_id" FROM "site" WHERE "pce" = 'GI987654321'),
(SELECT "user_id" FROM "user" WHERE "email" = 'claire.durand@example.com')),
(
(SELECT "site_id" FROM "site" WHERE "pce" = 'GI111222333'),
(SELECT "user_id" FROM "user" WHERE "email" = 'david.leroy@example.com')),
((SELECT "site_id" FROM "site" WHERE "pce" = 'GI444555666'),
(SELECT "user_id" FROM "user" WHERE "email" = 'david.leroy@example.com'));
INSERT INTO "device" ("device_eui", "site_id")
VALUES (
'0586fe41112d83d9',
(SELECT "site_id" FROM "site" WHERE "pce" = 'GI123456789')
);

16
server/servers.json Normal file
View file

@ -0,0 +1,16 @@
{
"Servers": {
"1": {
"Name": "SimuGaz",
"Group": "SimuGaz",
"Port": 5432,
"Username": "simugaz",
"Host": "db",
"MaintenanceDB": "postgres",
"ConnectionParameters": {
"sslmode": "prefer",
"connect_timeout": 10
}
}
}
}