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:
commit
63ead8a966
81 changed files with 5051 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
|
docs/
|
||||||
|
deploy/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
16
.env.example
Normal file
16
.env.example
Normal 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
26
.gitignore
vendored
Normal 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
52
Dockerfile
Normal 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
319
README.md
Normal 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
39
deploy/Caddyfile.snippet
Normal 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
54
deploy/nginx/nginx.conf
Normal 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
138
docker-compose.yml
Normal 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
176
pom.xml
Normal 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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package me.belkacemi.iotalarm.domain.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat de l'agregat {@link AlarmSystem}.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* DISARMED --arm()--> ARMED --intrusion--> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/main/java/me/belkacemi/iotalarm/domain/model/Alert.java
Normal file
121
src/main/java/me/belkacemi/iotalarm/domain/model/Alert.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/resources/application-local.yml
Normal file
20
src/main/resources/application-local.yml
Normal 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
|
||||||
18
src/main/resources/application-prod.yml
Normal file
18
src/main/resources/application-prod.yml
Normal 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
|
||||||
95
src/main/resources/application.yml
Normal file
95
src/main/resources/application.yml
Normal 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
|
||||||
48
src/main/resources/db/migration/V1__init_alarm_schema.sql
Normal file
48
src/main/resources/db/migration/V1__init_alarm_schema.sql
Normal 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);
|
||||||
270
src/main/resources/static/app.js
Normal file
270
src/main/resources/static/app.js
Normal 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();
|
||||||
|
})();
|
||||||
103
src/main/resources/static/index.html
Normal file
103
src/main/resources/static/index.html
Normal 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 — Console de supervision</h1>
|
||||||
|
<p class="subtitle">Capteur <code>iphone-biblio</code> · 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…</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">●</span> Armer l'alarme
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-disarm" class="btn btn-disarm">
|
||||||
|
<span class="btn-icon" aria-hidden="true">■</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">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<p class="metric-label">Derniere intrusion</p>
|
||||||
|
<p class="metric-value small" id="metric-last">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<p class="metric-label">Delai de sortie</p>
|
||||||
|
<p class="metric-value" id="metric-delay">—</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 · Spring Boot 3 · Apache Kafka (KRaft) · PostgreSQL ·
|
||||||
|
Architecture hexagonale & CQRS
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="/swagger-ui.html">API</a> ·
|
||||||
|
<a href="/actuator/health">Sante</a> ·
|
||||||
|
<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>
|
||||||
423
src/main/resources/static/style.css
Normal file
423
src/main/resources/static/style.css
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue