Systeme d'alarme IoT evenementiel : hexagonal, CQRS, Kafka

Backend d'un systeme de video-surveillance dont le capteur est un iPhone
recycle. Le probleme est simple, la structure est le sujet :

- domain/ sans aucun import de framework (verifie par 7 regles ArchUnit
  qui tournent aussi pendant le docker build)
- CQRS strict : write et read ne partagent ni modele ni port
- AlarmDecision en sealed interface -> switch exhaustif verifie a la
  compilation, plutot qu'une chaine de if
- Kafka en KRaft (Zookeeper est supprime depuis Kafka 4.0), cle de
  partition = deviceId pour garantir l'ordre par capteur
- ErrorHandlingDeserializer + DLQ : un message malforme ne bloque pas
  le flux des evenements suivants
- notification WebSocket differee apres commit, pour ne jamais afficher
  une intrusion qui partirait en rollback

31 tests au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-12 17:32:45 +02:00
commit 63ead8a966
81 changed files with 5051 additions and 0 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
target/
.git/
.idea/
*.iml
.DS_Store
README.md
docs/
deploy/
.env
.env.*

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Copier en .env et renseigner avant le premier demarrage :
# cp .env.example .env && sed -i "s/CHANGEME/$(openssl rand -hex 24)/" .env
# Mot de passe PostgreSQL. Obligatoire : le compose refuse de demarrer sans.
DB_PASSWORD=CHANGEME
# Simulateur de capteur. A desactiver le jour ou le vrai iPhone POSTe
# sur /api/v1/sensor-events.
SIMULATOR_ENABLED=true
SIMULATOR_INTERVAL=8s
# Reglages de sensibilite de l'alarme.
ALARM_EXIT_DELAY=30s
ALARM_MIN_CONFIDENCE=60
LOG_LEVEL=INFO

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
target/
!.mvn/wrapper/maven-wrapper.jar
.mvn/wrapper/maven-wrapper.properties.bak
### IDE ###
.idea/
*.iml
*.iws
.vscode/
.settings/
.classpath
.project
### OS ###
.DS_Store
Thumbs.db
### Logs & runtime ###
*.log
logs/
data/
### Secrets ###
.env
.env.*
!.env.example

52
Dockerfile Normal file
View file

@ -0,0 +1,52 @@
# =============================================================================
# Etage 1 : compilation
# Le build tourne dans le conteneur, avec le JDK 21 exact de la cible. Aucune
# dependance au JDK de la machine qui lance le build - la machine de dev est
# en 17, le serveur n'a pas de JDK du tout.
# =============================================================================
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
# Les dependances changent rarement, le code souvent. En copiant le pom seul
# d'abord, un simple changement de code ne re-telecharge pas tout Maven Central.
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline -q
COPY src ./src
# Les tests unitaires et les regles ArchUnit tournent ICI : une violation de
# l'architecture hexagonale casse le build de l'image, pas la production.
# Les tests d'integration Testcontainers sont exclus (groupe "integration"),
# ils demanderaient un Docker dans le Docker.
RUN --mount=type=cache,target=/root/.m2 mvn -B clean package
# =============================================================================
# Etage 2 : execution
# JRE seul, sans Maven ni sources : ~180 Mo au lieu de ~900 Mo.
# =============================================================================
FROM eclipse-temurin:21-jre-alpine
# wget pour le healthcheck, tzdata pour des logs a l'heure de Paris.
RUN apk add --no-cache wget tzdata && \
addgroup -S alarm && adduser -S alarm -G alarm
WORKDIR /app
COPY --from=build --chown=alarm:alarm /build/target/*.jar app.jar
USER alarm
EXPOSE 8080
ENV TZ=Europe/Paris \
JAVA_OPTS="-XX:MaxRAMPercentage=70 -XX:+UseSerialGC -XX:TieredStopAtLevel=1"
# MaxRAMPercentage plutot que -Xmx : la JVM lit la limite du cgroup, le reglage
# suit donc la limite du conteneur sans etre redefini a deux endroits.
# SerialGC : sur 2 vCPU partages, G1 coute plus en threads qu'il ne rapporte.
HEALTHCHECK --interval=20s --timeout=4s --start-period=75s --retries=4 \
CMD wget -qO- http://localhost:8080/actuator/health/readiness | grep -q UP || exit 1
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]

319
README.md Normal file
View file

@ -0,0 +1,319 @@
# 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
```
┌──────────────────┐
│ 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 :
```java
@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.
---
## 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
```bash
cp .env.example .env
sed -i '' "s/CHANGEME/$(openssl rand -hex 24)/" .env # macOS ; sinon sed -i
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
```bash
docker compose up -d kafka # le broker seul
mvn spring-boot:run -Dspring-boot.run.profiles=local # H2 en mémoire
```
### Tests
```bash
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) |
Documentation OpenAPI : `/swagger-ui.html`
Déclencher une intrusion à la main :
```bash
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`](deploy/Caddyfile.snippet).
- **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 |
| Build | Maven · Docker multi-stage |
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
| Observabilité | Actuator · Micrometer / Prometheus |
---
*Adam Belkacemi — le studio est toujours debout.*

39
deploy/Caddyfile.snippet Normal file
View file

@ -0,0 +1,39 @@
# ---------------------------------------------------------------------------
# Bloc a ajouter a /opt/edge/Caddyfile sur le serveur Hetzner.
#
# Pourquoi Caddy et pas Nginx + Certbot : le serveur heberge deja cinq autres
# domaines derriere un Caddy qui detient les ports 80 et 443. Y demarrer un
# second frontal aurait mis les deux en conflit sur les memes ports et coupe
# les services existants. Caddy obtient et renouvelle les certificats
# Let's Encrypt tout seul - il n'y a pas de Certbot a installer.
#
# Les deux orthographes sont servies : "seurveillance" est celle du sous-domaine
# demande, "surveillance" couvre la faute de frappe dans les deux sens.
# Caddy demande un certificat pour chaque nom qui resout vers ce serveur.
#
# Le proxy WebSocket n'a rien a declarer : Caddy propage l'upgrade nativement.
# ---------------------------------------------------------------------------
video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me {
reverse_proxy iot-alarm-app:8080
# L'actuator n'a aucune raison d'etre public : health et metriques
# renseignent un attaquant sur les versions et l'etat interne.
@actuator path /actuator/*
respond @actuator 404
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
log {
output file /var/log/caddy/iot-alarm.log
format json
}
}

54
deploy/nginx/nginx.conf Normal file
View file

@ -0,0 +1,54 @@
# Reverse proxy pour l'execution en local (profil "standalone").
# En production sur Hetzner, ce role est tenu par le Caddy deja en place.
upstream iot_alarm_app {
server app:8080;
}
server {
listen 80;
server_name _;
access_log /var/log/nginx/iot-alarm.access.log;
error_log /var/log/nginx/iot-alarm.error.log warn;
# --- WebSocket / SockJS -------------------------------------------------
# Sans l'upgrade explicite, la negociation STOMP echoue silencieusement et
# la console retombe indefiniment sur "reconnexion...".
location /ws {
proxy_pass http://iot_alarm_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Un websocket inactif ne doit pas etre coupe au bout de 60 s :
# la console reste ouverte des heures sans qu'il se passe rien.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# --- Application --------------------------------------------------------
location / {
proxy_pass http://iot_alarm_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# --- Supervision : non expose ------------------------------------------
location /actuator/ {
allow 127.0.0.1;
allow 172.16.0.0/12;
deny all;
proxy_pass http://iot_alarm_app;
proxy_set_header Host $host;
}
}

138
docker-compose.yml Normal file
View file

@ -0,0 +1,138 @@
# =============================================================================
# iot-alarm-system
#
# Deux reseaux :
# - "default" : prive, Kafka et PostgreSQL n'y sont joignables que par l'app.
# Aucun port n'est publie sur l'hote - le broker n'est pas expose a Internet.
# - "edge" : reseau externe partage, ou le reverse proxy Caddy du serveur
# vient chercher l'application pour la servir en HTTPS.
#
# Note sur Zookeeper : absent volontairement. Kafka tourne en mode KRaft, son
# quorum interne. Zookeeper est deprecie depuis Kafka 3.5 et supprime en 4.0 ;
# le garder ajouterait un conteneur, ~400 Mo de RAM et une piece a maintenir.
# =============================================================================
services:
# ---------------------------------------------------------------------------
# Bus d'evenements - Kafka en KRaft mono-noeud
# ---------------------------------------------------------------------------
kafka:
image: apache/kafka:3.9.1
container_name: iot-alarm-kafka
restart: unless-stopped
environment:
CLUSTER_ID: "iot-alarm-cluster-0001"
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
# Mono-noeud : aucune replication possible, on l'assume explicitement.
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_LOG_RETENTION_HOURS: 72
KAFKA_LOG_DIRS: /var/lib/kafka/data
KAFKA_HEAP_OPTS: "-Xmx400m -Xms256m"
volumes:
- kafka_data:/var/lib/kafka/data
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"]
interval: 15s
timeout: 10s
retries: 12
start_period: 40s
mem_limit: 800m
networks: [default]
# ---------------------------------------------------------------------------
# Persistance - write model et read model
# ---------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: iot-alarm-postgres
restart: unless-stopped
environment:
POSTGRES_DB: iotalarm
POSTGRES_USER: iotalarm
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD est obligatoire, voir .env.example}
# Le checksum des pages detecte une corruption disque silencieuse.
POSTGRES_INITDB_ARGS: "--data-checksums"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U iotalarm -d iotalarm"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
mem_limit: 256m
networks: [default]
# ---------------------------------------------------------------------------
# Application Spring Boot
# ---------------------------------------------------------------------------
app:
build:
context: .
dockerfile: Dockerfile
image: iot-alarm-system:1.0.0
container_name: iot-alarm-app
restart: unless-stopped
depends_on:
kafka:
condition: service_healthy
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: prod
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
DB_URL: jdbc:postgresql://postgres:5432/iotalarm
DB_USERNAME: iotalarm
DB_PASSWORD: ${DB_PASSWORD}
SIMULATOR_ENABLED: ${SIMULATOR_ENABLED:-true}
SIMULATOR_INTERVAL: ${SIMULATOR_INTERVAL:-8s}
ALARM_EXIT_DELAY: ${ALARM_EXIT_DELAY:-30s}
ALARM_MIN_CONFIDENCE: ${ALARM_MIN_CONFIDENCE:-60}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
mem_limit: 512m
# Joint les deux reseaux : "default" pour parler a Kafka et PostgreSQL,
# "edge" pour etre joignable par le reverse proxy.
networks: [default, edge]
# ---------------------------------------------------------------------------
# Nginx - profil "standalone" uniquement
#
# Sur le serveur de production, le frontal est le Caddy deja en place (il
# detient 80/443 et sert cinq autres domaines) : voir deploy/Caddyfile.snippet.
# Ce Nginx sert au cas "je clone le depot et je lance tout sur ma machine" :
# docker compose --profile standalone up -d -> http://localhost:8081
# ---------------------------------------------------------------------------
nginx:
image: nginx:alpine
container_name: iot-alarm-nginx
profiles: [standalone]
restart: unless-stopped
depends_on: [app]
ports:
- "8081:80"
volumes:
- ./deploy/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
mem_limit: 64m
networks: [default]
volumes:
kafka_data:
postgres_data:
networks:
default:
name: iot-alarm-internal
edge:
external: true
name: edge

176
pom.xml Normal file
View file

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.2</version>
<relativePath/>
</parent>
<groupId>me.belkacemi</groupId>
<artifactId>iot-alarm-system</artifactId>
<version>1.0.0</version>
<name>iot-alarm-system</name>
<description>Systeme de video-surveillance evenementiel - Hexagonal / CQRS / Kafka</description>
<properties>
<java.version>21</java.version>
<archunit.version>1.3.0</archunit.version>
<springdoc.version>2.8.3</springdoc.version>
</properties>
<dependencies>
<!-- ========================================================= -->
<!-- PRESENTATION : adapters entrants (REST + WebSocket STOMP) -->
<!-- ========================================================= -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- ========================================================= -->
<!-- INFRASTRUCTURE : bus d'evenements Kafka -->
<!-- ========================================================= -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- ========================================================= -->
<!-- INFRASTRUCTURE : persistance (write model + read model) -->
<!-- ========================================================= -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ========================================================= -->
<!-- OPERABILITE : health checks, metriques Prometheus -->
<!-- ========================================================= -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ========================================================= -->
<!-- TESTS -->
<!-- ========================================================= -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
<!-- ArchUnit : les regles d'architecture hexagonale sont testees, pas documentees -->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<!-- Tests unitaires + ArchUnit : tournent a chaque build (y compris dans Docker) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludedGroups>integration</excludedGroups>
</configuration>
</plugin>
<!-- Tests d'integration Testcontainers : mvn verify -Pintegration (Docker requis) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<groups>integration</groups>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,25 @@
package me.belkacemi.iotalarm;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Point d'entree du systeme d'alarme.
*
* <p>Le studio a ete cambriole. Plutot qu'un abonnement mensuel a une camera
* connectee, un vieil iPhone cale entre deux livres de la bibliotheque fait
* capteur. Ce qui suit est le backend qui ecoute ce capteur.</p>
*
* @see me.belkacemi.iotalarm.domain.model.AlarmSystem l'agregat ou vivent les regles
*/
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class IotAlarmSystemApplication {
public static void main(String[] args) {
SpringApplication.run(IotAlarmSystemApplication.class, args);
}
}

View file

@ -0,0 +1,34 @@
package me.belkacemi.iotalarm.application.command.handler;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Differe un effet de bord jusqu'au commit effectif de la transaction.
*
* <p>Sans ce garde-fou, la console recoit la notification WebSocket avant le
* commit : si la transaction part ensuite en rollback, l'interface affiche une
* intrusion qui n'existe dans aucune base. Un push WebSocket n'est pas
* transactionnel - on ne l'emet donc qu'une fois l'ecriture acquise.</p>
*
* <p>Hors transaction (tests unitaires), l'action s'execute immediatement.</p>
*/
final class AfterCommit {
private AfterCommit() {
}
static void run(Runnable action) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
});
}
}

View file

@ -0,0 +1,100 @@
package me.belkacemi.iotalarm.application.command.handler;
import me.belkacemi.iotalarm.domain.event.AlarmArmed;
import me.belkacemi.iotalarm.domain.event.AlarmDisarmed;
import me.belkacemi.iotalarm.domain.exception.DomainException;
import me.belkacemi.iotalarm.domain.model.Alert;
import me.belkacemi.iotalarm.domain.model.AlarmPolicy;
import me.belkacemi.iotalarm.domain.model.AlarmSystem;
import me.belkacemi.iotalarm.domain.port.in.AcknowledgeAlertUseCase;
import me.belkacemi.iotalarm.domain.port.in.ArmAlarmUseCase;
import me.belkacemi.iotalarm.domain.port.in.DisarmAlarmUseCase;
import me.belkacemi.iotalarm.domain.port.in.command.AcknowledgeAlertCommand;
import me.belkacemi.iotalarm.domain.port.in.command.ArmAlarmCommand;
import me.belkacemi.iotalarm.domain.port.in.command.DisarmAlarmCommand;
import me.belkacemi.iotalarm.domain.port.out.AlarmStateRepository;
import me.belkacemi.iotalarm.domain.port.out.AlertNotifier;
import me.belkacemi.iotalarm.domain.port.out.AlertRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
/**
* Cote COMMAND du CQRS : les commandes de pilotage de l'alarme.
*
* <p>Ce service <em>orchestre</em>, il ne decide pas. Charger l'agregat,
* appeler la methode metier, persister, notifier. Aucun {@code if} metier ne
* doit apparaitre ici - s'il en apparait un, il est mal place.</p>
*/
@Service
@Transactional
public class AlarmCommandService
implements ArmAlarmUseCase, DisarmAlarmUseCase, AcknowledgeAlertUseCase {
private static final Logger log = LoggerFactory.getLogger(AlarmCommandService.class);
private final AlarmStateRepository alarmStateRepository;
private final AlertRepository alertRepository;
private final AlertNotifier notifier;
private final AlarmPolicy policy;
private final Clock clock;
public AlarmCommandService(AlarmStateRepository alarmStateRepository,
AlertRepository alertRepository,
AlertNotifier notifier,
AlarmPolicy policy,
Clock clock) {
this.alarmStateRepository = alarmStateRepository;
this.alertRepository = alertRepository;
this.notifier = notifier;
this.policy = policy;
this.clock = clock;
}
@Override
public AlarmArmed arm(ArmAlarmCommand command) {
AlarmSystem alarm = loadOrCreate(command.zoneId());
AlarmArmed event = alarm.arm(command.requestedAt());
alarmStateRepository.save(alarm);
log.info("Alarme ARMEE sur la zone {} - delai de sortie {} s",
event.zoneId(), event.exitDelay().toSeconds());
AfterCommit.run(() -> notifier.notifyArmed(event));
return event;
}
@Override
public AlarmDisarmed disarm(DisarmAlarmCommand command) {
AlarmSystem alarm = loadOrCreate(command.zoneId());
AlarmDisarmed event = alarm.disarm(command.requestedAt());
alarmStateRepository.save(alarm);
log.info("Alarme DESARMEE sur la zone {} (etat precedent : {})",
event.zoneId(), event.previousState());
AfterCommit.run(() -> notifier.notifyDisarmed(event));
return event;
}
@Override
public Alert acknowledge(AcknowledgeAlertCommand command) {
Alert alert = alertRepository.findById(command.alertId())
.orElseThrow(() -> new DomainException(
"Aucune alerte pour l'identifiant " + command.alertId()));
alert.acknowledge(command.acknowledgedAt());
Alert saved = alertRepository.save(alert);
log.info("Alerte {} acquittee", saved.id());
AfterCommit.run(() -> notifier.notifyAcknowledged(saved));
return saved;
}
/**
* Au tout premier demarrage, aucun etat n'est encore persiste : on cree une
* alarme neuve, donc desarmee. Aux demarrages suivants, l'etat est relu -
* une alarme ne doit pas se desarmer parce que le conteneur a redemarre.
*/
private AlarmSystem loadOrCreate(String zoneId) {
return alarmStateRepository.load(zoneId)
.orElseGet(() -> AlarmSystem.initial(zoneId, policy, clock.instant()));
}
}

View file

@ -0,0 +1,113 @@
package me.belkacemi.iotalarm.application.command.handler;
import me.belkacemi.iotalarm.domain.model.AlarmDecision;
import me.belkacemi.iotalarm.domain.model.AlarmPolicy;
import me.belkacemi.iotalarm.domain.model.AlarmSystem;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import me.belkacemi.iotalarm.domain.port.in.IngestSensorEventUseCase;
import me.belkacemi.iotalarm.domain.port.in.ProcessSensorEventUseCase;
import me.belkacemi.iotalarm.domain.port.in.command.IngestSensorEventCommand;
import me.belkacemi.iotalarm.domain.port.out.AlarmStateRepository;
import me.belkacemi.iotalarm.domain.port.out.AlertNotifier;
import me.belkacemi.iotalarm.domain.port.out.AlertRepository;
import me.belkacemi.iotalarm.domain.port.out.SensorEventPublisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
/**
* Cote COMMAND du CQRS : le chemin capteur -> Kafka -> domaine.
*
* <p>Les deux moities du flux sont ici, et elles ne partagent pas de
* transaction :</p>
* <ol>
* <li>{@link #ingest} depose l'observation sur le bus et rend la main.
* L'iPhone recoit son 202 en quelques millisecondes.</li>
* <li>{@link #process} est appele plus tard par le consumer Kafka et applique
* les regles metier.</li>
* </ol>
*/
@Service
public class SensorEventCommandService
implements IngestSensorEventUseCase, ProcessSensorEventUseCase {
private static final Logger log = LoggerFactory.getLogger(SensorEventCommandService.class);
private final SensorEventPublisher publisher;
private final AlarmStateRepository alarmStateRepository;
private final AlertRepository alertRepository;
private final AlertNotifier notifier;
private final AlarmPolicy policy;
private final Clock clock;
public SensorEventCommandService(SensorEventPublisher publisher,
AlarmStateRepository alarmStateRepository,
AlertRepository alertRepository,
AlertNotifier notifier,
AlarmPolicy policy,
Clock clock) {
this.publisher = publisher;
this.alarmStateRepository = alarmStateRepository;
this.alertRepository = alertRepository;
this.notifier = notifier;
this.policy = policy;
this.clock = clock;
}
/**
* Ingestion : aucune regle metier, aucune base. On publie, point.
*
* <p>{@code NOT_SUPPORTED} est deliberé : ouvrir une transaction JDBC pour
* un simple envoi Kafka immobiliserait une connexion du pool a chaque
* battement de coeur du capteur.</p>
*/
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void ingest(IngestSensorEventCommand command) {
publisher.publish(command.event());
}
/**
* Traitement : c'est ici que le domaine tranche.
*
* <p>Le {@code switch} porte sur une interface scellee. Le compilateur
* garantit son exhaustivite : ajouter demain un troisieme type de decision
* casse la compilation au lieu de retomber silencieusement dans un
* {@code else}.</p>
*/
@Override
@Transactional
public AlarmDecision process(SensorEvent event) {
Instant now = clock.instant();
AlarmSystem alarm = loadOrCreate(AlarmSystem.DEFAULT_ZONE, now);
AlarmDecision decision = alarm.evaluate(event, now);
switch (decision) {
case AlarmDecision.Triggered triggered -> {
alertRepository.save(triggered.alert());
alarmStateRepository.save(alarm);
log.warn("INTRUSION - {} (capteur {}, confiance {} %)",
triggered.alert().message(),
event.deviceId(),
event.confidence());
AfterCommit.run(() -> notifier.notifyIntrusion(triggered.event()));
}
case AlarmDecision.Ignored ignored -> log.debug(
"Evenement {} du capteur {} ignore : {}",
event.type(), event.deviceId(), ignored.reason().label());
}
return decision;
}
private AlarmSystem loadOrCreate(String zoneId, Instant now) {
return alarmStateRepository.load(zoneId)
.orElseGet(() -> AlarmSystem.initial(zoneId, policy, now));
}
}

View file

@ -0,0 +1,45 @@
package me.belkacemi.iotalarm.application.query.handler;
import me.belkacemi.iotalarm.application.query.port.AlarmReadModelPort;
import me.belkacemi.iotalarm.application.query.view.AlarmStatusView;
import me.belkacemi.iotalarm.application.query.view.AlertView;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Cote QUERY du CQRS. Aucune ecriture ne passe par ici.
*
* <p>Transactions en lecture seule : l'intention est declaree au framework,
* qui peut router vers un replica si l'architecture l'exige un jour.</p>
*/
@Service
@Transactional(readOnly = true)
public class AlarmQueryService {
/** Garde-fou : une console qui demande 10 000 alertes est un bug, pas un besoin. */
private static final int MAX_LIMIT = 200;
private static final int DEFAULT_LIMIT = 50;
private final AlarmReadModelPort readModel;
public AlarmQueryService(AlarmReadModelPort readModel) {
this.readModel = readModel;
}
public Optional<AlarmStatusView> status(String zoneId) {
return readModel.alarmStatus(zoneId);
}
public List<AlertView> recentAlerts(String zoneId, Integer limit) {
int effective = limit == null ? DEFAULT_LIMIT : Math.clamp(limit, 1, MAX_LIMIT);
return readModel.recentAlerts(zoneId, effective);
}
public Optional<AlertView> alert(UUID alertId) {
return readModel.findAlert(alertId);
}
}

View file

@ -0,0 +1,25 @@
package me.belkacemi.iotalarm.application.query.port;
import me.belkacemi.iotalarm.application.query.view.AlarmStatusView;
import me.belkacemi.iotalarm.application.query.view.AlertView;
import java.util.List;
import java.util.Optional;
/**
* Port de LECTURE, propriete du cote read du CQRS.
*
* <p>Il ne traverse ni l'agregat ni les ports d'ecriture : les requetes
* d'affichage tapent directement une projection. Consequence pratique -
* brancher demain une vue materialisee, un cache Redis ou un index
* Elasticsearch ne demande qu'une nouvelle implementation de cette interface,
* sans toucher une ligne du domaine.</p>
*/
public interface AlarmReadModelPort {
Optional<AlarmStatusView> alarmStatus(String zoneId);
List<AlertView> recentAlerts(String zoneId, int limit);
Optional<AlertView> findAlert(java.util.UUID alertId);
}

View file

@ -0,0 +1,24 @@
package me.belkacemi.iotalarm.application.query.view;
import java.time.Instant;
/**
* READ MODEL. Etat complet de l'alarme tel qu'affiche par la console.
*
* @param zoneId zone surveillee
* @param state DISARMED / ARMED / TRIGGERED
* @param stateChangedAt dernier changement d'etat
* @param exitDelaySeconds delai de sortie configure
* @param lastAlertAt derniere intrusion constatee, {@code null} si aucune
* @param unacknowledgedCount alertes en attente d'acquittement
* @param lastAlert derniere alerte, {@code null} si aucune
*/
public record AlarmStatusView(
String zoneId,
String state,
Instant stateChangedAt,
long exitDelaySeconds,
Instant lastAlertAt,
long unacknowledgedCount,
AlertView lastAlert) {
}

View file

@ -0,0 +1,26 @@
package me.belkacemi.iotalarm.application.query.view;
import java.time.Instant;
import java.util.UUID;
/**
* READ MODEL. Representation d'une alerte destinee a l'affichage.
*
* <p>Distinct de l'entite {@code Alert} du domaine, et c'est volontaire : le
* read model porte des libelles deja calcules ({@code deviceLabel},
* {@code triggerLabel}) que l'agregat n'a aucune raison de connaitre. Les deux
* modeles peuvent diverger sans se contraindre - c'est tout l'interet du CQRS.</p>
*/
public record AlertView(
UUID id,
String deviceId,
String deviceLabel,
String trigger,
String triggerLabel,
String severity,
String message,
Instant detectedAt,
int confidence,
boolean acknowledged,
Instant acknowledgedAt) {
}

View file

@ -0,0 +1,16 @@
package me.belkacemi.iotalarm.domain.event;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/** L'alarme vient d'etre armee. Le delai de sortie court a partir de {@code occurredAt}. */
public record AlarmArmed(String zoneId, Instant occurredAt, Duration exitDelay)
implements DomainEvent {
public AlarmArmed {
Objects.requireNonNull(zoneId, "zoneId est obligatoire");
Objects.requireNonNull(occurredAt, "occurredAt est obligatoire");
Objects.requireNonNull(exitDelay, "exitDelay est obligatoire");
}
}

View file

@ -0,0 +1,23 @@
package me.belkacemi.iotalarm.domain.event;
import me.belkacemi.iotalarm.domain.model.AlarmState;
import java.time.Instant;
import java.util.Objects;
/**
* L'alarme vient d'etre desarmee.
*
* <p>{@code previousState} distingue un desarmement de routine (depuis ARMED)
* d'un acquittement d'intrusion (depuis TRIGGERED) - l'information est utile
* en audit, on la porte donc dans le fait lui-meme.</p>
*/
public record AlarmDisarmed(String zoneId, Instant occurredAt, AlarmState previousState)
implements DomainEvent {
public AlarmDisarmed {
Objects.requireNonNull(zoneId, "zoneId est obligatoire");
Objects.requireNonNull(occurredAt, "occurredAt est obligatoire");
Objects.requireNonNull(previousState, "previousState est obligatoire");
}
}

View file

@ -0,0 +1,20 @@
package me.belkacemi.iotalarm.domain.event;
import java.time.Instant;
/**
* Fait metier avere, au passe, immuable.
*
* <p>Scellee : la liste des faits que ce domaine sait produire est fermee et
* connue du compilateur. Toute exhaustivite (routage, projection, audit) est
* donc verifiee a la compilation.</p>
*/
public sealed interface DomainEvent
permits IntrusionDetected, AlarmArmed, AlarmDisarmed {
/** Zone concernee. Ici une seule : le studio. */
String zoneId();
/** Quand le fait s'est produit, du point de vue du domaine. */
Instant occurredAt();
}

View file

@ -0,0 +1,22 @@
package me.belkacemi.iotalarm.domain.event;
import me.belkacemi.iotalarm.domain.model.Alert;
import java.time.Instant;
import java.util.Objects;
/**
* Le fait central du systeme : quelque chose a bouge dans un studio cense etre vide.
*
* <p>C'est cet evenement qui, une fois l'alerte persistee, est pousse sur le
* WebSocket et fait passer la console au rouge.</p>
*/
public record IntrusionDetected(String zoneId, Alert alert, Instant occurredAt)
implements DomainEvent {
public IntrusionDetected {
Objects.requireNonNull(zoneId, "zoneId est obligatoire");
Objects.requireNonNull(alert, "alert est obligatoire");
Objects.requireNonNull(occurredAt, "occurredAt est obligatoire");
}
}

View file

@ -0,0 +1,14 @@
package me.belkacemi.iotalarm.domain.exception;
/**
* Violation d'une regle metier.
*
* <p>Volontairement non typee Spring : le domaine ne connait pas HTTP. C'est un
* {@code @RestControllerAdvice} de la couche presentation qui la traduit en 409.</p>
*/
public class DomainException extends RuntimeException {
public DomainException(String message) {
super(message);
}
}

View file

@ -0,0 +1,43 @@
package me.belkacemi.iotalarm.domain.model;
import me.belkacemi.iotalarm.domain.event.IntrusionDetected;
import java.util.Objects;
/**
* Resultat de l'evaluation d'un {@link SensorEvent} par l'agregat.
*
* <p>Interface <strong>scellee</strong> : les deux seules issues possibles sont
* connues du compilateur. Cote appelant, un {@code switch} a patterns est
* verifie exhaustivement - ajouter un cas demain casse la compilation au lieu
* de passer silencieusement dans un {@code else}.</p>
*/
public sealed interface AlarmDecision {
/** Intrusion confirmee : une alerte a ete levee et l'agregat est passe en TRIGGERED. */
record Triggered(IntrusionDetected event) implements AlarmDecision {
public Triggered {
Objects.requireNonNull(event, "event est obligatoire");
}
public Alert alert() {
return event.alert();
}
}
/** Evenement recu et volontairement ecarte, avec le motif. */
record Ignored(IgnoreReason reason, SensorEvent event) implements AlarmDecision {
public Ignored {
Objects.requireNonNull(reason, "reason est obligatoire");
Objects.requireNonNull(event, "event est obligatoire");
}
}
static AlarmDecision triggered(IntrusionDetected event) {
return new Triggered(event);
}
static AlarmDecision ignored(IgnoreReason reason, SensorEvent event) {
return new Ignored(reason, event);
}
}

View file

@ -0,0 +1,42 @@
package me.belkacemi.iotalarm.domain.model;
import java.time.Duration;
/**
* Reglages de sensibilite de l'alarme. Value Object injecte dans l'agregat.
*
* <p>Ces trois parametres sont ceux qui font la difference entre un systeme
* utilisable et un systeme qu'on finit par desarmer en permanence :</p>
* <ul>
* <li>{@code exitDelay} : le temps de sortir sans se declencher soi-meme ;</li>
* <li>{@code alertCooldown} : sans ca, un intrus qui marche devant l'objectif
* genere 40 alertes en 10 secondes ;</li>
* <li>{@code minimumConfidence} : un rideau qui bouge n'est pas un cambrioleur.</li>
* </ul>
*
* @param exitDelay delai de grace apres l'armement
* @param alertCooldown intervalle minimum entre deux alertes
* @param minimumConfidence seuil de confiance en deca duquel on ignore la detection
*/
public record AlarmPolicy(
Duration exitDelay,
Duration alertCooldown,
int minimumConfidence) {
public AlarmPolicy {
if (exitDelay == null || exitDelay.isNegative()) {
throw new IllegalArgumentException("exitDelay doit etre positif ou nul");
}
if (alertCooldown == null || alertCooldown.isNegative()) {
throw new IllegalArgumentException("alertCooldown doit etre positif ou nul");
}
if (minimumConfidence < 0 || minimumConfidence > 100) {
throw new IllegalArgumentException("minimumConfidence doit etre compris entre 0 et 100");
}
}
/** Reglage par defaut : 30 s pour sortir, 1 alerte / 15 s max, 60 % de confiance. */
public static AlarmPolicy defaults() {
return new AlarmPolicy(Duration.ofSeconds(30), Duration.ofSeconds(15), 60);
}
}

View file

@ -0,0 +1,27 @@
package me.belkacemi.iotalarm.domain.model;
/**
* Etat de l'agregat {@link AlarmSystem}.
*
* <pre>
* DISARMED --arm()--&gt; ARMED --intrusion--&gt; TRIGGERED
* ^ | |
* +----disarm()------+------disarm()--------+
* </pre>
*/
public enum AlarmState {
/** Je suis chez moi. Les capteurs remontent, rien ne se declenche. */
DISARMED,
/** Le studio est cense etre vide. Toute presence est une intrusion. */
ARMED,
/** Une intrusion a ete confirmee. L'alarme reste sensible aux evenements suivants. */
TRIGGERED;
/** Surveillance active : seuls ces etats evaluent les evenements capteur. */
public boolean isMonitoring() {
return this == ARMED || this == TRIGGERED;
}
}

View file

@ -0,0 +1,165 @@
package me.belkacemi.iotalarm.domain.model;
import me.belkacemi.iotalarm.domain.event.AlarmArmed;
import me.belkacemi.iotalarm.domain.event.AlarmDisarmed;
import me.belkacemi.iotalarm.domain.event.IntrusionDetected;
import me.belkacemi.iotalarm.domain.exception.DomainException;
import java.time.Instant;
import java.util.Objects;
/**
* AGREGAT RACINE. Seul point d'entree pour modifier l'etat de l'alarme.
*
* <p>Toute la logique de detection est ici, et <em>uniquement</em> ici : ni le
* consumer Kafka, ni le controleur REST, ni le service applicatif ne decident
* si un mouvement est une intrusion. Ils transportent, l'agregat tranche.</p>
*
* <p>Consequence directe : {@link #evaluate} se teste en JUnit pur, sans
* Spring, sans Kafka et sans base - voir {@code AlarmSystemTest}.</p>
*
* <p>Aucun import {@code org.springframework} ni {@code jakarta.persistence}
* dans ce package : c'est une regle verifiee par ArchUnit, pas une intention.</p>
*/
public final class AlarmSystem {
/** Identifiant de la zone unique surveillee. Un studio, une piece, un capteur. */
public static final String DEFAULT_ZONE = "studio";
private final String zoneId;
private final AlarmPolicy policy;
private AlarmState state;
private Instant stateChangedAt;
private Instant lastAlertAt;
private AlarmSystem(String zoneId, AlarmPolicy policy, AlarmState state,
Instant stateChangedAt, Instant lastAlertAt) {
this.zoneId = Objects.requireNonNull(zoneId, "zoneId est obligatoire");
this.policy = Objects.requireNonNull(policy, "policy est obligatoire");
this.state = Objects.requireNonNull(state, "state est obligatoire");
this.stateChangedAt = Objects.requireNonNull(stateChangedAt, "stateChangedAt est obligatoire");
this.lastAlertAt = lastAlertAt;
}
/** Premier demarrage : une alarme neuve est desarmee. */
public static AlarmSystem initial(String zoneId, AlarmPolicy policy, Instant now) {
return new AlarmSystem(zoneId, policy, AlarmState.DISARMED, now, null);
}
/** Reconstruction depuis la persistance. Ne rejoue aucune regle metier. */
public static AlarmSystem rehydrate(String zoneId, AlarmPolicy policy, AlarmState state,
Instant stateChangedAt, Instant lastAlertAt) {
return new AlarmSystem(zoneId, policy, state, stateChangedAt, lastAlertAt);
}
// ------------------------------------------------------------------
// Commandes
// ------------------------------------------------------------------
/**
* Arme l'alarme. Le delai de sortie demarre maintenant.
*
* @throws DomainException si l'alarme est deja armee
*/
public AlarmArmed arm(Instant now) {
Objects.requireNonNull(now, "now est obligatoire");
if (state == AlarmState.ARMED) {
throw new DomainException("L'alarme est deja armee");
}
this.state = AlarmState.ARMED;
this.stateChangedAt = now;
return new AlarmArmed(zoneId, now, policy.exitDelay());
}
/**
* Desarme l'alarme, qu'elle soit armee ou declenchee.
*
* @throws DomainException si l'alarme est deja desarmee
*/
public AlarmDisarmed disarm(Instant now) {
Objects.requireNonNull(now, "now est obligatoire");
if (state == AlarmState.DISARMED) {
throw new DomainException("L'alarme est deja desarmee");
}
AlarmState previous = this.state;
this.state = AlarmState.DISARMED;
this.stateChangedAt = now;
return new AlarmDisarmed(zoneId, now, previous);
}
/**
* LA regle metier du systeme : cette observation est-elle une intrusion ?
*
* <p>Les cinq filtres sont ordonnes du moins au plus couteux a evaluer, et
* chacun renvoie un motif explicite plutot qu'un {@code false} muet.</p>
*
* @return {@link AlarmDecision.Triggered} avec l'alerte levee, ou
* {@link AlarmDecision.Ignored} avec le motif de rejet
*/
public AlarmDecision evaluate(SensorEvent event, Instant now) {
Objects.requireNonNull(event, "event est obligatoire");
Objects.requireNonNull(now, "now est obligatoire");
// 1. Je suis chez moi : les capteurs remontent, rien ne se declenche.
if (!state.isMonitoring()) {
return AlarmDecision.ignored(IgnoreReason.ALARM_DISARMED, event);
}
// 2. Un heartbeat n'a jamais cambriole personne.
if (!event.type().isIntrusionRelevant()) {
return AlarmDecision.ignored(IgnoreReason.NOT_INTRUSION_RELEVANT, event);
}
// 3. Delai de grace : le temps de prendre mes clefs et de sortir.
if (isWithinExitDelay(now)) {
return AlarmDecision.ignored(IgnoreReason.WITHIN_EXIT_DELAY, event);
}
// 4. Un rideau qui bouge n'est pas un cambrioleur.
if (event.confidence() < policy.minimumConfidence()) {
return AlarmDecision.ignored(IgnoreReason.CONFIDENCE_TOO_LOW, event);
}
// 5. Anti-rafale : sans ce filtre, un intrus qui traverse la piece
// genere des dizaines d'alertes en quelques secondes.
if (isWithinCooldown(now)) {
return AlarmDecision.ignored(IgnoreReason.WITHIN_COOLDOWN, event);
}
this.state = AlarmState.TRIGGERED;
this.lastAlertAt = now;
Alert alert = Alert.raise(zoneId, event, now);
return AlarmDecision.triggered(new IntrusionDetected(zoneId, alert, now));
}
private boolean isWithinExitDelay(Instant now) {
return state == AlarmState.ARMED
&& now.isBefore(stateChangedAt.plus(policy.exitDelay()));
}
private boolean isWithinCooldown(Instant now) {
return lastAlertAt != null
&& now.isBefore(lastAlertAt.plus(policy.alertCooldown()));
}
// ------------------------------------------------------------------
// Accesseurs (lecture seule : l'etat ne se modifie que par les commandes)
// ------------------------------------------------------------------
public String zoneId() {
return zoneId;
}
public AlarmState state() {
return state;
}
public AlarmPolicy policy() {
return policy;
}
public Instant stateChangedAt() {
return stateChangedAt;
}
public Instant lastAlertAt() {
return lastAlertAt;
}
}

View file

@ -0,0 +1,121 @@
package me.belkacemi.iotalarm.domain.model;
import me.belkacemi.iotalarm.domain.exception.DomainException;
import java.time.Instant;
import java.util.Objects;
/**
* Intrusion constatee : une observation capteur que l'agregat a jugee anormale.
*
* <p>Entite (identite propre + cycle de vie), a la difference de
* {@link SensorEvent} qui est un simple Value Object. Une alerte nait
* non acquittee et ne peut etre acquittee qu'une fois.</p>
*/
public final class Alert {
private final AlertId id;
private final String zoneId;
private final DeviceId deviceId;
private final SensorEventType trigger;
private final Severity severity;
private final String message;
private final Instant detectedAt;
private final int confidence;
private boolean acknowledged;
private Instant acknowledgedAt;
private Alert(AlertId id, String zoneId, DeviceId deviceId, SensorEventType trigger,
Severity severity, String message, Instant detectedAt, int confidence,
boolean acknowledged, Instant acknowledgedAt) {
this.id = Objects.requireNonNull(id);
this.zoneId = Objects.requireNonNull(zoneId);
this.deviceId = Objects.requireNonNull(deviceId);
this.trigger = Objects.requireNonNull(trigger);
this.severity = Objects.requireNonNull(severity);
this.message = Objects.requireNonNull(message);
this.detectedAt = Objects.requireNonNull(detectedAt);
this.confidence = confidence;
this.acknowledged = acknowledged;
this.acknowledgedAt = acknowledgedAt;
}
/** Fabrique metier : seule l'evaluation d'un {@link SensorEvent} peut lever une alerte. */
static Alert raise(String zoneId, SensorEvent event, Instant detectedAt) {
String message = "%s - %s".formatted(
event.type().label(), event.deviceId().displayName());
return new Alert(
AlertId.generate(), zoneId, event.deviceId(), event.type(),
event.type().severity(), message, detectedAt, event.confidence(),
false, null);
}
/** Reconstruction depuis la persistance. Ne rejoue aucune regle metier. */
public static Alert rehydrate(AlertId id, String zoneId, DeviceId deviceId,
SensorEventType trigger, Severity severity, String message,
Instant detectedAt, int confidence,
boolean acknowledged, Instant acknowledgedAt) {
return new Alert(id, zoneId, deviceId, trigger, severity, message,
detectedAt, confidence, acknowledged, acknowledgedAt);
}
/** Je suis rentre, j'ai vu, c'etait le chat du voisin. */
public void acknowledge(Instant now) {
if (acknowledged) {
throw new DomainException("L'alerte " + id + " a deja ete acquittee");
}
this.acknowledged = true;
this.acknowledgedAt = Objects.requireNonNull(now, "now est obligatoire");
}
public AlertId id() {
return id;
}
public String zoneId() {
return zoneId;
}
public DeviceId deviceId() {
return deviceId;
}
public SensorEventType trigger() {
return trigger;
}
public Severity severity() {
return severity;
}
public String message() {
return message;
}
public Instant detectedAt() {
return detectedAt;
}
public int confidence() {
return confidence;
}
public boolean isAcknowledged() {
return acknowledged;
}
public Instant acknowledgedAt() {
return acknowledgedAt;
}
@Override
public boolean equals(Object o) {
return o instanceof Alert other && id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}

View file

@ -0,0 +1,36 @@
package me.belkacemi.iotalarm.domain.model;
import java.util.Objects;
import java.util.UUID;
/**
* Identite d'une {@link Alert}.
*
* <p>Generee par le domaine, pas par la base : une alerte a une identite des
* l'instant ou elle est constatee, bien avant d'etre persistee. C'est ce qui
* permet de la pousser sur le WebSocket et de l'ecrire en base sans dependre
* de l'ordre des deux operations.</p>
*/
public record AlertId(UUID value) {
public AlertId {
Objects.requireNonNull(value, "L'identifiant d'alerte est obligatoire");
}
public static AlertId generate() {
return new AlertId(UUID.randomUUID());
}
public static AlertId of(UUID value) {
return new AlertId(value);
}
public static AlertId fromString(String value) {
return new AlertId(UUID.fromString(value));
}
@Override
public String toString() {
return value.toString();
}
}

View file

@ -0,0 +1,53 @@
package me.belkacemi.iotalarm.domain.model;
import java.util.regex.Pattern;
/**
* Value Object identifiant un capteur physique.
*
* <p>Dans le studio il n'y en a qu'un : {@code iphone-biblio}, un vieil iPhone
* recyclé, calé entre deux livres de la bibliothèque.</p>
*
* <p>L'identifiant est contraint dès la construction. Le domaine refuse les
* données invalides à sa frontière, il n'a donc jamais à s'en méfier ensuite.</p>
*/
public record DeviceId(String value) {
private static final Pattern VALID = Pattern.compile("^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$");
public DeviceId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("L'identifiant de capteur est obligatoire");
}
value = value.trim().toLowerCase();
if (!VALID.matcher(value).matches()) {
throw new IllegalArgumentException(
"Identifiant de capteur invalide : '" + value
+ "' (minuscules, chiffres et tirets, 3 a 64 caracteres)");
}
}
public static DeviceId of(String value) {
return new DeviceId(value);
}
/** Libelle lisible affiche dans la console : {@code iphone-biblio} -> {@code "Iphone Biblio"}. */
public String displayName() {
StringBuilder sb = new StringBuilder(value.length());
for (String part : value.split("-")) {
if (part.isEmpty()) {
continue;
}
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
}
return sb.toString();
}
@Override
public String toString() {
return value;
}
}

View file

@ -0,0 +1,27 @@
package me.belkacemi.iotalarm.domain.model;
/**
* Pourquoi un evenement capteur n'a pas declenche d'alerte.
*
* <p>Le domaine ne se contente pas de renvoyer {@code false} : il dit
* <em>pourquoi</em>. C'est ce qui rend le systeme debuggable a 3 h du matin
* quand l'alarme n'a pas sonne, et ce qui alimente les metriques Micrometer.</p>
*/
public enum IgnoreReason {
ALARM_DISARMED("Alarme desarmee"),
NOT_INTRUSION_RELEVANT("Type d'evenement non intrusif"),
WITHIN_EXIT_DELAY("Delai de sortie en cours"),
CONFIDENCE_TOO_LOW("Indice de confiance sous le seuil"),
WITHIN_COOLDOWN("Alerte deja levee recemment");
private final String label;
IgnoreReason(String label) {
this.label = label;
}
public String label() {
return label;
}
}

View file

@ -0,0 +1,38 @@
package me.belkacemi.iotalarm.domain.model;
import java.time.Instant;
import java.util.Objects;
/**
* Observation brute remontee par un capteur. Value Object immuable.
*
* <p>C'est la donnee qui transite sur le topic Kafka {@code sensor-events}.
* Elle ne porte aucun jugement : dire si c'est une intrusion est le travail de
* l'agregat {@link AlarmSystem}, pas du capteur.</p>
*
* @param deviceId capteur emetteur
* @param type nature de l'observation
* @param occurredAt horodatage <em>cote capteur</em>
* @param confidence indice de confiance 0-100 remonte par la detection de mouvement
*/
public record SensorEvent(
DeviceId deviceId,
SensorEventType type,
Instant occurredAt,
int confidence) {
public SensorEvent {
Objects.requireNonNull(deviceId, "deviceId est obligatoire");
Objects.requireNonNull(type, "type est obligatoire");
Objects.requireNonNull(occurredAt, "occurredAt est obligatoire");
if (confidence < 0 || confidence > 100) {
throw new IllegalArgumentException(
"L'indice de confiance doit etre compris entre 0 et 100, recu : " + confidence);
}
}
/** Raccourci pour les evenements binaires (ouverture de porte, heartbeat). */
public static SensorEvent of(DeviceId deviceId, SensorEventType type, Instant occurredAt) {
return new SensorEvent(deviceId, type, occurredAt, 100);
}
}

View file

@ -0,0 +1,53 @@
package me.belkacemi.iotalarm.domain.model;
/**
* Nature de ce que le capteur a observe.
*
* <p>Chaque type porte lui-meme sa semantique metier - est-ce le signe d'une
* intrusion, et a quelle gravite ? - plutot que de laisser un {@code switch} se
* disperser dans les services. Brancher un nouveau capteur (bris de glace,
* contact de porte) se fait ici, en une ligne, sans toucher a l'agregat.</p>
*/
public enum SensorEventType {
/** Le flux video de l'iPhone a bouge au-dela du seuil de bruit. */
MOTION_DETECTED(true, Severity.HIGH, "Mouvement detecte"),
/** Contact d'ouverture : porte d'entree du studio. */
DOOR_OPENED(true, Severity.HIGH, "Ouverture de porte"),
/** Pic sonore anormal : bris de verre, choc. */
SOUND_DETECTED(true, Severity.MEDIUM, "Pic sonore anormal"),
/**
* Le capteur ne repond plus alors que l'alarme est armee. Soit la batterie
* est morte, soit quelqu'un l'a trouve derriere les livres.
*/
CAMERA_OFFLINE(true, Severity.CRITICAL, "Capteur hors ligne"),
/** Battement de coeur periodique : prouve que le capteur est vivant. Jamais une intrusion. */
HEARTBEAT(false, Severity.LOW, "Signal de vie");
private final boolean intrusionRelevant;
private final Severity severity;
private final String label;
SensorEventType(boolean intrusionRelevant, Severity severity, String label) {
this.intrusionRelevant = intrusionRelevant;
this.severity = severity;
this.label = label;
}
/** {@code true} si ce type peut, a lui seul, caracteriser une intrusion. */
public boolean isIntrusionRelevant() {
return intrusionRelevant;
}
public Severity severity() {
return severity;
}
public String label() {
return label;
}
}

View file

@ -0,0 +1,18 @@
package me.belkacemi.iotalarm.domain.model;
/** Gravite d'une alerte : pilote le code couleur de la console et le canal de notification. */
public enum Severity {
/** Evenement note, sans action. */
LOW,
/** Anomalie a verifier en rentrant. */
MEDIUM,
/** Mouvement confirme dans un logement cense etre vide. */
HIGH,
/** Capteur neutralise alors que l'alarme est armee : quelqu'un sait qu'il est filme. */
CRITICAL;
public boolean isAtLeast(Severity other) {
return this.ordinal() >= other.ordinal();
}
}

View file

@ -0,0 +1,10 @@
package me.belkacemi.iotalarm.domain.port.in;
import me.belkacemi.iotalarm.domain.model.Alert;
import me.belkacemi.iotalarm.domain.port.in.command.AcknowledgeAlertCommand;
/** Port primaire : acquitter une alerte. */
public interface AcknowledgeAlertUseCase {
Alert acknowledge(AcknowledgeAlertCommand command);
}

View file

@ -0,0 +1,10 @@
package me.belkacemi.iotalarm.domain.port.in;
import me.belkacemi.iotalarm.domain.event.AlarmArmed;
import me.belkacemi.iotalarm.domain.port.in.command.ArmAlarmCommand;
/** Port primaire : armer l'alarme. Implemente par la couche application. */
public interface ArmAlarmUseCase {
AlarmArmed arm(ArmAlarmCommand command);
}

View file

@ -0,0 +1,10 @@
package me.belkacemi.iotalarm.domain.port.in;
import me.belkacemi.iotalarm.domain.event.AlarmDisarmed;
import me.belkacemi.iotalarm.domain.port.in.command.DisarmAlarmCommand;
/** Port primaire : desarmer l'alarme. */
public interface DisarmAlarmUseCase {
AlarmDisarmed disarm(DisarmAlarmCommand command);
}

View file

@ -0,0 +1,16 @@
package me.belkacemi.iotalarm.domain.port.in;
import me.belkacemi.iotalarm.domain.port.in.command.IngestSensorEventCommand;
/**
* Port primaire : accuser reception d'une observation capteur.
*
* <p>Volontairement dissocie de {@link ProcessSensorEventUseCase} : ingerer,
* c'est deposer sur le bus et repondre immediatement au capteur ; traiter,
* c'est appliquer les regles metier de maniere asynchrone. Cette separation
* est ce qui permet a l'iPhone de ne jamais attendre la base de donnees.</p>
*/
public interface IngestSensorEventUseCase {
void ingest(IngestSensorEventCommand command);
}

View file

@ -0,0 +1,15 @@
package me.belkacemi.iotalarm.domain.port.in;
import me.belkacemi.iotalarm.domain.model.AlarmDecision;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
/**
* Port primaire : appliquer les regles d'alarme a une observation.
*
* <p>Invoque par l'adapter consumer Kafka, jamais par HTTP. Retourne la
* decision de l'agregat pour que l'adapter puisse tracer et metriquer.</p>
*/
public interface ProcessSensorEventUseCase {
AlarmDecision process(SensorEvent event);
}

View file

@ -0,0 +1,15 @@
package me.belkacemi.iotalarm.domain.port.in.command;
import me.belkacemi.iotalarm.domain.model.AlertId;
import java.time.Instant;
import java.util.Objects;
/** Intention : "j'ai vu cette alerte, c'etait le chat du voisin". */
public record AcknowledgeAlertCommand(AlertId alertId, Instant acknowledgedAt) {
public AcknowledgeAlertCommand {
Objects.requireNonNull(alertId, "alertId est obligatoire");
Objects.requireNonNull(acknowledgedAt, "acknowledgedAt est obligatoire");
}
}

View file

@ -0,0 +1,13 @@
package me.belkacemi.iotalarm.domain.port.in.command;
import java.time.Instant;
import java.util.Objects;
/** Intention : "je sors, arme l'alarme". Emise par la console web. */
public record ArmAlarmCommand(String zoneId, Instant requestedAt) {
public ArmAlarmCommand {
Objects.requireNonNull(zoneId, "zoneId est obligatoire");
Objects.requireNonNull(requestedAt, "requestedAt est obligatoire");
}
}

View file

@ -0,0 +1,13 @@
package me.belkacemi.iotalarm.domain.port.in.command;
import java.time.Instant;
import java.util.Objects;
/** Intention : "je suis rentre, coupe l'alarme". */
public record DisarmAlarmCommand(String zoneId, Instant requestedAt) {
public DisarmAlarmCommand {
Objects.requireNonNull(zoneId, "zoneId est obligatoire");
Objects.requireNonNull(requestedAt, "requestedAt est obligatoire");
}
}

View file

@ -0,0 +1,18 @@
package me.belkacemi.iotalarm.domain.port.in.command;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import java.util.Objects;
/**
* Intention : "ce capteur a observe quelque chose, prends-en acte".
*
* <p>Emise par l'adapter REST d'ingestion ou par le simulateur. Elle ne
* declenche aucune regle metier : elle depose l'observation sur Kafka.</p>
*/
public record IngestSensorEventCommand(SensorEvent event) {
public IngestSensorEventCommand {
Objects.requireNonNull(event, "event est obligatoire");
}
}

View file

@ -0,0 +1,19 @@
package me.belkacemi.iotalarm.domain.port.out;
import me.belkacemi.iotalarm.domain.model.AlarmSystem;
import java.util.Optional;
/**
* Port secondaire : persistance de l'etat de l'agregat.
*
* <p>L'etat armee/desarmee doit survivre a un redemarrage du conteneur :
* un systeme d'alarme qui se desarme tout seul apres un {@code docker compose
* up -d} n'est pas un systeme d'alarme.</p>
*/
public interface AlarmStateRepository {
Optional<AlarmSystem> load(String zoneId);
void save(AlarmSystem alarmSystem);
}

View file

@ -0,0 +1,24 @@
package me.belkacemi.iotalarm.domain.port.out;
import me.belkacemi.iotalarm.domain.event.AlarmArmed;
import me.belkacemi.iotalarm.domain.event.AlarmDisarmed;
import me.belkacemi.iotalarm.domain.event.IntrusionDetected;
import me.belkacemi.iotalarm.domain.model.Alert;
/**
* Port secondaire : notification temps reel de la console de supervision.
*
* <p>Implemente par un adapter WebSocket/STOMP. Le domaine ignore qu'il existe
* un navigateur a l'autre bout.</p>
*/
public interface AlertNotifier {
/** Une intrusion vient d'etre confirmee : la console doit passer au rouge. */
void notifyIntrusion(IntrusionDetected event);
void notifyArmed(AlarmArmed event);
void notifyDisarmed(AlarmDisarmed event);
void notifyAcknowledged(Alert alert);
}

View file

@ -0,0 +1,20 @@
package me.belkacemi.iotalarm.domain.port.out;
import me.belkacemi.iotalarm.domain.model.Alert;
import me.belkacemi.iotalarm.domain.model.AlertId;
import java.util.Optional;
/**
* Port secondaire : persistance des alertes (cote ecriture du CQRS).
*
* <p>Le domaine declare ce dont il a besoin - pas ce que JPA sait faire.
* L'implementation vit dans {@code infrastructure.persistence} ; en test
* unitaire, une {@code HashMap} suffit.</p>
*/
public interface AlertRepository {
Alert save(Alert alert);
Optional<Alert> findById(AlertId id);
}

View file

@ -0,0 +1,15 @@
package me.belkacemi.iotalarm.domain.port.out;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
/**
* Port secondaire : publication d'une observation sur le bus d'evenements.
*
* <p>Le mot "Kafka" n'apparait nulle part dans cette signature. C'est
* exactement l'interet : basculer sur RabbitMQ ou AWS SQS demain ne touche
* qu'une classe d'infrastructure.</p>
*/
public interface SensorEventPublisher {
void publish(SensorEvent event);
}

View file

@ -0,0 +1,38 @@
package me.belkacemi.iotalarm.infrastructure.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Configuration externalisee (prefixe {@code alarm} dans application.yml).
*
* <p>Record immuable : la configuration est lue au demarrage et ne change plus.
* Les valeurs de sensibilite sont ici pour pouvoir etre ajustees par variable
* d'environnement sans recompiler - en vrai, le seuil de confiance se regle en
* observant les faux positifs du salon pendant une semaine.</p>
*/
@ConfigurationProperties(prefix = "alarm")
public record AlarmProperties(
String zoneId,
Duration exitDelay,
Duration alertCooldown,
int minimumConfidence,
Kafka kafka,
Simulator simulator) {
public record Kafka(String topic, String dlqTopic, int partitions, short replicationFactor) {
}
/**
* Simulateur de capteur. Sur le deploiement public, l'iPhone n'est pas
* joignable : ce simulateur rejoue un trafic realiste pour que la
* demonstration soit vivante sans capteur physique.
*/
public record Simulator(
boolean enabled,
Duration interval,
String deviceId,
int intrusionChancePercent) {
}
}

View file

@ -0,0 +1,38 @@
package me.belkacemi.iotalarm.infrastructure.config;
import me.belkacemi.iotalarm.domain.model.AlarmPolicy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
/**
* Traduit la configuration Spring en objets du domaine.
*
* <p>C'est le seul endroit ou {@code @ConfigurationProperties} rencontre le
* domaine. {@link AlarmPolicy} reste un Value Object pur, sans annotation :
* il est instancie ici, puis injecte partout ailleurs.</p>
*/
@Configuration
public class DomainConfig {
@Bean
public AlarmPolicy alarmPolicy(AlarmProperties properties) {
return new AlarmPolicy(
properties.exitDelay(),
properties.alertCooldown(),
properties.minimumConfidence());
}
/**
* Le temps est une dependance, pas un appel statique.
*
* <p>{@code Instant.now()} en dur rendrait intestables le delai de sortie et
* l'anti-rafale : il faudrait des {@code Thread.sleep} dans les tests. Avec
* une {@link Clock} injectee, {@code Clock.fixed(...)} suffit.</p>
*/
@Bean
public Clock clock() {
return Clock.systemUTC();
}
}

View file

@ -0,0 +1,158 @@
package me.belkacemi.iotalarm.infrastructure.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.belkacemi.iotalarm.infrastructure.kafka.message.SensorEventMessage;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.config.TopicBuilder;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer;
import org.springframework.kafka.support.serializer.JsonDeserializer;
import org.springframework.kafka.support.serializer.JsonSerializer;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.util.backoff.FixedBackOff;
import java.util.HashMap;
import java.util.Map;
/**
* Cablage du bus d'evenements.
*
* <p>Les serialiseurs sont construits a la main plutot que declares en
* properties, pour deux raisons precises :</p>
* <ul>
* <li>reutiliser l'{@link ObjectMapper} de Spring, seul a savoir serialiser
* un {@code Instant} en ISO-8601 plutot qu'en tableau de nombres ;</li>
* <li>envelopper le deserialiseur dans un {@link ErrorHandlingDeserializer} :
* sans lui, <em>un seul</em> message malforme sur le topic met le
* consumer dans une boucle de replay infinie.</li>
* </ul>
*/
@Configuration
@EnableKafka
public class KafkaConfig {
private static final Logger log = LoggerFactory.getLogger(KafkaConfig.class);
private final KafkaProperties kafkaProperties;
private final AlarmProperties alarmProperties;
private final ObjectMapper objectMapper;
public KafkaConfig(KafkaProperties kafkaProperties,
AlarmProperties alarmProperties,
ObjectMapper objectMapper) {
this.kafkaProperties = kafkaProperties;
this.alarmProperties = alarmProperties;
this.objectMapper = objectMapper;
}
/** Cree le topic au demarrage s'il n'existe pas : pas de script d'init a lancer a la main. */
@Bean
public NewTopic sensorEventsTopic() {
return TopicBuilder.name(alarmProperties.kafka().topic())
.partitions(alarmProperties.kafka().partitions())
.replicas(alarmProperties.kafka().replicationFactor())
.build();
}
/** Topic de rebut : les messages irrecuperables y atterrissent au lieu de bloquer le flux. */
@Bean
public NewTopic sensorEventsDlqTopic() {
return TopicBuilder.name(alarmProperties.kafka().dlqTopic())
.partitions(1)
.replicas(alarmProperties.kafka().replicationFactor())
.build();
}
@Bean
public ProducerFactory<String, SensorEventMessage> producerFactory() {
Map<String, Object> props = new HashMap<>(
kafkaProperties.buildProducerProperties(null));
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// Durabilite : un evenement d'intrusion ne doit pas se perdre sur un reequilibrage.
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.RETRIES_CONFIG, 3);
JsonSerializer<SensorEventMessage> valueSerializer = new JsonSerializer<>(objectMapper);
// Pas d'en-tete de type : le consumer connait deja le format attendu, et
// un producteur tiers (l'app iPhone) n'a aucune raison d'en envoyer un.
valueSerializer.setAddTypeInfo(false);
return new DefaultKafkaProducerFactory<>(props, new StringSerializer(), valueSerializer);
}
@Bean
public KafkaTemplate<String, SensorEventMessage> kafkaTemplate(
ProducerFactory<String, SensorEventMessage> producerFactory) {
return new KafkaTemplate<>(producerFactory);
}
@Bean
public ConsumerFactory<String, SensorEventMessage> consumerFactory() {
Map<String, Object> props = new HashMap<>(
kafkaProperties.buildConsumerProperties(null));
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
JsonDeserializer<SensorEventMessage> delegate =
new JsonDeserializer<>(SensorEventMessage.class, objectMapper, false);
delegate.addTrustedPackages("me.belkacemi.iotalarm.*");
return new DefaultKafkaConsumerFactory<>(
props,
new StringDeserializer(),
new ErrorHandlingDeserializer<>(delegate));
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, SensorEventMessage>
kafkaListenerContainerFactory(
ConsumerFactory<String, SensorEventMessage> consumerFactory,
KafkaTemplate<String, SensorEventMessage> kafkaTemplate) {
var factory = new ConcurrentKafkaListenerContainerFactory<String, SensorEventMessage>();
factory.setConsumerFactory(consumerFactory);
// Un seul thread : l'agregat d'alarme est une ressource unique et
// l'anti-rafale suppose un ordre de traitement. Monter en charge se
// ferait par partitionnement sur deviceId, pas par concurrence locale.
factory.setConcurrency(1);
factory.setCommonErrorHandler(errorHandler(kafkaTemplate));
return factory;
}
/**
* 2 tentatives espacees d'une seconde, puis mise au rebut.
*
* <p>Rejouer indefiniment un message qu'on ne sait pas lire ne le rendra pas
* lisible - ca ne fait que bloquer tous les evenements suivants, y compris
* une vraie intrusion.</p>
*/
private DefaultErrorHandler errorHandler(KafkaTemplate<String, SensorEventMessage> template) {
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(
template,
(record, exception) -> new TopicPartition(alarmProperties.kafka().dlqTopic(), 0));
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, new FixedBackOff(1_000L, 2L));
handler.setRetryListeners((record, ex, attempt) ->
log.warn("Echec de traitement du message offset={} (tentative {}) : {}",
record.offset(), attempt, ex.getMessage()));
return handler;
}
}

View file

@ -0,0 +1,42 @@
package me.belkacemi.iotalarm.infrastructure.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* STOMP over WebSocket : le canal temps reel vers la console de supervision.
*
* <p>Broker en memoire : il n'y a qu'une instance de l'application, et les
* alertes sont deja durables en base. Un broker externe (RabbitMQ relay) ne
* deviendrait necessaire qu'a partir de plusieurs instances derriere le
* reverse proxy.</p>
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/** Topic des intrusions : c'est lui qui fait passer la console au rouge. */
public static final String TOPIC_ALERTS = "/topic/alerts";
/** Topic des changements d'etat armee / desarmee. */
public static final String TOPIC_ALARM_STATE = "/topic/alarm-state";
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
// La console est servie par la meme origine que l'API. Le
// repli SockJS couvre les reseaux d'entreprise qui filtrent
// encore l'upgrade WebSocket.
.setAllowedOriginPatterns("https://*.belkacemi.me", "http://localhost:*")
.withSockJS();
}
}

View file

@ -0,0 +1,78 @@
package me.belkacemi.iotalarm.infrastructure.kafka;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import me.belkacemi.iotalarm.domain.model.AlarmDecision;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import me.belkacemi.iotalarm.domain.port.in.ProcessSensorEventUseCase;
import me.belkacemi.iotalarm.infrastructure.kafka.message.SensorEventMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
/**
* ADAPTER ENTRANT : le consumer du topic {@code sensor-events}.
*
* <p>Cette classe ne contient <strong>aucune</strong> regle metier. Elle
* deserialise, delegue a {@link ProcessSensorEventUseCase}, et instrumente.
* Tout ce qui ressemble a une decision se trouve dans l'agregat.</p>
*/
@Component
public class SensorEventKafkaConsumer {
private static final Logger log = LoggerFactory.getLogger(SensorEventKafkaConsumer.class);
private final ProcessSensorEventUseCase processSensorEvent;
private final MeterRegistry meterRegistry;
public SensorEventKafkaConsumer(ProcessSensorEventUseCase processSensorEvent,
MeterRegistry meterRegistry) {
this.processSensorEvent = processSensorEvent;
this.meterRegistry = meterRegistry;
}
@KafkaListener(
topics = "${alarm.kafka.topic}",
groupId = "${spring.kafka.consumer.group-id}",
containerFactory = "kafkaListenerContainerFactory")
public void onSensorEvent(@Payload SensorEventMessage message,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset) {
log.debug("Reception offset {} partition {} : {}", offset, partition, message);
// Une conversion qui echoue leve une exception : le DefaultErrorHandler
// reessaie deux fois puis bascule le message en DLQ. Le flux continue.
SensorEvent event = message.toDomain();
AlarmDecision decision = processSensorEvent.process(event);
recordMetrics(event, decision);
}
/**
* Expose sur {@code /actuator/prometheus} le detail des decisions.
*
* <p>Le tag {@code outcome} porte le motif exact d'un rejet. C'est ce qui
* permet de repondre a "pourquoi l'alarme n'a pas sonne cette nuit ?" par
* une requete PromQL plutot que par une lecture de logs.</p>
*/
private void recordMetrics(SensorEvent event, AlarmDecision decision) {
String outcome = switch (decision) {
case AlarmDecision.Triggered ignored -> "TRIGGERED";
case AlarmDecision.Ignored rejected -> "IGNORED_" + rejected.reason().name();
};
Counter.builder("alarm.sensor.events.processed")
.description("Evenements capteur traites, par type et par issue")
.tag("device", event.deviceId().value())
.tag("type", event.type().name())
.tag("outcome", outcome)
.register(meterRegistry)
.increment();
}
}

View file

@ -0,0 +1,54 @@
package me.belkacemi.iotalarm.infrastructure.kafka;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import me.belkacemi.iotalarm.domain.port.out.SensorEventPublisher;
import me.belkacemi.iotalarm.infrastructure.config.AlarmProperties;
import me.belkacemi.iotalarm.infrastructure.kafka.message.SensorEventMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
/**
* ADAPTER SORTANT : implementation Kafka du port {@link SensorEventPublisher}.
*
* <p>La cle de partitionnement est le {@code deviceId}. Ce n'est pas un detail :
* Kafka ne garantit l'ordre qu'a l'interieur d'une partition. En cleant par
* capteur, tous les evenements d'un meme iPhone arrivent dans l'ordre ou ils
* ont ete emis - ce dont depend directement l'anti-rafale de l'agregat.</p>
*/
@Component
public class SensorEventKafkaPublisher implements SensorEventPublisher {
private static final Logger log = LoggerFactory.getLogger(SensorEventKafkaPublisher.class);
private final KafkaTemplate<String, SensorEventMessage> kafkaTemplate;
private final String topic;
public SensorEventKafkaPublisher(KafkaTemplate<String, SensorEventMessage> kafkaTemplate,
AlarmProperties properties) {
this.kafkaTemplate = kafkaTemplate;
this.topic = properties.kafka().topic();
}
@Override
public void publish(SensorEvent event) {
SensorEventMessage message = SensorEventMessage.fromDomain(event);
String partitionKey = event.deviceId().value();
kafkaTemplate.send(topic, partitionKey, message)
.whenComplete((result, throwable) -> {
if (throwable != null) {
// On ne propage pas : le capteur a deja recu son 202 et ne
// saurait rien faire de l'erreur. C'est l'alerting qui doit
// remonter un bus indisponible, pas l'iPhone.
log.error("Publication impossible sur {} pour le capteur {} : {}",
topic, partitionKey, throwable.getMessage(), throwable);
return;
}
var metadata = result.getRecordMetadata();
log.debug("Evenement {} publie sur {}-{} offset {}",
event.type(), metadata.topic(), metadata.partition(), metadata.offset());
});
}
}

View file

@ -0,0 +1,57 @@
package me.belkacemi.iotalarm.infrastructure.kafka.message;
import me.belkacemi.iotalarm.domain.model.DeviceId;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import me.belkacemi.iotalarm.domain.model.SensorEventType;
import java.time.Instant;
/**
* Contrat de serialisation du topic {@code sensor-events}.
*
* <p>Classe distincte de {@link SensorEvent}, et ce n'est pas de la ceremonie :
* le format de message est un contrat public partage avec des producteurs
* qu'on ne maitrise pas (l'app iPhone). Serialiser directement l'objet du
* domaine reviendrait a ce que tout renommage de champ casse le capteur en
* production. Ici, seul ce mapper bouge.</p>
*
* <p>Les champs sont volontairement laxistes ({@code String} plutot
* qu'{@code enum}, {@code Integer} nullable) : un capteur peut envoyer
* n'importe quoi, la validation se fait dans {@link #toDomain()}.</p>
*/
public record SensorEventMessage(
String deviceId,
String type,
Instant occurredAt,
Integer confidence) {
public static SensorEventMessage fromDomain(SensorEvent event) {
return new SensorEventMessage(
event.deviceId().value(),
event.type().name(),
event.occurredAt(),
event.confidence());
}
/**
* Traduit le message en objet du domaine, en validant au passage.
*
* @throws IllegalArgumentException si le message est inexploitable
*/
public SensorEvent toDomain() {
if (type == null || type.isBlank()) {
throw new IllegalArgumentException("Le champ 'type' est obligatoire");
}
SensorEventType parsedType;
try {
parsedType = SensorEventType.valueOf(type.trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Type d'evenement inconnu : '" + type + "'", e);
}
// Un capteur sans horloge fiable : on retombe sur l'heure de reception.
Instant timestamp = occurredAt != null ? occurredAt : Instant.now();
// Capteur binaire (contact de porte) : pas d'indice de confiance, donc certitude.
int parsedConfidence = confidence != null ? confidence : 100;
return new SensorEvent(DeviceId.of(deviceId), parsedType, timestamp, parsedConfidence);
}
}

View file

@ -0,0 +1,7 @@
package me.belkacemi.iotalarm.infrastructure.notification;
import java.time.Instant;
/** Charge utile poussee sur {@code /topic/alarm-state}. */
public record AlarmStateNotification(String state, Instant at, String message) {
}

View file

@ -0,0 +1,73 @@
package me.belkacemi.iotalarm.infrastructure.notification;
import me.belkacemi.iotalarm.domain.event.AlarmArmed;
import me.belkacemi.iotalarm.domain.event.AlarmDisarmed;
import me.belkacemi.iotalarm.domain.event.IntrusionDetected;
import me.belkacemi.iotalarm.domain.model.Alert;
import me.belkacemi.iotalarm.domain.model.AlarmState;
import me.belkacemi.iotalarm.domain.port.out.AlertNotifier;
import me.belkacemi.iotalarm.infrastructure.config.WebSocketConfig;
import me.belkacemi.iotalarm.infrastructure.persistence.mapper.AlertMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
/**
* ADAPTER SORTANT : implementation WebSocket/STOMP du port {@link AlertNotifier}.
*
* <p>Cote CQRS, c'est la projection "temps reel" du read model : le meme
* {@code AlertView} que sert l'API REST est pousse sur le websocket. La console
* affiche donc exactement la meme chose qu'elle vienne d'un rafraichissement
* ou d'un push - aucun risque de divergence entre les deux chemins.</p>
*/
@Component
public class WebSocketAlertNotifier implements AlertNotifier {
private static final Logger log = LoggerFactory.getLogger(WebSocketAlertNotifier.class);
private final SimpMessagingTemplate messagingTemplate;
public WebSocketAlertNotifier(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@Override
public void notifyIntrusion(IntrusionDetected event) {
log.info("Push WebSocket de l'alerte {} vers la console", event.alert().id());
messagingTemplate.convertAndSend(
WebSocketConfig.TOPIC_ALERTS, AlertMapper.toView(event.alert()));
messagingTemplate.convertAndSend(
WebSocketConfig.TOPIC_ALARM_STATE,
new AlarmStateNotification(
AlarmState.TRIGGERED.name(),
event.occurredAt(),
event.alert().message()));
}
@Override
public void notifyArmed(AlarmArmed event) {
messagingTemplate.convertAndSend(
WebSocketConfig.TOPIC_ALARM_STATE,
new AlarmStateNotification(
AlarmState.ARMED.name(),
event.occurredAt(),
"Alarme armee - delai de sortie " + event.exitDelay().toSeconds() + " s"));
}
@Override
public void notifyDisarmed(AlarmDisarmed event) {
messagingTemplate.convertAndSend(
WebSocketConfig.TOPIC_ALARM_STATE,
new AlarmStateNotification(
AlarmState.DISARMED.name(),
event.occurredAt(),
"Alarme desarmee"));
}
@Override
public void notifyAcknowledged(Alert alert) {
messagingTemplate.convertAndSend(
WebSocketConfig.TOPIC_ALERTS, AlertMapper.toView(alert));
}
}

View file

@ -0,0 +1,86 @@
package me.belkacemi.iotalarm.infrastructure.persistence;
import me.belkacemi.iotalarm.application.query.port.AlarmReadModelPort;
import me.belkacemi.iotalarm.application.query.view.AlarmStatusView;
import me.belkacemi.iotalarm.application.query.view.AlertView;
import me.belkacemi.iotalarm.domain.model.AlarmPolicy;
import me.belkacemi.iotalarm.domain.model.AlarmState;
import me.belkacemi.iotalarm.infrastructure.persistence.jpa.AlarmStateJpaRepository;
import me.belkacemi.iotalarm.infrastructure.persistence.jpa.AlertJpaRepository;
import me.belkacemi.iotalarm.infrastructure.persistence.mapper.AlertMapper;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* ADAPTER SORTANT du cote LECTURE : implementation du {@link AlarmReadModelPort}.
*
* <p>Il ne charge jamais l'agregat. Il assemble une vue a partir de requetes
* ciblees - c'est la raison d'etre du CQRS : afficher un compteur d'alertes non
* acquittees via un {@code COUNT(*)} plutot qu'en rehydratant un objet metier
* complet dont on n'utiliserait aucune methode.</p>
*/
@Component
public class AlarmReadModelAdapter implements AlarmReadModelPort {
private final AlertJpaRepository alertRepository;
private final AlarmStateJpaRepository alarmStateRepository;
private final AlarmPolicy policy;
private final Clock clock;
public AlarmReadModelAdapter(AlertJpaRepository alertRepository,
AlarmStateJpaRepository alarmStateRepository,
AlarmPolicy policy,
Clock clock) {
this.alertRepository = alertRepository;
this.alarmStateRepository = alarmStateRepository;
this.policy = policy;
this.clock = clock;
}
@Override
public Optional<AlarmStatusView> alarmStatus(String zoneId) {
long unacknowledged = alertRepository.countByZoneIdAndAcknowledgedFalse(zoneId);
AlertView lastAlert = alertRepository.findFirstByZoneIdOrderByDetectedAtDesc(zoneId)
.map(AlertMapper::toView)
.orElse(null);
return Optional.of(alarmStateRepository.findById(zoneId)
.map(entity -> new AlarmStatusView(
entity.getZoneId(),
entity.getState().name(),
entity.getStateChangedAt(),
policy.exitDelay().toSeconds(),
entity.getLastAlertAt(),
unacknowledged,
lastAlert))
// Base neuve : l'alarme n'a simplement jamais ete armee. La
// console doit afficher DESARMEE, pas une erreur 404.
.orElseGet(() -> new AlarmStatusView(
zoneId,
AlarmState.DISARMED.name(),
clock.instant(),
policy.exitDelay().toSeconds(),
null,
unacknowledged,
lastAlert)));
}
@Override
public List<AlertView> recentAlerts(String zoneId, int limit) {
return alertRepository
.findByZoneIdOrderByDetectedAtDesc(zoneId, PageRequest.of(0, limit))
.stream()
.map(AlertMapper::toView)
.toList();
}
@Override
public Optional<AlertView> findAlert(UUID alertId) {
return alertRepository.findById(alertId).map(AlertMapper::toView);
}
}

View file

@ -0,0 +1,54 @@
package me.belkacemi.iotalarm.infrastructure.persistence;
import me.belkacemi.iotalarm.domain.model.AlarmPolicy;
import me.belkacemi.iotalarm.domain.model.AlarmSystem;
import me.belkacemi.iotalarm.domain.port.out.AlarmStateRepository;
import me.belkacemi.iotalarm.infrastructure.persistence.entity.AlarmStateEntity;
import me.belkacemi.iotalarm.infrastructure.persistence.jpa.AlarmStateJpaRepository;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* ADAPTER SORTANT : implementation JPA du port {@link AlarmStateRepository}.
*
* <p>La {@link AlarmPolicy} n'est pas persistee avec l'etat : elle vient de la
* configuration et est reinjectee a la rehydratation. Changer le seuil de
* confiance dans {@code application.yml} prend donc effet au redemarrage, sans
* migration de donnees.</p>
*/
@Component
public class AlarmStateRepositoryAdapter implements AlarmStateRepository {
private final AlarmStateJpaRepository jpaRepository;
private final AlarmPolicy policy;
public AlarmStateRepositoryAdapter(AlarmStateJpaRepository jpaRepository, AlarmPolicy policy) {
this.jpaRepository = jpaRepository;
this.policy = policy;
}
@Override
public Optional<AlarmSystem> load(String zoneId) {
return jpaRepository.findById(zoneId)
.map(entity -> AlarmSystem.rehydrate(
entity.getZoneId(),
policy,
entity.getState(),
entity.getStateChangedAt(),
entity.getLastAlertAt()));
}
@Override
public void save(AlarmSystem alarmSystem) {
AlarmStateEntity entity = jpaRepository.findById(alarmSystem.zoneId())
.orElseGet(() -> new AlarmStateEntity(
alarmSystem.zoneId(),
alarmSystem.state(),
alarmSystem.stateChangedAt(),
alarmSystem.lastAlertAt()));
entity.apply(alarmSystem.state(), alarmSystem.stateChangedAt(), alarmSystem.lastAlertAt());
jpaRepository.save(entity);
}
}

View file

@ -0,0 +1,43 @@
package me.belkacemi.iotalarm.infrastructure.persistence;
import me.belkacemi.iotalarm.domain.model.Alert;
import me.belkacemi.iotalarm.domain.model.AlertId;
import me.belkacemi.iotalarm.domain.port.out.AlertRepository;
import me.belkacemi.iotalarm.infrastructure.persistence.entity.AlertEntity;
import me.belkacemi.iotalarm.infrastructure.persistence.jpa.AlertJpaRepository;
import me.belkacemi.iotalarm.infrastructure.persistence.mapper.AlertMapper;
import org.springframework.stereotype.Component;
import java.util.Optional;
/** ADAPTER SORTANT : implementation JPA du port {@link AlertRepository}. */
@Component
public class AlertRepositoryAdapter implements AlertRepository {
private final AlertJpaRepository jpaRepository;
public AlertRepositoryAdapter(AlertJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Alert save(Alert alert) {
// Une alerte est immuable a l'exception de son acquittement : plutot que
// de reconstruire l'entite (ce qui perdrait la gestion d'etat JPA), on
// met a jour la ligne existante quand elle est deja connue.
AlertEntity entity = jpaRepository.findById(alert.id().value())
.map(existing -> {
existing.setAcknowledged(alert.isAcknowledged());
existing.setAcknowledgedAt(alert.acknowledgedAt());
return existing;
})
.orElseGet(() -> AlertMapper.toEntity(alert));
return AlertMapper.toDomain(jpaRepository.save(entity));
}
@Override
public Optional<Alert> findById(AlertId id) {
return jpaRepository.findById(id.value()).map(AlertMapper::toDomain);
}
}

View file

@ -0,0 +1,79 @@
package me.belkacemi.iotalarm.infrastructure.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import me.belkacemi.iotalarm.domain.model.AlarmState;
import java.time.Instant;
/**
* Table {@code alarm_state} : une ligne par zone surveillee.
*
* <p>Persister l'etat n'est pas cosmetique - une alarme qui se desarme toute
* seule apres un {@code docker compose up -d} n'est pas une alarme.</p>
*
* <p>{@code @Version} pose un verrou optimiste : si un jour deux consumers
* traitent des evenements en parallele, la seconde ecriture echoue au lieu
* d'ecraser silencieusement la premiere.</p>
*/
@Entity
@Table(name = "alarm_state")
public class AlarmStateEntity {
@Id
@Column(name = "zone_id", nullable = false, length = 64)
private String zoneId;
@Enumerated(EnumType.STRING)
@Column(name = "state", nullable = false, length = 16)
private AlarmState state;
@Column(name = "state_changed_at", nullable = false)
private Instant stateChangedAt;
@Column(name = "last_alert_at")
private Instant lastAlertAt;
@Version
@Column(name = "version", nullable = false)
private long version;
protected AlarmStateEntity() {
// Requis par JPA.
}
public AlarmStateEntity(String zoneId, AlarmState state,
Instant stateChangedAt, Instant lastAlertAt) {
this.zoneId = zoneId;
this.state = state;
this.stateChangedAt = stateChangedAt;
this.lastAlertAt = lastAlertAt;
}
public String getZoneId() {
return zoneId;
}
public AlarmState getState() {
return state;
}
public Instant getStateChangedAt() {
return stateChangedAt;
}
public Instant getLastAlertAt() {
return lastAlertAt;
}
public void apply(AlarmState state, Instant stateChangedAt, Instant lastAlertAt) {
this.state = state;
this.stateChangedAt = stateChangedAt;
this.lastAlertAt = lastAlertAt;
}
}

View file

@ -0,0 +1,133 @@
package me.belkacemi.iotalarm.infrastructure.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import me.belkacemi.iotalarm.domain.model.SensorEventType;
import me.belkacemi.iotalarm.domain.model.Severity;
import java.time.Instant;
import java.util.UUID;
/**
* Table {@code alerts}. Objet de persistance, pas objet du domaine.
*
* <p>Annoter directement l'entite {@code Alert} aurait ete plus court, et aurait
* impose a l'agregat un constructeur sans argument, des setters et des champs
* mutables - c'est-a-dire exactement ce que l'encapsulation cherche a empecher.
* Le prix de cette separation est un mapper ; le benefice est un domaine qui
* reste testable sans base.</p>
*
* <p>Noter {@code trigger_type} : {@code trigger} est un mot reserve SQL.</p>
*/
@Entity
@Table(name = "alerts", indexes = {
@Index(name = "idx_alerts_zone_detected", columnList = "zone_id, detected_at DESC"),
@Index(name = "idx_alerts_acknowledged", columnList = "zone_id, acknowledged")
})
public class AlertEntity {
@Id
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "zone_id", nullable = false, length = 64)
private String zoneId;
@Column(name = "device_id", nullable = false, length = 64)
private String deviceId;
@Enumerated(EnumType.STRING)
@Column(name = "trigger_type", nullable = false, length = 32)
private SensorEventType trigger;
@Enumerated(EnumType.STRING)
@Column(name = "severity", nullable = false, length = 16)
private Severity severity;
@Column(name = "message", nullable = false, length = 255)
private String message;
@Column(name = "detected_at", nullable = false)
private Instant detectedAt;
@Column(name = "confidence", nullable = false)
private int confidence;
@Column(name = "acknowledged", nullable = false)
private boolean acknowledged;
@Column(name = "acknowledged_at")
private Instant acknowledgedAt;
protected AlertEntity() {
// Requis par JPA.
}
public AlertEntity(UUID id, String zoneId, String deviceId, SensorEventType trigger,
Severity severity, String message, Instant detectedAt, int confidence,
boolean acknowledged, Instant acknowledgedAt) {
this.id = id;
this.zoneId = zoneId;
this.deviceId = deviceId;
this.trigger = trigger;
this.severity = severity;
this.message = message;
this.detectedAt = detectedAt;
this.confidence = confidence;
this.acknowledged = acknowledged;
this.acknowledgedAt = acknowledgedAt;
}
public UUID getId() {
return id;
}
public String getZoneId() {
return zoneId;
}
public String getDeviceId() {
return deviceId;
}
public SensorEventType getTrigger() {
return trigger;
}
public Severity getSeverity() {
return severity;
}
public String getMessage() {
return message;
}
public Instant getDetectedAt() {
return detectedAt;
}
public int getConfidence() {
return confidence;
}
public boolean isAcknowledged() {
return acknowledged;
}
public Instant getAcknowledgedAt() {
return acknowledgedAt;
}
public void setAcknowledged(boolean acknowledged) {
this.acknowledged = acknowledged;
}
public void setAcknowledgedAt(Instant acknowledgedAt) {
this.acknowledgedAt = acknowledgedAt;
}
}

View file

@ -0,0 +1,7 @@
package me.belkacemi.iotalarm.infrastructure.persistence.jpa;
import me.belkacemi.iotalarm.infrastructure.persistence.entity.AlarmStateEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AlarmStateJpaRepository extends JpaRepository<AlarmStateEntity, String> {
}

View file

@ -0,0 +1,19 @@
package me.belkacemi.iotalarm.infrastructure.persistence.jpa;
import me.belkacemi.iotalarm.infrastructure.persistence.entity.AlertEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Repository Spring Data. Detail d'infrastructure : le domaine ne le voit jamais. */
public interface AlertJpaRepository extends JpaRepository<AlertEntity, UUID> {
List<AlertEntity> findByZoneIdOrderByDetectedAtDesc(String zoneId, Pageable pageable);
Optional<AlertEntity> findFirstByZoneIdOrderByDetectedAtDesc(String zoneId);
long countByZoneIdAndAcknowledgedFalse(String zoneId);
}

View file

@ -0,0 +1,85 @@
package me.belkacemi.iotalarm.infrastructure.persistence.mapper;
import me.belkacemi.iotalarm.application.query.view.AlertView;
import me.belkacemi.iotalarm.domain.model.Alert;
import me.belkacemi.iotalarm.domain.model.AlertId;
import me.belkacemi.iotalarm.domain.model.DeviceId;
import me.belkacemi.iotalarm.infrastructure.persistence.entity.AlertEntity;
/**
* Traduction entre les trois representations d'une alerte :
* l'entite du domaine, la ligne en base, et le read model affiche.
*
* <p>Regroupees ici volontairement : quand un champ est ajoute, les trois
* traductions a mettre a jour sont cote a cote dans un seul fichier.</p>
*/
public final class AlertMapper {
private AlertMapper() {
}
public static AlertEntity toEntity(Alert alert) {
return new AlertEntity(
alert.id().value(),
alert.zoneId(),
alert.deviceId().value(),
alert.trigger(),
alert.severity(),
alert.message(),
alert.detectedAt(),
alert.confidence(),
alert.isAcknowledged(),
alert.acknowledgedAt());
}
public static Alert toDomain(AlertEntity entity) {
return Alert.rehydrate(
AlertId.of(entity.getId()),
entity.getZoneId(),
DeviceId.of(entity.getDeviceId()),
entity.getTrigger(),
entity.getSeverity(),
entity.getMessage(),
entity.getDetectedAt(),
entity.getConfidence(),
entity.isAcknowledged(),
entity.getAcknowledgedAt());
}
public static AlertView toView(AlertEntity entity) {
DeviceId deviceId = DeviceId.of(entity.getDeviceId());
return new AlertView(
entity.getId(),
deviceId.value(),
deviceId.displayName(),
entity.getTrigger().name(),
entity.getTrigger().label(),
entity.getSeverity().name(),
entity.getMessage(),
entity.getDetectedAt(),
entity.getConfidence(),
entity.isAcknowledged(),
entity.getAcknowledgedAt());
}
/**
* Projette directement depuis le domaine, sans relire la base.
*
* <p>Utilise par le push WebSocket : l'alerte vient d'etre ecrite, faire un
* aller-retour SQL pour l'afficher n'apporterait rien.</p>
*/
public static AlertView toView(Alert alert) {
return new AlertView(
alert.id().value(),
alert.deviceId().value(),
alert.deviceId().displayName(),
alert.trigger().name(),
alert.trigger().label(),
alert.severity().name(),
alert.message(),
alert.detectedAt(),
alert.confidence(),
alert.isAcknowledged(),
alert.acknowledgedAt());
}
}

View file

@ -0,0 +1,82 @@
package me.belkacemi.iotalarm.infrastructure.simulator;
import me.belkacemi.iotalarm.domain.model.DeviceId;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import me.belkacemi.iotalarm.domain.model.SensorEventType;
import me.belkacemi.iotalarm.domain.port.in.IngestSensorEventUseCase;
import me.belkacemi.iotalarm.domain.port.in.command.IngestSensorEventCommand;
import me.belkacemi.iotalarm.infrastructure.config.AlarmProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.util.concurrent.ThreadLocalRandom;
/**
* ADAPTER ENTRANT : le capteur, quand l'iPhone n'est pas la.
*
* <p>Sur le deploiement public, l'iPhone du studio n'est evidemment pas
* joignable. Ce simulateur produit un trafic realiste - une majorite de
* heartbeats, de temps en temps un mouvement - pour que la demonstration soit
* vivante sans capteur physique.</p>
*
* <p>Point d'architecture : il passe par le <em>meme port</em>
* {@link IngestSensorEventUseCase} que l'endpoint REST. Le simulateur n'est pas
* un mode degrade cable a part, c'est un producteur d'evenements de plus. Le
* vrai iPhone se branche en POSTant sur {@code /api/v1/sensor-events}, sans
* qu'une ligne du domaine ne change.</p>
*/
@Component
@ConditionalOnProperty(prefix = "alarm.simulator", name = "enabled", havingValue = "true")
public class IphoneSensorSimulator {
private static final Logger log = LoggerFactory.getLogger(IphoneSensorSimulator.class);
private final IngestSensorEventUseCase ingestSensorEvent;
private final AlarmProperties.Simulator config;
private final DeviceId deviceId;
private final Clock clock;
public IphoneSensorSimulator(IngestSensorEventUseCase ingestSensorEvent,
AlarmProperties properties,
Clock clock) {
this.ingestSensorEvent = ingestSensorEvent;
this.config = properties.simulator();
this.deviceId = DeviceId.of(properties.simulator().deviceId());
this.clock = clock;
log.info("Simulateur de capteur actif sur {} (periode {})",
deviceId, config.interval());
}
@Scheduled(fixedDelayString = "${alarm.simulator.interval}")
public void emit() {
SensorEvent event = nextEvent();
ingestSensorEvent.ingest(new IngestSensorEventCommand(event));
}
/**
* Un capteur reel est ennuyeux : il envoie surtout des signaux de vie.
* Le mouvement est l'exception, et sa confiance varie - c'est precisement
* ce qui met a l'epreuve le seuil configure dans l'agregat.
*/
private SensorEvent nextEvent() {
ThreadLocalRandom random = ThreadLocalRandom.current();
if (random.nextInt(100) >= config.intrusionChancePercent()) {
return SensorEvent.of(deviceId, SensorEventType.HEARTBEAT, clock.instant());
}
SensorEventType type = random.nextInt(100) < 80
? SensorEventType.MOTION_DETECTED
: SensorEventType.SOUND_DETECTED;
// Confiance entre 35 et 99 : une partie des detections tombera sous le
// seuil et sera ecartee par le domaine. C'est le comportement attendu.
int confidence = random.nextInt(35, 100);
return new SensorEvent(deviceId, type, clock.instant(), confidence);
}
}

View file

@ -0,0 +1,79 @@
package me.belkacemi.iotalarm.presentation.advice;
import me.belkacemi.iotalarm.domain.exception.DomainException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.util.stream.Collectors;
/**
* Traduit les exceptions en reponses HTTP, au format RFC 7807 (ProblemDetail).
*
* <p>C'est ici, et nulle part ailleurs, que le domaine rencontre HTTP. Une
* {@link DomainException} ne sait pas ce qu'est un code 409 - et c'est
* precisement ce qui permet de la lever depuis l'agregat sans le coupler au web.</p>
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* Regle metier violee : armer une alarme deja armee, acquitter deux fois.
* 409 et non 400 - la requete est bien formee, c'est l'etat qui s'y oppose.
*/
@ExceptionHandler(DomainException.class)
public ProblemDetail onDomainException(DomainException exception) {
log.info("Regle metier refusee : {}", exception.getMessage());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, exception.getMessage());
problem.setTitle("Regle metier violee");
problem.setType(URI.create("urn:iot-alarm:domain-rule-violation"));
return problem;
}
/** Donnee invalide a la frontiere : type de capteur inconnu, deviceId malforme. */
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail onIllegalArgument(IllegalArgumentException exception) {
log.info("Requete rejetee : {}", exception.getMessage());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, exception.getMessage());
problem.setTitle("Requete invalide");
problem.setType(URI.create("urn:iot-alarm:invalid-request"));
return problem;
}
/** Echec de Bean Validation : champs manquants ou hors bornes. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail onValidationError(MethodArgumentNotValidException exception) {
String details = exception.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + " : " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, details);
problem.setTitle("Validation echouee");
problem.setType(URI.create("urn:iot-alarm:validation-failed"));
return problem;
}
/**
* Filet de securite. On journalise la stacktrace mais on ne la renvoie pas :
* l'endpoint d'ingestion est expose publiquement.
*/
@ExceptionHandler(Exception.class)
public ProblemDetail onUnexpectedError(Exception exception) {
log.error("Erreur inattendue", exception);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"Une erreur interne est survenue.");
problem.setTitle("Erreur interne");
return problem;
}
}

View file

@ -0,0 +1,92 @@
package me.belkacemi.iotalarm.presentation.rest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import me.belkacemi.iotalarm.domain.event.AlarmArmed;
import me.belkacemi.iotalarm.domain.event.AlarmDisarmed;
import me.belkacemi.iotalarm.domain.model.Alert;
import me.belkacemi.iotalarm.domain.model.AlarmState;
import me.belkacemi.iotalarm.domain.model.AlertId;
import me.belkacemi.iotalarm.domain.port.in.AcknowledgeAlertUseCase;
import me.belkacemi.iotalarm.domain.port.in.ArmAlarmUseCase;
import me.belkacemi.iotalarm.domain.port.in.DisarmAlarmUseCase;
import me.belkacemi.iotalarm.domain.port.in.command.AcknowledgeAlertCommand;
import me.belkacemi.iotalarm.domain.port.in.command.ArmAlarmCommand;
import me.belkacemi.iotalarm.domain.port.in.command.DisarmAlarmCommand;
import me.belkacemi.iotalarm.presentation.rest.dto.AlarmActionResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Clock;
import java.util.UUID;
/**
* ADAPTER ENTRANT : cote COMMAND de l'API.
*
* <p>Separe de {@link AlarmQueryController} et ce n'est pas cosmetique : ce
* controleur ne retourne jamais d'etat a lire, il declare ce qui vient d'etre
* fait. La console rafraichit sa vue via le WebSocket ou l'API de query.</p>
*/
@RestController
@RequestMapping("/api/v1")
@Tag(name = "Commandes", description = "Pilotage de l'alarme (cote ecriture du CQRS)")
public class AlarmCommandController {
private final ArmAlarmUseCase armAlarm;
private final DisarmAlarmUseCase disarmAlarm;
private final AcknowledgeAlertUseCase acknowledgeAlert;
private final String zoneId;
private final Clock clock;
public AlarmCommandController(ArmAlarmUseCase armAlarm,
DisarmAlarmUseCase disarmAlarm,
AcknowledgeAlertUseCase acknowledgeAlert,
@Value("${alarm.zone-id}") String zoneId,
Clock clock) {
this.armAlarm = armAlarm;
this.disarmAlarm = disarmAlarm;
this.acknowledgeAlert = acknowledgeAlert;
this.zoneId = zoneId;
this.clock = clock;
}
@PostMapping("/alarm/arm")
@Operation(summary = "Armer l'alarme",
description = "Le delai de sortie demarre immediatement.")
public ResponseEntity<AlarmActionResponse> arm() {
AlarmArmed event = armAlarm.arm(new ArmAlarmCommand(zoneId, clock.instant()));
return ResponseEntity.ok(new AlarmActionResponse(
event.zoneId(),
AlarmState.ARMED.name(),
event.occurredAt(),
"Alarme armee - delai de sortie " + event.exitDelay().toSeconds() + " s"));
}
@PostMapping("/alarm/disarm")
@Operation(summary = "Desarmer l'alarme")
public ResponseEntity<AlarmActionResponse> disarm() {
AlarmDisarmed event = disarmAlarm.disarm(new DisarmAlarmCommand(zoneId, clock.instant()));
return ResponseEntity.ok(new AlarmActionResponse(
event.zoneId(),
AlarmState.DISARMED.name(),
event.occurredAt(),
"Alarme desarmee (etat precedent : " + event.previousState() + ")"));
}
@PostMapping("/alerts/{alertId}/acknowledge")
@Operation(summary = "Acquitter une alerte",
description = "Marque l'alerte comme vue. Ne modifie pas l'etat de l'alarme.")
public ResponseEntity<AlarmActionResponse> acknowledge(@PathVariable UUID alertId) {
Alert alert = acknowledgeAlert.acknowledge(
new AcknowledgeAlertCommand(AlertId.of(alertId), clock.instant()));
return ResponseEntity.ok(new AlarmActionResponse(
alert.zoneId(),
"ACKNOWLEDGED",
alert.acknowledgedAt(),
"Alerte acquittee : " + alert.message()));
}
}

View file

@ -0,0 +1,57 @@
package me.belkacemi.iotalarm.presentation.rest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import me.belkacemi.iotalarm.application.query.handler.AlarmQueryService;
import me.belkacemi.iotalarm.application.query.view.AlarmStatusView;
import me.belkacemi.iotalarm.application.query.view.AlertView;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
/**
* ADAPTER ENTRANT : cote QUERY de l'API. Lecture seule, aucun effet de bord.
*/
@RestController
@RequestMapping("/api/v1")
@Tag(name = "Requetes", description = "Consultation de l'etat et des alertes (cote lecture du CQRS)")
public class AlarmQueryController {
private final AlarmQueryService queryService;
private final String zoneId;
public AlarmQueryController(AlarmQueryService queryService, @Value("${alarm.zone-id}") String zoneId) {
this.queryService = queryService;
this.zoneId = zoneId;
}
@GetMapping("/alarm/status")
@Operation(summary = "Etat courant de l'alarme")
public ResponseEntity<AlarmStatusView> status() {
return queryService.status(zoneId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@GetMapping("/alerts")
@Operation(summary = "Historique des alertes, les plus recentes d'abord")
public ResponseEntity<List<AlertView>> alerts(
@RequestParam(required = false) Integer limit) {
return ResponseEntity.ok(queryService.recentAlerts(zoneId, limit));
}
@GetMapping("/alerts/{alertId}")
@Operation(summary = "Detail d'une alerte")
public ResponseEntity<AlertView> alert(@PathVariable UUID alertId) {
return queryService.alert(alertId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
}

View file

@ -0,0 +1,58 @@
package me.belkacemi.iotalarm.presentation.rest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import me.belkacemi.iotalarm.domain.port.in.IngestSensorEventUseCase;
import me.belkacemi.iotalarm.domain.port.in.command.IngestSensorEventCommand;
import me.belkacemi.iotalarm.presentation.rest.dto.IngestSensorEventRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Clock;
import java.util.Map;
/**
* ADAPTER ENTRANT : porte d'entree des capteurs.
*
* <p>Repond <strong>202 Accepted</strong> et non 201 : a cet instant, rien n'a
* encore ete decide. L'evenement est sur le bus, le traitement metier suivra.
* Le code de statut dit la verite sur ce qui s'est passe - l'iPhone peut se
* rendormir sans attendre la base de donnees.</p>
*/
@RestController
@RequestMapping("/api/v1/sensor-events")
@Tag(name = "Ingestion capteur", description = "Reception des observations des capteurs")
public class SensorIngestController {
private final IngestSensorEventUseCase ingestSensorEvent;
private final Clock clock;
public SensorIngestController(IngestSensorEventUseCase ingestSensorEvent, Clock clock) {
this.ingestSensorEvent = ingestSensorEvent;
this.clock = clock;
}
@PostMapping
@Operation(
summary = "Remonter une observation capteur",
description = "Publie l'observation sur le topic Kafka sensor-events. "
+ "Aucune regle metier n'est appliquee a ce stade.")
public ResponseEntity<Map<String, Object>> ingest(
@Valid @RequestBody IngestSensorEventRequest request) {
SensorEvent event = request.toDomain(clock.instant());
ingestSensorEvent.ingest(new IngestSensorEventCommand(event));
return ResponseEntity.accepted().body(Map.of(
"status", "ACCEPTED",
"deviceId", event.deviceId().value(),
"type", event.type().name(),
"occurredAt", event.occurredAt()));
}
}

View file

@ -0,0 +1,7 @@
package me.belkacemi.iotalarm.presentation.rest.dto;
import java.time.Instant;
/** Reponse a une commande de pilotage de l'alarme. */
public record AlarmActionResponse(String zoneId, String state, Instant at, String message) {
}

View file

@ -0,0 +1,57 @@
package me.belkacemi.iotalarm.presentation.rest.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import me.belkacemi.iotalarm.domain.model.DeviceId;
import me.belkacemi.iotalarm.domain.model.SensorEvent;
import me.belkacemi.iotalarm.domain.model.SensorEventType;
import java.time.Instant;
/**
* Contrat HTTP d'ingestion. C'est ce que l'iPhone POSTe.
*
* <pre>
* {"deviceId": "iphone-biblio", "type": "MOTION_DETECTED"}
* </pre>
*
* <p>{@code occurredAt} et {@code confidence} sont optionnels : un capteur
* binaire n'a ni horloge synchronisee ni notion de probabilite.</p>
*/
public record IngestSensorEventRequest(
@NotBlank(message = "deviceId est obligatoire")
String deviceId,
@NotBlank(message = "type est obligatoire")
String type,
Instant occurredAt,
@Min(value = 0, message = "confidence doit etre >= 0")
@Max(value = 100, message = "confidence doit etre <= 100")
Integer confidence) {
/**
* Traduit la requete HTTP en Value Object du domaine.
*
* <p>La frontiere est ici : au-dela de cette methode, plus aucune
* {@code String} non validee ne circule.</p>
*/
public SensorEvent toDomain(Instant receivedAt) {
SensorEventType parsedType;
try {
parsedType = SensorEventType.valueOf(type.trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Type d'evenement inconnu : '" + type + "'. Valeurs acceptees : "
+ java.util.Arrays.toString(SensorEventType.values()), e);
}
return new SensorEvent(
DeviceId.of(deviceId),
parsedType,
occurredAt != null ? occurredAt : receivedAt,
confidence != null ? confidence : 100);
}
}

View file

@ -0,0 +1,39 @@
package me.belkacemi.iotalarm.presentation.websocket;
import me.belkacemi.iotalarm.application.query.handler.AlarmQueryService;
import me.belkacemi.iotalarm.application.query.view.AlarmStatusView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
/**
* ADAPTER ENTRANT WebSocket.
*
* <p>A la connexion, la console envoie sur {@code /app/alarm/status} pour
* recuperer l'etat courant. Sans ce message, un navigateur ouvert juste apres
* un armement resterait affiche "desarmee" jusqu'au prochain evenement - un
* websocket ne pousse que les changements, pas l'etat initial.</p>
*/
@Controller
public class AlarmWebSocketController {
private static final Logger log = LoggerFactory.getLogger(AlarmWebSocketController.class);
private final AlarmQueryService queryService;
private final String zoneId;
public AlarmWebSocketController(AlarmQueryService queryService, @Value("${alarm.zone-id}") String zoneId) {
this.queryService = queryService;
this.zoneId = zoneId;
}
@MessageMapping("/alarm/status")
@SendTo("/topic/alarm-status")
public AlarmStatusView status() {
log.debug("Demande d'etat initial recue sur le websocket");
return queryService.status(zoneId).orElse(null);
}
}

View file

@ -0,0 +1,20 @@
# Profil de developpement : base H2 en memoire, schema gere par Hibernate.
# Kafka reste necessaire -> `docker compose up -d kafka`.
spring:
datasource:
url: jdbc:h2:mem:iotalarm;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
username: sa
password: ""
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
database-platform: org.hibernate.dialect.H2Dialect
flyway:
enabled: false
logging:
level:
me.belkacemi.iotalarm: DEBUG

View file

@ -0,0 +1,18 @@
# Profil de deploiement Hetzner : PostgreSQL, Kafka en conteneur.
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://postgres:5432/iotalarm}
username: ${DB_USERNAME:iotalarm}
password: ${DB_PASSWORD}
hikari:
# Serveur a 2 vCPU partages avec d'autres services : un pool large
# n'accelere rien, il consomme juste de la memoire.
maximum-pool-size: 8
minimum-idle: 2
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
logging:
level:
me.belkacemi.iotalarm: INFO

View file

@ -0,0 +1,95 @@
# ---------------------------------------------------------------------------
# Configuration commune. Les profils prod / local ne surchargent que ce qui
# differe (source de donnees, simulateur).
# ---------------------------------------------------------------------------
spring:
application:
name: iot-alarm-system
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
consumer:
group-id: ${KAFKA_GROUP_ID:iot-alarm-system}
# "earliest" : au premier demarrage on rejoue l'historique du topic plutot
# que de demarrer aveugle. Le domaine est idempotent sur les doublons
# grace a l'anti-rafale.
auto-offset-reset: earliest
enable-auto-commit: false
listener:
ack-mode: record
jpa:
hibernate:
# Le schema est la propriete de Flyway, pas d'Hibernate. "validate" fait
# echouer le demarrage si l'entite et la table ont diverge - on prefere
# un conteneur qui refuse de demarrer a une colonne silencieusement absente.
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC
flyway:
enabled: true
baseline-on-migrate: true
threads:
virtual:
enabled: true
server:
port: ${SERVER_PORT:8080}
# Derriere le reverse proxy Caddy : sans ca, les URLs generees seraient en http.
forward-headers-strategy: framework
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
health:
probes:
enabled: true
show-details: when-authorized
metrics:
tags:
application: iot-alarm-system
# ---------------------------------------------------------------------------
# Reglages metier de l'alarme
# ---------------------------------------------------------------------------
alarm:
zone-id: studio
# Le temps de prendre mes clefs et de sortir sans declencher l'alarme moi-meme.
exit-delay: ${ALARM_EXIT_DELAY:30s}
# Anti-rafale : sans ce filtre, un intrus qui traverse la piece genere
# des dizaines d'alertes en quelques secondes.
alert-cooldown: ${ALARM_COOLDOWN:15s}
# Un rideau qui bouge n'est pas un cambrioleur. Se regle en observant
# les faux positifs pendant une semaine.
minimum-confidence: ${ALARM_MIN_CONFIDENCE:60}
kafka:
topic: sensor-events
dlq-topic: sensor-events-dlq
partitions: 3
replication-factor: 1
simulator:
enabled: ${SIMULATOR_ENABLED:true}
interval: ${SIMULATOR_INTERVAL:8s}
device-id: iphone-biblio
# 25 % des emissions sont un mouvement ou un son ; le reste, des heartbeats.
intrusion-chance-percent: ${SIMULATOR_INTRUSION_CHANCE:25}
logging:
level:
me.belkacemi.iotalarm: ${LOG_LEVEL:INFO}
org.apache.kafka: WARN
org.apache.kafka.clients.NetworkClient: ERROR

View file

@ -0,0 +1,48 @@
-- ---------------------------------------------------------------------------
-- V1 : schema initial du systeme d'alarme.
--
-- Le schema appartient a Flyway. Hibernate est en ddl-auto=validate : si une
-- entite et sa table divergent, l'application refuse de demarrer plutot que
-- de tomber en marche sur la premiere requete.
-- ---------------------------------------------------------------------------
-- Etat de l'alarme : une ligne par zone surveillee.
-- Persiste pour qu'un redemarrage du conteneur ne desarme pas l'alarme.
CREATE TABLE alarm_state (
zone_id VARCHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL,
state_changed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
last_alert_at TIMESTAMP(6) WITH TIME ZONE,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT pk_alarm_state PRIMARY KEY (zone_id),
CONSTRAINT ck_alarm_state_state CHECK (state IN ('DISARMED', 'ARMED', 'TRIGGERED'))
);
COMMENT ON TABLE alarm_state IS 'Etat courant de l''agregat AlarmSystem, une ligne par zone';
COMMENT ON COLUMN alarm_state.version IS 'Verrou optimiste JPA';
-- Historique des intrusions constatees.
-- "trigger_type" et non "trigger" : TRIGGER est un mot reserve SQL.
CREATE TABLE alerts (
id UUID NOT NULL,
zone_id VARCHAR(64) NOT NULL,
device_id VARCHAR(64) NOT NULL,
trigger_type VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
message VARCHAR(255) NOT NULL,
detected_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
confidence INTEGER NOT NULL,
acknowledged BOOLEAN NOT NULL DEFAULT FALSE,
acknowledged_at TIMESTAMP(6) WITH TIME ZONE,
CONSTRAINT pk_alerts PRIMARY KEY (id),
CONSTRAINT ck_alerts_confidence CHECK (confidence BETWEEN 0 AND 100),
CONSTRAINT ck_alerts_severity CHECK (severity IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL'))
);
COMMENT ON TABLE alerts IS 'Alertes levees par l''agregat AlarmSystem';
-- Requete dominante de la console : les N dernieres alertes d'une zone.
CREATE INDEX idx_alerts_zone_detected ON alerts (zone_id, detected_at DESC);
-- Compteur d'alertes en attente, affiche en permanence dans le bandeau.
CREATE INDEX idx_alerts_acknowledged ON alerts (zone_id, acknowledged);

View file

@ -0,0 +1,270 @@
/* ---------------------------------------------------------------------------
* Console de supervision - client temps reel.
*
* Deux sources alimentent l'affichage :
* - au chargement, l'API REST de QUERY donne l'etat complet (une console
* ouverte apres coup doit voir ce qui s'est passe avant elle) ;
* - ensuite, STOMP pousse les changements au fil de l'eau.
*
* Les deux chemins servent exactement le meme objet AlertView, cote backend.
* Il n'y a donc aucun risque de divergence entre "ce qui est affiche au
* chargement" et "ce qui arrive en push".
* --------------------------------------------------------------------------- */
(function () {
'use strict';
const API = '/api/v1';
const MAX_FEED_ITEMS = 60;
const el = {
panel: document.getElementById('status-panel'),
value: document.getElementById('status-value'),
detail: document.getElementById('status-detail'),
btnArm: document.getElementById('btn-arm'),
btnDisarm: document.getElementById('btn-disarm'),
btnRefresh: document.getElementById('btn-refresh'),
feed: document.getElementById('feed'),
feedEmpty: document.getElementById('feed-empty'),
linkStatus: document.getElementById('link-status'),
linkLabel: document.getElementById('link-label'),
unacked: document.getElementById('metric-unacked'),
last: document.getElementById('metric-last'),
delay: document.getElementById('metric-delay'),
events: document.getElementById('metric-events')
};
const LABELS = {
DISARMED: 'DESARMEE',
ARMED: 'ARMEE',
TRIGGERED: 'INTRUSION DETECTEE'
};
const DETAILS = {
DISARMED: 'Aucune surveillance active. Les capteurs remontent, rien ne se declenche.',
ARMED: 'Le studio est sous surveillance. Toute presence detectee leve une alerte.',
TRIGGERED: 'Presence detectee dans un logement cense etre vide.'
};
let eventCount = 0;
let currentState = 'DISARMED';
/* --------------------------- Rendu de l'etat -------------------------- */
function renderState(state, message) {
currentState = state;
el.panel.dataset.state = state;
el.value.textContent = LABELS[state] || state;
el.detail.textContent = message || DETAILS[state] || '';
el.btnArm.disabled = (state === 'ARMED' || state === 'TRIGGERED');
el.btnDisarm.disabled = (state === 'DISARMED');
// Le titre de l'onglet aussi : la console est rarement au premier plan.
document.title = state === 'TRIGGERED'
? '[!] INTRUSION - Studio'
: 'Console de supervision - Studio';
}
function renderStatus(status) {
if (!status) { return; }
renderState(status.state, null);
el.unacked.textContent = status.unacknowledgedCount;
el.delay.textContent = status.exitDelaySeconds + ' s';
el.last.textContent = status.lastAlertAt ? formatDate(status.lastAlertAt) : 'Aucune';
}
/* ---------------------------- Rendu du flux --------------------------- */
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
}
function buildFeedItem(alert) {
const li = document.createElement('li');
li.className = 'feed-item' + (alert.acknowledged ? ' acked' : '');
li.dataset.alertId = alert.id;
const dot = document.createElement('span');
dot.className = 'sev sev-' + alert.severity;
li.appendChild(dot);
const body = document.createElement('div');
body.className = 'feed-body';
const msg = document.createElement('p');
msg.className = 'feed-msg';
// textContent et non innerHTML : le message contient un deviceId qui
// vient d'une requete externe. Pas de XSS par le capteur.
msg.textContent = alert.message;
body.appendChild(msg);
const meta = document.createElement('p');
meta.className = 'feed-meta';
meta.textContent = formatDate(alert.detectedAt)
+ ' · ' + alert.severity
+ ' · confiance ' + alert.confidence + ' %'
+ (alert.acknowledged ? ' · acquittee' : '');
body.appendChild(meta);
li.appendChild(body);
if (!alert.acknowledged) {
const ack = document.createElement('button');
ack.type = 'button';
ack.className = 'feed-ack';
ack.textContent = 'Acquitter';
ack.addEventListener('click', () => acknowledge(alert.id));
li.appendChild(ack);
}
return li;
}
function upsertAlert(alert, prepend) {
el.feedEmpty.hidden = true;
const existing = el.feed.querySelector('[data-alert-id="' + alert.id + '"]');
const node = buildFeedItem(alert);
if (existing) {
existing.replaceWith(node);
return;
}
if (prepend) {
el.feed.insertBefore(node, el.feed.firstChild);
} else {
el.feed.appendChild(node);
}
while (el.feed.children.length > MAX_FEED_ITEMS) {
el.feed.removeChild(el.feed.lastChild);
}
}
function renderAlerts(alerts) {
el.feed.replaceChildren(el.feedEmpty);
el.feedEmpty.hidden = alerts.length > 0;
alerts.forEach(a => upsertAlert(a, false));
}
/* ------------------------------- REST --------------------------------- */
async function call(path, options) {
const response = await fetch(API + path, options);
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
throw new Error(problem.detail || ('HTTP ' + response.status));
}
return response.status === 204 ? null : response.json();
}
async function loadStatus() {
try {
renderStatus(await call('/alarm/status'));
} catch (e) {
console.error('Etat indisponible', e);
}
}
async function loadAlerts() {
try {
renderAlerts(await call('/alerts?limit=' + MAX_FEED_ITEMS));
} catch (e) {
console.error('Alertes indisponibles', e);
}
}
async function sendCommand(path, button) {
button.disabled = true;
try {
const result = await call(path, { method: 'POST' });
renderState(result.state, result.message);
await loadStatus();
} catch (e) {
// 409 attendu si l'alarme est deja dans l'etat demande : on
// resynchronise plutot que d'afficher une erreur a l'utilisateur.
console.warn('Commande refusee', e.message);
await loadStatus();
}
}
async function acknowledge(alertId) {
try {
await call('/alerts/' + alertId + '/acknowledge', { method: 'POST' });
await Promise.all([loadStatus(), loadAlerts()]);
} catch (e) {
console.error('Acquittement impossible', e);
}
}
/* ------------------------------- STOMP -------------------------------- */
function setLink(state, label) {
el.linkStatus.dataset.state = state;
el.linkLabel.textContent = label;
}
function connect() {
const client = new StompJs.Client({
webSocketFactory: () => new SockJS('/ws'),
// Reconnexion automatique : la console reste ouverte des semaines
// sur un ecran, elle doit survivre a un redeploiement du backend.
reconnectDelay: 3000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
debug: () => {}
});
client.onConnect = () => {
setLink('connected', 'Temps reel actif');
client.subscribe('/topic/alerts', frame => {
eventCount += 1;
el.events.textContent = eventCount;
upsertAlert(JSON.parse(frame.body), true);
loadStatus();
});
client.subscribe('/topic/alarm-state', frame => {
eventCount += 1;
el.events.textContent = eventCount;
const notification = JSON.parse(frame.body);
renderState(notification.state, notification.message);
loadStatus();
});
client.subscribe('/topic/alarm-status', frame => {
renderStatus(JSON.parse(frame.body));
});
// Un websocket ne pousse que les changements. Sans cette demande
// explicite, une console ouverte apres un armement resterait
// affichee "desarmee" jusqu'au prochain evenement.
client.publish({ destination: '/app/alarm/status', body: '{}' });
};
client.onWebSocketClose = () => setLink('down', 'Deconnecte - reconnexion...');
client.onStompError = frame => {
console.error('Erreur STOMP', frame.headers.message);
setLink('down', 'Erreur de liaison');
};
setLink('connecting', 'Connexion...');
client.activate();
}
/* ------------------------------ Demarrage ----------------------------- */
el.btnArm.addEventListener('click', () => sendCommand('/alarm/arm', el.btnArm));
el.btnDisarm.addEventListener('click', () => sendCommand('/alarm/disarm', el.btnDisarm));
el.btnRefresh.addEventListener('click', () => Promise.all([loadStatus(), loadAlerts()]));
renderState('DISARMED', null);
loadStatus();
loadAlerts();
connect();
})();

View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Console de supervision — Studio</title>
<meta name="description" content="Systeme de video-surveillance evenementiel : Java 21, Spring Boot, Kafka, architecture hexagonale et CQRS.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="scanlines" aria-hidden="true"></div>
<header class="topbar">
<div class="brand">
<span class="brand-dot" aria-hidden="true"></span>
<div>
<h1>Studio &mdash; Console de supervision</h1>
<p class="subtitle">Capteur <code>iphone-biblio</code> &middot; bibliotheque, etagere du haut</p>
</div>
</div>
<div class="link-status" id="link-status" data-state="connecting">
<span class="link-dot" aria-hidden="true"></span>
<span id="link-label">Connexion&hellip;</span>
</div>
</header>
<main>
<!-- ---------------------------------------------------------------- -->
<!-- Bandeau d'etat : c'est lui qui passe au rouge sur intrusion -->
<!-- ---------------------------------------------------------------- -->
<section class="status-panel" id="status-panel" data-state="DISARMED" aria-live="assertive">
<div class="status-main">
<p class="status-eyebrow">Etat du systeme</p>
<p class="status-value" id="status-value">DESARMEE</p>
<p class="status-detail" id="status-detail">Aucune surveillance active.</p>
</div>
<div class="controls">
<button type="button" id="btn-arm" class="btn btn-arm">
<span class="btn-icon" aria-hidden="true">&#9679;</span> Armer l'alarme
</button>
<button type="button" id="btn-disarm" class="btn btn-disarm">
<span class="btn-icon" aria-hidden="true">&#9632;</span> Desarmer
</button>
</div>
</section>
<!-- ---------------------------------------------------------------- -->
<!-- Metriques -->
<!-- ---------------------------------------------------------------- -->
<section class="metrics" aria-label="Indicateurs">
<div class="metric">
<p class="metric-label">Alertes non acquittees</p>
<p class="metric-value" id="metric-unacked">&mdash;</p>
</div>
<div class="metric">
<p class="metric-label">Derniere intrusion</p>
<p class="metric-value small" id="metric-last">&mdash;</p>
</div>
<div class="metric">
<p class="metric-label">Delai de sortie</p>
<p class="metric-value" id="metric-delay">&mdash;</p>
</div>
<div class="metric">
<p class="metric-label">Evenements recus</p>
<p class="metric-value" id="metric-events">0</p>
</div>
</section>
<!-- ---------------------------------------------------------------- -->
<!-- Journal temps reel -->
<!-- ---------------------------------------------------------------- -->
<section class="feed-section">
<div class="feed-header">
<h2>Journal des alertes</h2>
<button type="button" id="btn-refresh" class="btn-ghost">Rafraichir</button>
</div>
<ul class="feed" id="feed">
<li class="feed-empty" id="feed-empty">Aucune alerte enregistree.</li>
</ul>
</section>
</main>
<footer>
<p>
Java 21 &middot; Spring Boot 3 &middot; Apache Kafka (KRaft) &middot; PostgreSQL &middot;
Architecture hexagonale &amp; CQRS
</p>
<p>
<a href="/swagger-ui.html">API</a> &middot;
<a href="/actuator/health">Sante</a> &middot;
<a href="/actuator/prometheus">Metriques</a>
</p>
</footer>
<!-- Repli SockJS pour les reseaux qui filtrent l'upgrade WebSocket -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,423 @@
/* ---------------------------------------------------------------------------
Console de supervision. Parti pris : sombre, dense, lisible de loin.
L'information qui compte a 3 h du matin est l'etat du systeme - il occupe
donc la moitie superieure de l'ecran et change de couleur, pas de taille.
--------------------------------------------------------------------------- */
:root {
--bg: #0a0c10;
--bg-panel: #12151c;
--bg-panel-alt: #171b24;
--border: #242a36;
--text: #e6e9ef;
--text-dim: #8b94a7;
--text-faint: #5a6273;
--green: #34d399;
--green-dim: #0f3f33;
--amber: #fbbf24;
--amber-dim: #4a3708;
--red: #f43f5e;
--red-dim: #4a0f1d;
--blue: #60a5fa;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Grain de moniteur de surveillance. Discret : 3 % d'opacite. */
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background: repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.03) 0px,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 3px
);
}
/* ----------------------------- Barre du haut ----------------------------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 18px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 12px var(--green);
flex-shrink: 0;
}
.topbar h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0;
font-size: 12.5px;
color: var(--text-dim);
}
.subtitle code {
font-family: var(--mono);
font-size: 12px;
color: var(--blue);
background: rgba(96, 165, 250, 0.1);
padding: 1px 5px;
border-radius: 4px;
}
.link-status {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
}
.link-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text-faint);
}
.link-status[data-state="connected"] .link-dot {
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
.link-status[data-state="connected"] { color: var(--green); }
.link-status[data-state="connecting"] .link-dot {
background: var(--amber);
animation: pulse 1.2s ease-in-out infinite;
}
.link-status[data-state="connecting"] { color: var(--amber); }
.link-status[data-state="down"] .link-dot { background: var(--red); }
.link-status[data-state="down"] { color: var(--red); }
/* ------------------------------- Contenu -------------------------------- */
main {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* --------------------------- Bandeau d'etat ----------------------------- */
.status-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
flex-wrap: wrap;
padding: 32px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg-panel);
transition: background 240ms ease, border-color 240ms ease, box-shadow 240ms ease;
}
.status-eyebrow {
margin: 0 0 6px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
}
.status-value {
margin: 0;
font-size: clamp(34px, 6vw, 52px);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
font-family: var(--mono);
}
.status-detail {
margin: 10px 0 0;
font-size: 14px;
color: var(--text-dim);
max-width: 52ch;
}
/* Desarmee : neutre. Rien ne se passe, rien n'attire l'oeil. */
.status-panel[data-state="DISARMED"] .status-value { color: var(--text-dim); }
/* Armee : vert. Le systeme veille. */
.status-panel[data-state="ARMED"] {
border-color: var(--green-dim);
background: linear-gradient(180deg, rgba(52, 211, 153, 0.05), var(--bg-panel));
}
.status-panel[data-state="ARMED"] .status-value { color: var(--green); }
/* Intrusion : rouge, et le panneau entier pulse. */
.status-panel[data-state="TRIGGERED"] {
border-color: var(--red);
background: linear-gradient(180deg, rgba(244, 63, 94, 0.14), var(--bg-panel));
animation: alarm 1.1s ease-in-out infinite;
}
.status-panel[data-state="TRIGGERED"] .status-value {
color: var(--red);
text-shadow: 0 0 24px rgba(244, 63, 94, 0.5);
}
.status-panel[data-state="TRIGGERED"] .status-detail { color: #fca5b5; }
@keyframes alarm {
0%, 100% { box-shadow: 0 0 0 0 rgba(244, 63, 94, 0); }
50% { box-shadow: 0 0 36px 2px rgba(244, 63, 94, 0.3); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* -------------------------------- Boutons ------------------------------- */
.controls { display: flex; gap: 12px; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 13px 22px;
border-radius: 10px;
border: 1px solid transparent;
font-family: var(--sans);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 120ms ease, background 160ms ease, opacity 160ms ease;
white-space: nowrap;
}
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.btn-icon { font-size: 10px; }
.btn-arm {
background: var(--green);
color: #05231a;
}
.btn-arm:hover:not(:disabled) { background: #4ade9f; }
.btn-disarm {
background: transparent;
border-color: var(--border);
color: var(--text);
}
.btn-disarm:hover:not(:disabled) {
background: var(--bg-panel-alt);
border-color: var(--text-faint);
}
.btn-ghost {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 6px 14px;
border-radius: 8px;
font-size: 12.5px;
cursor: pointer;
font-family: var(--sans);
}
.btn-ghost:hover { color: var(--text); border-color: var(--text-faint); }
/* ------------------------------ Metriques ------------------------------- */
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
}
.metric {
padding: 16px 18px;
border: 1px solid var(--border);
border-radius: 11px;
background: var(--bg-panel);
}
.metric-label {
margin: 0 0 8px;
font-size: 11px;
font-family: var(--mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.metric-value {
margin: 0;
font-size: 26px;
font-weight: 600;
font-family: var(--mono);
letter-spacing: -0.02em;
}
.metric-value.small { font-size: 15px; font-weight: 500; color: var(--text-dim); }
/* -------------------------------- Journal ------------------------------- */
.feed-section {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel);
overflow: hidden;
}
.feed-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.feed-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.feed {
list-style: none;
margin: 0;
padding: 0;
max-height: 420px;
overflow-y: auto;
}
.feed-item {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
animation: slide-in 320ms ease;
}
.feed-item:last-child { border-bottom: none; }
.feed-item.acked { opacity: 0.45; }
@keyframes slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.sev {
flex-shrink: 0;
margin-top: 3px;
width: 8px; height: 8px;
border-radius: 50%;
}
.sev-LOW { background: var(--text-faint); }
.sev-MEDIUM { background: var(--amber); }
.sev-HIGH { background: var(--red); }
.sev-CRITICAL { background: var(--red); box-shadow: 0 0 10px var(--red); }
.feed-body { flex: 1; min-width: 0; }
.feed-msg {
margin: 0;
font-size: 14px;
font-weight: 500;
}
.feed-meta {
margin: 4px 0 0;
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-faint);
}
.feed-ack {
flex-shrink: 0;
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 4px 11px;
border-radius: 7px;
font-size: 11.5px;
cursor: pointer;
font-family: var(--sans);
}
.feed-ack:hover { color: var(--text); border-color: var(--text-faint); }
.feed-empty {
padding: 40px 20px;
text-align: center;
color: var(--text-faint);
font-size: 13.5px;
}
/* ---------------------------------- Pied -------------------------------- */
footer {
max-width: 1100px;
margin: 0 auto;
padding: 28px 24px 44px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
line-height: 1.9;
}
footer a { color: var(--text-dim); text-decoration: none; }
footer a:hover { color: var(--blue); }
/* ------------------------------- Mobile --------------------------------- */
@media (max-width: 640px) {
main { padding: 16px; }
.status-panel { padding: 24px 20px; }
.controls { width: 100%; }
.btn { flex: 1; justify-content: center; }
}

View file

@ -0,0 +1,148 @@
package me.belkacemi.iotalarm.architecture;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/**
* Les regles de l'architecture hexagonale, verifiees par le compilateur de tests.
*
* <p>Un diagramme dans un README derive en trois sprints. Ces tests echouent le
* jour ou quelqu'un - moi compris - importe {@code @Autowired} dans un agregat.
* C'est la difference entre une architecture documentee et une architecture
* tenue.</p>
*/
@DisplayName("Architecture hexagonale")
class HexagonalArchitectureTest {
private static final String ROOT = "me.belkacemi.iotalarm";
private static JavaClasses classes;
@BeforeAll
static void importClasses() {
classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages(ROOT);
}
// ------------------------------------------------------------------
// La regle cardinale : le domaine ignore tout du monde exterieur
// ------------------------------------------------------------------
@Test
@DisplayName("Le domaine ne depend d'aucun framework")
void domainHasNoFrameworkDependency() {
ArchRule rule = noClasses()
.that().resideInAPackage(ROOT + ".domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"jakarta.validation..",
"org.apache.kafka..",
"com.fasterxml.jackson..",
"io.micrometer..")
.because("le coeur metier doit rester testable sans conteneur, "
+ "et survivre a un changement de framework");
rule.check(classes);
}
@Test
@DisplayName("Le domaine ne depend d'aucune couche externe")
void domainDependsOnNothingElse() {
ArchRule rule = noClasses()
.that().resideInAPackage(ROOT + ".domain..")
.should().dependOnClassesThat().resideInAnyPackage(
ROOT + ".application..",
ROOT + ".infrastructure..",
ROOT + ".presentation..")
.because("les dependances pointent vers l'interieur, jamais l'inverse");
rule.check(classes);
}
// ------------------------------------------------------------------
// Les couches externes
// ------------------------------------------------------------------
@Test
@DisplayName("L'application ignore l'infrastructure et la presentation")
void applicationDependsOnDomainOnly() {
ArchRule rule = noClasses()
.that().resideInAPackage(ROOT + ".application..")
.should().dependOnClassesThat().resideInAnyPackage(
ROOT + ".infrastructure..",
ROOT + ".presentation..")
.because("les services applicatifs orchestrent des ports, "
+ "pas des implementations");
rule.check(classes);
}
@Test
@DisplayName("La presentation ignore l'infrastructure")
void presentationDoesNotTouchInfrastructure() {
ArchRule rule = noClasses()
.that().resideInAPackage(ROOT + ".presentation..")
.should().dependOnClassesThat().resideInAPackage(ROOT + ".infrastructure..")
.because("un controleur parle a des use cases, pas a des adapters");
rule.check(classes);
}
@Test
@DisplayName("Les couches respectent le sens des dependances")
void layersAreRespected() {
ArchRule rule = layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Domain").definedBy(ROOT + ".domain..")
.layer("Application").definedBy(ROOT + ".application..")
.layer("Infrastructure").definedBy(ROOT + ".infrastructure..")
.layer("Presentation").definedBy(ROOT + ".presentation..")
.whereLayer("Presentation").mayNotBeAccessedByAnyLayer()
.whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers(
"Infrastructure", "Presentation")
.whereLayer("Domain").mayOnlyBeAccessedByLayers(
"Application", "Infrastructure", "Presentation");
rule.check(classes);
}
// ------------------------------------------------------------------
// Conventions
// ------------------------------------------------------------------
@Test
@DisplayName("Les ports sont des interfaces")
void portsAreInterfaces() {
ArchRule rule = classes()
.that().resideInAPackage(ROOT + ".domain.port.in")
.or().resideInAPackage(ROOT + ".domain.port.out")
.should().beInterfaces()
.because("un port est un contrat, pas une implementation");
rule.check(classes);
}
@Test
@DisplayName("Aucun controleur n'accede directement a un repository")
void controllersDoNotReachRepositories() {
ArchRule rule = noClasses()
.that().resideInAPackage(ROOT + ".presentation..")
.should().dependOnClassesThat().haveSimpleNameEndingWith("JpaRepository")
.because("court-circuiter la couche application vide "
+ "l'architecture de son sens");
rule.check(classes);
}
}

View file

@ -0,0 +1,226 @@
package me.belkacemi.iotalarm.domain.model;
import me.belkacemi.iotalarm.domain.exception.DomainException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests de l'agregat. Pas de Spring, pas de Kafka, pas de base, pas de
* {@code Thread.sleep} - le temps est un parametre.
*
* <p>C'est le benefice concret de l'architecture hexagonale : la totalite de la
* logique de detection se verifie en quelques millisecondes.</p>
*/
@DisplayName("AlarmSystem - regles de detection")
class AlarmSystemTest {
private static final Instant T0 = Instant.parse("2026-09-12T22:00:00Z");
private static final DeviceId IPHONE = DeviceId.of("iphone-biblio");
private static final AlarmPolicy POLICY = new AlarmPolicy(
Duration.ofSeconds(30), Duration.ofSeconds(15), 60);
private static AlarmSystem disarmed() {
return AlarmSystem.initial(AlarmSystem.DEFAULT_ZONE, POLICY, T0);
}
/** Alarme armee, delai de sortie deja ecoule : l'etat "en veille" nominal. */
private static AlarmSystem armedAndReady() {
AlarmSystem alarm = disarmed();
alarm.arm(T0);
return alarm;
}
private static SensorEvent motion(Instant at, int confidence) {
return new SensorEvent(IPHONE, SensorEventType.MOTION_DETECTED, at, confidence);
}
private static Instant afterExitDelay() {
return T0.plus(POLICY.exitDelay()).plusSeconds(1);
}
// ------------------------------------------------------------------
@Nested
@DisplayName("Cycle de vie")
class Lifecycle {
@Test
@DisplayName("une alarme neuve est desarmee")
void newAlarmIsDisarmed() {
assertThat(disarmed().state()).isEqualTo(AlarmState.DISARMED);
}
@Test
@DisplayName("armer produit un evenement portant le delai de sortie")
void armingEmitsEvent() {
AlarmSystem alarm = disarmed();
var event = alarm.arm(T0);
assertThat(alarm.state()).isEqualTo(AlarmState.ARMED);
assertThat(event.exitDelay()).isEqualTo(Duration.ofSeconds(30));
assertThat(event.occurredAt()).isEqualTo(T0);
}
@Test
@DisplayName("armer deux fois est refuse")
void cannotArmTwice() {
AlarmSystem alarm = armedAndReady();
assertThatThrownBy(() -> alarm.arm(T0.plusSeconds(5)))
.isInstanceOf(DomainException.class)
.hasMessageContaining("deja armee");
}
@Test
@DisplayName("desarmer une alarme desarmee est refuse")
void cannotDisarmTwice() {
assertThatThrownBy(() -> disarmed().disarm(T0))
.isInstanceOf(DomainException.class)
.hasMessageContaining("deja desarmee");
}
@Test
@DisplayName("desarmer depuis TRIGGERED conserve l'etat precedent dans l'evenement")
void disarmFromTriggeredKeepsHistory() {
AlarmSystem alarm = armedAndReady();
alarm.evaluate(motion(afterExitDelay(), 90), afterExitDelay());
var event = alarm.disarm(afterExitDelay().plusSeconds(10));
assertThat(event.previousState()).isEqualTo(AlarmState.TRIGGERED);
assertThat(alarm.state()).isEqualTo(AlarmState.DISARMED);
}
}
// ------------------------------------------------------------------
@Nested
@DisplayName("Detection d'intrusion")
class Detection {
@Test
@DisplayName("un mouvement confirme sur alarme armee declenche une alerte")
void motionOnArmedAlarmTriggers() {
AlarmSystem alarm = armedAndReady();
Instant now = afterExitDelay();
AlarmDecision decision = alarm.evaluate(motion(now, 90), now);
assertThat(decision).isInstanceOf(AlarmDecision.Triggered.class);
AlarmDecision.Triggered triggered = (AlarmDecision.Triggered) decision;
assertThat(triggered.alert().severity()).isEqualTo(Severity.HIGH);
assertThat(triggered.alert().deviceId()).isEqualTo(IPHONE);
assertThat(triggered.alert().message()).contains("Iphone Biblio");
assertThat(alarm.state()).isEqualTo(AlarmState.TRIGGERED);
}
@Test
@DisplayName("un capteur hors ligne sur alarme armee est une alerte CRITICAL")
void tamperingIsCritical() {
AlarmSystem alarm = armedAndReady();
Instant now = afterExitDelay();
AlarmDecision decision = alarm.evaluate(
SensorEvent.of(IPHONE, SensorEventType.CAMERA_OFFLINE, now), now);
assertThat(decision).isInstanceOf(AlarmDecision.Triggered.class);
assertThat(((AlarmDecision.Triggered) decision).alert().severity())
.isEqualTo(Severity.CRITICAL);
}
}
// ------------------------------------------------------------------
@Nested
@DisplayName("Motifs de rejet")
class Rejection {
@Test
@DisplayName("alarme desarmee : l'evenement est ignore")
void disarmedIgnoresEverything() {
AlarmSystem alarm = disarmed();
AlarmDecision decision = alarm.evaluate(motion(T0, 100), T0);
assertThat(decision).isInstanceOf(AlarmDecision.Ignored.class);
assertThat(((AlarmDecision.Ignored) decision).reason())
.isEqualTo(IgnoreReason.ALARM_DISARMED);
assertThat(alarm.state()).isEqualTo(AlarmState.DISARMED);
}
@Test
@DisplayName("un heartbeat ne declenche jamais rien")
void heartbeatNeverTriggers() {
AlarmSystem alarm = armedAndReady();
Instant now = afterExitDelay();
AlarmDecision decision = alarm.evaluate(
SensorEvent.of(IPHONE, SensorEventType.HEARTBEAT, now), now);
assertThat(((AlarmDecision.Ignored) decision).reason())
.isEqualTo(IgnoreReason.NOT_INTRUSION_RELEVANT);
}
@Test
@DisplayName("pendant le delai de sortie, je ne declenche pas ma propre alarme")
void exitDelayProtectsTheOwner() {
AlarmSystem alarm = armedAndReady();
Instant justAfterArming = T0.plusSeconds(5);
AlarmDecision decision = alarm.evaluate(motion(justAfterArming, 100), justAfterArming);
assertThat(((AlarmDecision.Ignored) decision).reason())
.isEqualTo(IgnoreReason.WITHIN_EXIT_DELAY);
assertThat(alarm.state()).isEqualTo(AlarmState.ARMED);
}
@Test
@DisplayName("une detection peu fiable est ecartee")
void lowConfidenceIsRejected() {
AlarmSystem alarm = armedAndReady();
Instant now = afterExitDelay();
AlarmDecision decision = alarm.evaluate(motion(now, 42), now);
assertThat(((AlarmDecision.Ignored) decision).reason())
.isEqualTo(IgnoreReason.CONFIDENCE_TOO_LOW);
}
@Test
@DisplayName("anti-rafale : la seconde detection immediate est ecartee")
void cooldownPreventsAlertStorm() {
AlarmSystem alarm = armedAndReady();
Instant first = afterExitDelay();
alarm.evaluate(motion(first, 95), first);
Instant twoSecondsLater = first.plusSeconds(2);
AlarmDecision decision = alarm.evaluate(motion(twoSecondsLater, 95), twoSecondsLater);
assertThat(((AlarmDecision.Ignored) decision).reason())
.isEqualTo(IgnoreReason.WITHIN_COOLDOWN);
}
@Test
@DisplayName("passe le cooldown, une nouvelle alerte est levee")
void newAlertAfterCooldown() {
AlarmSystem alarm = armedAndReady();
Instant first = afterExitDelay();
alarm.evaluate(motion(first, 95), first);
Instant later = first.plus(POLICY.alertCooldown()).plusSeconds(1);
AlarmDecision decision = alarm.evaluate(motion(later, 95), later);
assertThat(decision).isInstanceOf(AlarmDecision.Triggered.class);
}
}
}

View file

@ -0,0 +1,40 @@
package me.belkacemi.iotalarm.domain.model;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("DeviceId - validation a la frontiere")
class DeviceIdTest {
@Test
@DisplayName("normalise la casse et les espaces")
void normalises() {
assertThat(DeviceId.of(" IPHONE-Biblio ").value()).isEqualTo("iphone-biblio");
}
@Test
@DisplayName("produit un libelle lisible pour la console")
void buildsDisplayName() {
assertThat(DeviceId.of("iphone-biblio").displayName()).isEqualTo("Iphone Biblio");
}
@ParameterizedTest(name = "\"{0}\" est refuse")
@ValueSource(strings = {"", " ", "a", "ab", "-iphone", "iphone-", "iphone_biblio", "iPhone/../etc"})
@DisplayName("refuse les identifiants malformes")
void rejectsInvalid(String candidate) {
assertThatThrownBy(() -> DeviceId.of(candidate))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("refuse null")
void rejectsNull() {
assertThatThrownBy(() -> DeviceId.of(null))
.isInstanceOf(IllegalArgumentException.class);
}
}