No description
Find a file
Adam Belkacemi b401fb96b1 Documente la publication WHIP depuis un navigateur
MediaMTX accepte l'ingestion WebRTC montante et sert une page de
publication. Trois proprietes qu'aucune application ne reunit : rien a
installer donc aucune limite de duree imposee par un editeur, tout passe
par le 443 donc ca traverse un reseau qui ne laisse sortir que HTTPS, et
WebRTC de bout en bout donc la latence minimale sans transcodage.

Contrepartie assumee : iOS suspend la capture des que l'ecran se
verrouille. Acceptable pour un capteur fixe et alimente, insuffisant pour
du 24/7 autonome - ou une application native reste preferable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:02:28 +02:00
deploy Chemin du flux en deux segments, et revalidation de la console 2026-09-12 18:51:17 +02:00
src Chemin du flux en deux segments, et revalidation de la console 2026-09-12 18:51:17 +02:00
.dockerignore Systeme d'alarme IoT evenementiel : hexagonal, CQRS, Kafka 2026-09-12 17:32:45 +02:00
.env.example Sonde de presence video : la camera muette devient un evenement capteur 2026-09-12 18:24:45 +02:00
.gitignore Flux video temps reel : l'iPhone pousse, MediaMTX restitue 2026-09-12 18:08:34 +02:00
docker-compose.yml Sonde de presence video : la camera muette devient un evenement capteur 2026-09-12 18:24:45 +02:00
Dockerfile Systeme d'alarme IoT evenementiel : hexagonal, CQRS, Kafka 2026-09-12 17:32:45 +02:00
pom.xml Systeme d'alarme IoT evenementiel : hexagonal, CQRS, Kafka 2026-09-12 17:32:45 +02:00
README.md Documente la publication WHIP depuis un navigateur 2026-09-12 19:02:28 +02:00

iot-alarm-system

Système d'alarme événementiel pour capteurs IoT. Java 21 · Spring Boot 3 · Apache Kafka · Architecture hexagonale · CQRS · DDD

🔗 Démonstration en ligne : https://video-seurveillance.belkacemi.me


Le contexte

Mon studio a été cambriolé.

Les offres de vidéosurveillance du marché se ressemblent toutes : une caméra à 80 €, puis un abonnement mensuel pour avoir le droit d'accéder à ses propres images. J'avais un vieil iPhone dans un tiroir et une bibliothèque bien remplie. L'iPhone est désormais calé entre deux livres, objectif vers la porte d'entrée.

Ce dépôt est le backend qui écoute ce capteur : il ingère les détections de mouvement, décide lesquelles constituent une intrusion, et pousse l'alerte en temps réel sur une console de supervision.

Le problème est volontairement simple. Ce qui est travaillé ici, c'est la structure : un domaine métier isolé du framework, un bus d'événements, une séparation lecture/écriture, et des règles d'architecture vérifiées par les tests plutôt que par la discipline.


Le flux

Deux flux, deux chemins

Le système transporte deux choses de nature très différente, et elles ne suivent volontairement pas le même chemin :

                    ┌─────────────────────┐
                    │   iPhone (capteur)  │
                    └──────────┬──────────┘
             événements        │        vidéo
          (quelques octets)    │    (800 kbps continu)
                    ┌──────────┴──────────┐
                    ▼                     ▼
            ┌───────────────┐     ┌────────────────┐
            │  Spring Boot  │     │   MediaMTX     │
            │  Kafka · JPA  │     │  SRT → WebRTC  │
            │  domaine      │     │  transport pur │
            └───────┬───────┘     └────────┬───────┘
                    └──────────┬───────────┘
                               ▼
                     Console web (une seule page)

La vidéo ne traverse jamais la JVM. Faire transiter 800 kbps continus par Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM garde le domaine métier. Les deux se rejoignent seulement dans le navigateur.

Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de fonctionner, et inversement.

Le flux d'événements en détail

 ┌──────────────────┐
 │  iPhone 6s       │  Détection de mouvement
 │  "iphone-biblio" │  {"deviceId":"iphone-biblio","type":"MOTION_DETECTED"}
 └────────┬─────────┘
          │  POST /api/v1/sensor-events
          ▼
 ┌────────────────────────────┐
 │ SensorIngestController     │  ADAPTER ENTRANT
 │ → 202 Accepted             │  Répond avant tout traitement métier :
 └────────┬───────────────────┘  le capteur n'attend pas la base.
          │
          ▼
 ┌────────────────────────────┐
 │ SensorEventKafkaPublisher  │  ADAPTER SORTANT
 └────────┬───────────────────┘  Clé de partition = deviceId → ordre garanti
          │                       par capteur.
          ▼
    ╔═══════════════════════╗
    ║  KAFKA  sensor-events ║   3 partitions · acks=all · idempotent
    ╚═══════════┬═══════════╝   (poison pill → sensor-events-dlq)
                │
                ▼
 ┌────────────────────────────┐
 │ SensorEventKafkaConsumer   │  ADAPTER ENTRANT
 └────────┬───────────────────┘  Désérialise, délègue, instrumente. Zéro règle.
          │
          ▼
 ┌────────────────────────────────────────────────────────┐
 │  AlarmSystem.evaluate()          ◄── LE CŒUR DU SYSTÈME│
 │                                                        │
 │  1. alarme désarmée ?          → Ignored(ALARM_DISARMED)
 │  2. heartbeat ?                → Ignored(NOT_INTRUSION_RELEVANT)
 │  3. délai de sortie en cours ? → Ignored(WITHIN_EXIT_DELAY)
 │  4. confiance < seuil ?        → Ignored(CONFIDENCE_TOO_LOW)
 │  5. alerte récente ?           → Ignored(WITHIN_COOLDOWN)
 │                                                        │
 │  sinon → TRIGGERED + Alert + IntrusionDetected         │
 └────────┬───────────────────────────────────────────────┘
          │
    ┌─────┴─────────────────────────┐
    ▼                               ▼
 ┌──────────────────┐    ┌──────────────────────────┐
 │  PostgreSQL      │    │  WebSocket STOMP         │  après commit
 │  (write model)   │    │  /topic/alerts           │  uniquement
 └──────────────────┘    └────────────┬─────────────┘
                                      ▼
                         ┌──────────────────────────┐
                         │  Console : fond rouge,   │
                         │  "INTRUSION DÉTECTÉE"    │
                         └──────────────────────────┘

L'architecture

Hexagonale (ports & adapters)

domain/          Le métier. Zéro import de framework.
  model/           AlarmSystem (agrégat racine), Alert, SensorEvent,
                   AlarmPolicy, AlarmDecision (sealed)
  event/           DomainEvent (sealed) : IntrusionDetected, AlarmArmed…
  port/in/         Ports primaires  — ce que le système sait faire
  port/out/        Ports secondaires — ce dont il a besoin (SPI)

application/     Orchestration. Charge, appelle, persiste, notifie.
  command/         Côté écriture du CQRS
  query/           Côté lecture — son propre port, son propre modèle

infrastructure/  Les adapters. C'est ici, et seulement ici, qu'on trouve
                 les mots « Kafka », « JPA », « WebSocket ».

presentation/    REST + WebSocket. Traduit HTTP en commandes.

Les dépendances pointent vers l'intérieur. Le domaine ne connaît ni Spring, ni Kafka, ni PostgreSQL — il déclare des interfaces (SensorEventPublisher, AlertRepository) que l'infrastructure implémente.

Conséquence mesurable : les 13 tests de AlarmSystemTest couvrent l'intégralité de la logique de détection et s'exécutent en 48 millisecondes, sans conteneur, sans base, sans Thread.sleep.

Ces règles sont testées, pas documentées

Un schéma dans un README dérive en trois sprints. Celles-ci cassent le build :

@Test
void domainHasNoFrameworkDependency() {
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAnyPackage(
            "org.springframework..", "jakarta.persistence..",
            "org.apache.kafka..", "io.micrometer..")
        .check(classes);
}

7 règles ArchUnit s'exécutent à chaque mvn test — et à chaque docker build. Une violation de l'architecture hexagonale ne franchit pas l'étape de packaging.

CQRS

La séparation n'est pas cosmétique : les deux côtés ne partagent aucun modèle.

Écriture Lecture
Entrée ArmAlarmCommand, IngestSensorEventCommand AlarmQueryService
Chemin passe par l'agrégat, applique les invariants requête directe, ne charge jamais l'agrégat
Modèle Alert (entité, comportement) AlertView (record, libellés pré-calculés)
Port domain.port.out application.query.port
Transaction @Transactional @Transactional(readOnly = true)

Compter les alertes non acquittées se fait par un COUNT(*), pas en réhydratant un agrégat dont on n'appellerait aucune méthode. Brancher un cache Redis ou un index Elasticsearch sur la lecture ne demanderait qu'une nouvelle implémentation de AlarmReadModelPort.

DDD

  • Agrégat racine — AlarmSystem est le seul point d'entrée pour modifier l'état. Aucun service ne décide à sa place.
  • Value Objects — DeviceId valide son format à la construction ; au-delà de la frontière, aucune String non validée ne circule.
  • Entité — Alert a une identité (AlertId, générée par le domaine et non par la base) et un cycle de vie (acquittement à usage unique).
  • Interfaces scellées — AlarmDecision est Triggered | Ignored(reason). Le switch qui la consomme est vérifié exhaustivement par le compilateur : ajouter un troisième cas casse la compilation au lieu de tomber dans un else.
  • Un rejet dit pourquoi — IgnoreReason alimente à la fois les logs et un tag Micrometer. « Pourquoi l'alarme n'a pas sonné cette nuit ? » se répond par une requête PromQL, pas par une lecture de logs.

Le flux vidéo

Le téléphone pousse, on ne l'interroge pas

C'est la contrainte qui a dicté toute cette partie. Un iPhone n'a aucune adresse joignable depuis Internet : en 4G il est derrière le CGNAT de l'opérateur, sur le WiFi du studio derrière le NAT de la box, et l'application signale UPnP unavailable. Le serveur RTSP intégré à l'application caméra est donc inutilisable de l'extérieur, quelle que soit la configuration.

La seule direction qui traverse ces deux NAT est la sortante. Le téléphone pousse son flux vers le serveur :

iPhone ──SRT (UDP 8890)──▶ MediaMTX ──┬──▶ WebRTC   < 1 s
       └─RTMP (TCP 1935)──▶           └──▶ HLS      ~ 6 s

SRT plutôt que RTMP quand le choix est possible : RTMP est du TCP, et sur un lien mobile un paquet perdu déclenche une retransmission qui bloque tout le reste. SRT transporte en UDP avec sa propre retransmission sélective — c'est exactement le protocole conçu pour ce cas.

WebRTC plutôt que HLS côté lecture : voir quelqu'un six secondes après son passage n'a pas le même intérêt. La console tente WebRTC (WHEP) d'abord et bascule sur HLS si l'UDP est filtré — ce qui arrive précisément sur les réseaux d'entreprise.

Ce qui est protégé, ce qui ne l'est pas

Lecture publique — le flux est une vitrine, c'est assumé
Publication authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux biblio

La caméra muette est un événement comme un autre

Le domaine connaissait déjà CAMERA_OFFLINE, classé CRITICAL — un capteur qui se tait alors que l'alarme est armée, c'est soit une batterie morte, soit quelqu'un qui l'a trouvé derrière les livres. Rien ne l'émettait jamais. CameraLivenessProbe comble ce trou : elle interroge l'API du serveur média et traduit une absence prolongée en événement capteur.

Elle passe par le même port IngestSensorEventUseCase que l'endpoint REST et le simulateur. Trois producteurs d'événements de natures totalement différentes — un téléphone, un simulateur, une sonde de supervision — un seul contrat d'entrée, et le même trajet Kafka → agrégat ensuite. Si l'alarme est désarmée, une coupure vidéo ne réveille personne : c'est l'agrégat qui tranche, pas la sonde.

Le seuil est la partie qui compte. Sur un lien 4G, le flux tombe régulièrement sans que personne n'ait touché au téléphone : tunnel, bascule d'antenne, congestion. Alerter dès la première sonde ratée produirait plusieurs CRITICAL par jour — et la seule chose qu'on apprend à vivre avec des alertes qui crient pour rien, c'est à les ignorer, y compris le jour où elles ont raison. Il faut donc trois sondes consécutives, soit une minute d'absence réelle.

Brancher un vrai téléphone

Larix Broadcaster (iOS/Android, gratuite, sans filigrane) : elle gère SRT, la reconnexion automatique et le débit adaptatif. C'est ce qu'il faut sur un lien mobile. IP Camera Lite sait aussi pousser, mais elle est conçue pour le mode pull — lequel ne peut pas fonctionner depuis l'extérieur.

SRT    srt://<serveur>:8890  ·  streamid = publish:biblio:iphone:<mot-de-passe>
RTMP   rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>

Réglages pour la 4G : H.264, 640×360, 15 fps, 400–600 kbps, latence SRT 2000 ms, audio coupé. À 500 kbps, compter ~225 Mo par heure — un flux permanent dépasse 5 Go par jour, ce qui n'a de sens que sur le WiFi du studio.

Le mot de passe est généré au déploiement dans le .env du serveur (STREAM_PUBLISH_PASSWORD) et n'existe nulle part dans ce dépôt.

Pour valider la chaîne sans téléphone, une mire suffit :

docker run --rm --network iot-alarm-internal jrottenberg/ffmpeg:6.1-alpine \
  -re -f lavfi -i testsrc=size=640x360:rate=25 \
  -c:v libx264 -preset ultrafast -tune zerolatency -b:v 800k -pix_fmt yuv420p \
  -f flv "rtmp://mediamtx:1935/biblio?user=iphone&pass=$PASS"

Publier sans aucune application

MediaMTX accepte aussi l'ingestion WHIP — WebRTC dans le sens montant — et sert une page de publication prête à l'emploi :

https://<user>:<mot-de-passe>@<domaine>/webrtc/live/biblio/publish

Ouverte dans le navigateur du téléphone, elle capte la caméra et pousse le flux. Trois propriétés qu'aucune application n'offre en même temps :

  • rien à installer, donc aucune limite de durée imposée par un éditeur ;
  • tout passe par le port 443, donc ça fonctionne même derrière un réseau qui ne laisse sortir que HTTPS — là où SRT et RTMP sont bloqués ;
  • WebRTC de bout en bout, donc la latence minimale sans transcodage.

La contrepartie est réelle : iOS suspend la capture caméra dès que l'écran se verrouille ou que l'onglet passe en arrière-plan. Il faut donc désactiver le verrouillage automatique et laisser le téléphone alimenté — ce qui est de toute façon le cas d'un capteur fixe posé dans une bibliothèque.

Pour un fonctionnement 24/7 réellement autonome, une application native reste préférable : Moblin (open source, gratuite, sans limite) diffuse en arrière-plan et se reconnecte seule.

Le piège du reverse proxy

MediaMTX répond d'abord une redirection vers un chemin absolu (/biblio/index.m3u8?cookieCheck=1). Derrière un handle_path /hls/* qui retire le préfixe, le navigateur suivait cette redirection jusqu'à l'application Spring Boot et recevait du JSON là où il attendait une playlist. Le header_down Location du snippet Caddy réécrit le préfixe. Symptôme trompeur : curl -I renvoyait un 404 sans rapport, le serveur HLS ne gérant simplement pas la méthode HEAD.


Les décisions non évidentes

Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce que c'est précisément ce qui distingue un projet de démonstration d'un système qu'on accepte de laisser tourner.

202 Accepted et non 201 Created — à cet instant, rien n'a été décidé. L'événement est sur le bus ; le traitement suivra. Le code de statut dit la vérité sur ce qui s'est passé.

Notification WebSocket après commit — pousser l'alerte dans la transaction signifierait afficher « INTRUSION » sur une écriture qui peut encore partir en rollback. AfterCommit diffère l'effet de bord jusqu'au commit effectif.

Entité JPA ≠ entité du domaine — annoter directement Alert aurait imposé un constructeur sans argument, des setters et des champs mutables : exactement ce que l'encapsulation cherche à empêcher. Le prix est un mapper ; le bénéfice est un domaine testable sans base.

SensorEventMessage ≠ SensorEvent — le format Kafka est un contrat public partagé avec un producteur qu'on ne maîtrise pas (l'app iPhone). Sérialiser l'objet du domaine ferait qu'un renommage de champ casse le capteur en production.

Anti-rafale dans l'agrégat — sans ce filtre, un intrus qui traverse la pièce génère quarante alertes en dix secondes. C'est une règle métier, elle est donc dans le domaine, pas dans le consumer.

Délai de sortie — la différence entre un système utilisable et un système qu'on finit par laisser désarmé en permanence.

ErrorHandlingDeserializer + DLQ — sans lui, un seul message malformé met le consumer dans une boucle de replay infinie et bloque tous les événements suivants, y compris une vraie intrusion.

Clock injectée — Instant.now() en dur rendrait le délai de sortie et l'anti-rafale intestables autrement qu'avec des Thread.sleep.

KRaft, pas Zookeeper — Zookeeper est déprécié depuis Kafka 3.5 et supprimé en 4.0. Le conserver ajouterait un conteneur, ~400 Mo de RAM et une pièce à maintenir, pour une fonction que le broker assure désormais lui-même.

ddl-auto: validate — le schéma appartient à Flyway. L'application refuse de démarrer si une entité et sa table ont divergé, plutôt que de tomber en marche sur la première requête.


Démarrage

Tout en conteneurs

grep -v '^DB_PASSWORD=' .env.example > .env
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" >> .env && chmod 600 .env

docker compose up -d --build
docker compose logs -f app

Console sur http://localhost:8080 — le simulateur de capteur produit du trafic dès le démarrage.

Pour un frontal Nginx en plus (http://localhost:8081) : docker compose --profile standalone up -d

En développement

docker compose up -d kafka                  # le broker seul
mvn spring-boot:run -Dspring-boot.run.profiles=local   # H2 en mémoire

Tests

mvn test                  # unitaires + ArchUnit — aucune infra requise
mvn verify -Pintegration  # Testcontainers (Kafka + PostgreSQL réels)

API

Méthode Route Rôle
POST /api/v1/sensor-events Ingestion capteur → Kafka (202)
POST /api/v1/alarm/arm Commande — armer
POST /api/v1/alarm/disarm Commande — désarmer
POST /api/v1/alerts/{id}/acknowledge Commande — acquitter
GET /api/v1/alarm/status Requête — état courant
GET /api/v1/alerts?limit=50 Requête — historique
WS /ws → /topic/alerts Push temps réel (STOMP)
POST /webrtc/biblio/whep Négociation WebRTC (servi par MediaMTX)
GET /hls/biblio/index.m3u8 Repli HLS (servi par MediaMTX)

Documentation OpenAPI : /swagger-ui.html

Déclencher une intrusion à la main :

curl -X POST http://localhost:8080/api/v1/alarm/arm
sleep 31   # laisser passer le délai de sortie

curl -X POST http://localhost:8080/api/v1/sensor-events \
  -H 'Content-Type: application/json' \
  -d '{"deviceId":"iphone-biblio","type":"MOTION_DETECTED","confidence":95}'

Les erreurs suivent la RFC 7807 (ProblemDetail). Une règle métier violée renvoie 409 et non 400 : la requête est bien formée, c'est l'état qui s'y oppose.


Production

Déployé sur un serveur Hetzner (Ubuntu, 2 vCPU, 4 Go) mutualisé avec d'autres services.

  • TLS — Caddy, déjà en place sur la machine et détenteur des ports 80/443, obtient et renouvelle les certificats Let's Encrypt automatiquement. Y démarrer un second frontal les aurait mis en conflit et coupé les services existants. Le bloc à ajouter est dans deploy/Caddyfile.snippet.

  • Deux pare-feux à traverser, pas un — le serveur a ufw, et le projet Hetzner Cloud a son propre pare-feu en amont de la machine. Ouvrir un port dans ufw ne suffit pas : le paquet n'arrive jamais jusqu'à ufw. Diagnostic sans ambiguïté — tcpdump sur l'hôte ne voit aucun paquet alors qu'un port autorisé, lui, apparaît. Les trois ports d'ingestion média (1935/tcp, 8890/udp, 8189/udp) doivent être ouverts des deux côtés :

    # Sur la machine
    ufw allow 1935/tcp && ufw allow 8890/udp && ufw allow 8189/udp
    
    # Puis dans la console Hetzner Cloud > Firewalls > règles entrantes,
    # ou via l'API :
    hcloud firewall add-rule <nom> --direction in --protocol tcp --port 1935 --source-ips 0.0.0.0/0
    hcloud firewall add-rule <nom> --direction in --protocol udp --port 8890 --source-ips 0.0.0.0/0
    hcloud firewall add-rule <nom> --direction in --protocol udp --port 8189 --source-ips 0.0.0.0/0
    

    Sans 8189/udp, la signalisation WebRTC aboutit mais aucun média ne circule : la console bascule silencieusement en HLS. Panne d'autant plus trompeuse que l'interface continue d'afficher une image.

  • Isolation réseau — Kafka et PostgreSQL vivent sur un réseau Docker privé sans aucun port publié sur l'hôte. Seule l'application rejoint le réseau partagé du reverse proxy.

  • Empreinte mémoire — limites de conteneur explicites (app 512 Mo, Kafka 800 Mo, PostgreSQL 256 Mo). La JVM utilise MaxRAMPercentage plutôt que -Xmx : le réglage suit la limite du cgroup sans être défini à deux endroits. SerialGC sur 2 vCPU partagés, où G1 coûte plus en threads qu'il ne rapporte.

  • Supervision — /actuator/prometheus exposé au Prometheus de la machine, bloqué côté public.

  • Conteneur non-root, healthchecks sur les trois services, arrêt gracieux.


Stack

Langage Java 21 (records, sealed interfaces, pattern matching, virtual threads)
Framework Spring Boot 3.4
Messagerie Apache Kafka 3.9 (KRaft)
Persistance PostgreSQL 16 · Spring Data JPA · Flyway
Temps réel WebSocket / STOMP / SockJS
Vidéo MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie
Build Maven · Docker multi-stage
Tests JUnit 5 · AssertJ · ArchUnit · Testcontainers
Observabilité Actuator · Micrometer / Prometheus

Adam Belkacemi — le studio est toujours debout.