commit 63ead8a966f84d6ac7166480531189d10fbaff4d Author: Adam Belkacemi Date: Sat Sep 12 17:32:45 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca42707 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +target/ +.git/ +.idea/ +*.iml +.DS_Store +README.md +docs/ +deploy/ +.env +.env.* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e495b3 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c16497 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1f0664 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7df7a13 --- /dev/null +++ b/README.md @@ -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.* diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet new file mode 100644 index 0000000..75d5e1c --- /dev/null +++ b/deploy/Caddyfile.snippet @@ -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 + } +} diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..8672363 --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0190a3b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7ead98f --- /dev/null +++ b/pom.xml @@ -0,0 +1,176 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.2 + + + + me.belkacemi + iot-alarm-system + 1.0.0 + iot-alarm-system + Systeme de video-surveillance evenementiel - Hexagonal / CQRS / Kafka + + + 21 + 1.3.0 + 2.8.3 + + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-validation + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + + + org.springframework.kafka + spring-kafka + + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + runtime + + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + runtime + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.kafka + spring-kafka-test + test + + + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + kafka + test + + + org.testcontainers + postgresql + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + integration + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + integration + + + + + integration-test + verify + + + + + + + + diff --git a/src/main/java/me/belkacemi/iotalarm/IotAlarmSystemApplication.java b/src/main/java/me/belkacemi/iotalarm/IotAlarmSystemApplication.java new file mode 100644 index 0000000..9e09c34 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/IotAlarmSystemApplication.java @@ -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. + * + *

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.

+ * + * @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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/application/command/handler/AfterCommit.java b/src/main/java/me/belkacemi/iotalarm/application/command/handler/AfterCommit.java new file mode 100644 index 0000000..a8066c6 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/application/command/handler/AfterCommit.java @@ -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. + * + *

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.

+ * + *

Hors transaction (tests unitaires), l'action s'execute immediatement.

+ */ +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(); + } + }); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/application/command/handler/AlarmCommandService.java b/src/main/java/me/belkacemi/iotalarm/application/command/handler/AlarmCommandService.java new file mode 100644 index 0000000..ecb84bd --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/application/command/handler/AlarmCommandService.java @@ -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. + * + *

Ce service orchestre, 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.

+ */ +@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())); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/application/command/handler/SensorEventCommandService.java b/src/main/java/me/belkacemi/iotalarm/application/command/handler/SensorEventCommandService.java new file mode 100644 index 0000000..fa4dbe9 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/application/command/handler/SensorEventCommandService.java @@ -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. + * + *

Les deux moities du flux sont ici, et elles ne partagent pas de + * transaction :

+ *
    + *
  1. {@link #ingest} depose l'observation sur le bus et rend la main. + * L'iPhone recoit son 202 en quelques millisecondes.
  2. + *
  3. {@link #process} est appele plus tard par le consumer Kafka et applique + * les regles metier.
  4. + *
+ */ +@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. + * + *

{@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.

+ */ + @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void ingest(IngestSensorEventCommand command) { + publisher.publish(command.event()); + } + + /** + * Traitement : c'est ici que le domaine tranche. + * + *

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}.

+ */ + @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)); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/application/query/handler/AlarmQueryService.java b/src/main/java/me/belkacemi/iotalarm/application/query/handler/AlarmQueryService.java new file mode 100644 index 0000000..9a4e6fc --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/application/query/handler/AlarmQueryService.java @@ -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. + * + *

Transactions en lecture seule : l'intention est declaree au framework, + * qui peut router vers un replica si l'architecture l'exige un jour.

+ */ +@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 status(String zoneId) { + return readModel.alarmStatus(zoneId); + } + + public List recentAlerts(String zoneId, Integer limit) { + int effective = limit == null ? DEFAULT_LIMIT : Math.clamp(limit, 1, MAX_LIMIT); + return readModel.recentAlerts(zoneId, effective); + } + + public Optional alert(UUID alertId) { + return readModel.findAlert(alertId); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/application/query/port/AlarmReadModelPort.java b/src/main/java/me/belkacemi/iotalarm/application/query/port/AlarmReadModelPort.java new file mode 100644 index 0000000..ba62a5a --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/application/query/port/AlarmReadModelPort.java @@ -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. + * + *

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.

+ */ +public interface AlarmReadModelPort { + + Optional alarmStatus(String zoneId); + + List recentAlerts(String zoneId, int limit); + + Optional findAlert(java.util.UUID alertId); +} diff --git a/src/main/java/me/belkacemi/iotalarm/application/query/view/AlarmStatusView.java b/src/main/java/me/belkacemi/iotalarm/application/query/view/AlarmStatusView.java new file mode 100644 index 0000000..1c92c4c --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/application/query/view/AlarmStatusView.java @@ -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) { +} diff --git a/src/main/java/me/belkacemi/iotalarm/application/query/view/AlertView.java b/src/main/java/me/belkacemi/iotalarm/application/query/view/AlertView.java new file mode 100644 index 0000000..628e39d --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/application/query/view/AlertView.java @@ -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. + * + *

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.

+ */ +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) { +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/event/AlarmArmed.java b/src/main/java/me/belkacemi/iotalarm/domain/event/AlarmArmed.java new file mode 100644 index 0000000..8c84443 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/event/AlarmArmed.java @@ -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"); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/event/AlarmDisarmed.java b/src/main/java/me/belkacemi/iotalarm/domain/event/AlarmDisarmed.java new file mode 100644 index 0000000..3f11d10 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/event/AlarmDisarmed.java @@ -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. + * + *

{@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.

+ */ +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"); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/event/DomainEvent.java b/src/main/java/me/belkacemi/iotalarm/domain/event/DomainEvent.java new file mode 100644 index 0000000..c7e0a00 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/event/DomainEvent.java @@ -0,0 +1,20 @@ +package me.belkacemi.iotalarm.domain.event; + +import java.time.Instant; + +/** + * Fait metier avere, au passe, immuable. + * + *

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.

+ */ +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(); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/event/IntrusionDetected.java b/src/main/java/me/belkacemi/iotalarm/domain/event/IntrusionDetected.java new file mode 100644 index 0000000..208dd82 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/event/IntrusionDetected.java @@ -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. + * + *

C'est cet evenement qui, une fois l'alerte persistee, est pousse sur le + * WebSocket et fait passer la console au rouge.

+ */ +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"); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/exception/DomainException.java b/src/main/java/me/belkacemi/iotalarm/domain/exception/DomainException.java new file mode 100644 index 0000000..9325ed2 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/exception/DomainException.java @@ -0,0 +1,14 @@ +package me.belkacemi.iotalarm.domain.exception; + +/** + * Violation d'une regle metier. + * + *

Volontairement non typee Spring : le domaine ne connait pas HTTP. C'est un + * {@code @RestControllerAdvice} de la couche presentation qui la traduit en 409.

+ */ +public class DomainException extends RuntimeException { + + public DomainException(String message) { + super(message); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmDecision.java b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmDecision.java new file mode 100644 index 0000000..c52aaa3 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmDecision.java @@ -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. + * + *

Interface scellee : 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}.

+ */ +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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmPolicy.java b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmPolicy.java new file mode 100644 index 0000000..3b7ebdb --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmPolicy.java @@ -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. + * + *

Ces trois parametres sont ceux qui font la difference entre un systeme + * utilisable et un systeme qu'on finit par desarmer en permanence :

+ *
    + *
  • {@code exitDelay} : le temps de sortir sans se declencher soi-meme ;
  • + *
  • {@code alertCooldown} : sans ca, un intrus qui marche devant l'objectif + * genere 40 alertes en 10 secondes ;
  • + *
  • {@code minimumConfidence} : un rideau qui bouge n'est pas un cambrioleur.
  • + *
+ * + * @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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmState.java b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmState.java new file mode 100644 index 0000000..9faa62d --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmState.java @@ -0,0 +1,27 @@ +package me.belkacemi.iotalarm.domain.model; + +/** + * Etat de l'agregat {@link AlarmSystem}. + * + *
+ *   DISARMED --arm()--> ARMED --intrusion--> TRIGGERED
+ *       ^                  |                      |
+ *       +----disarm()------+------disarm()--------+
+ * 
+ */ +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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmSystem.java b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmSystem.java new file mode 100644 index 0000000..4a51fde --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/AlarmSystem.java @@ -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. + * + *

Toute la logique de detection est ici, et uniquement 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.

+ * + *

Consequence directe : {@link #evaluate} se teste en JUnit pur, sans + * Spring, sans Kafka et sans base - voir {@code AlarmSystemTest}.

+ * + *

Aucun import {@code org.springframework} ni {@code jakarta.persistence} + * dans ce package : c'est une regle verifiee par ArchUnit, pas une intention.

+ */ +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 ? + * + *

Les cinq filtres sont ordonnes du moins au plus couteux a evaluer, et + * chacun renvoie un motif explicite plutot qu'un {@code false} muet.

+ * + * @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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/Alert.java b/src/main/java/me/belkacemi/iotalarm/domain/model/Alert.java new file mode 100644 index 0000000..0bae9a5 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/Alert.java @@ -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. + * + *

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.

+ */ +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(); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/AlertId.java b/src/main/java/me/belkacemi/iotalarm/domain/model/AlertId.java new file mode 100644 index 0000000..bb5e489 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/AlertId.java @@ -0,0 +1,36 @@ +package me.belkacemi.iotalarm.domain.model; + +import java.util.Objects; +import java.util.UUID; + +/** + * Identite d'une {@link Alert}. + * + *

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.

+ */ +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(); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/DeviceId.java b/src/main/java/me/belkacemi/iotalarm/domain/model/DeviceId.java new file mode 100644 index 0000000..a38c769 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/DeviceId.java @@ -0,0 +1,53 @@ +package me.belkacemi.iotalarm.domain.model; + +import java.util.regex.Pattern; + +/** + * Value Object identifiant un capteur physique. + * + *

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.

+ * + *

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.

+ */ +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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/IgnoreReason.java b/src/main/java/me/belkacemi/iotalarm/domain/model/IgnoreReason.java new file mode 100644 index 0000000..3dd8c32 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/IgnoreReason.java @@ -0,0 +1,27 @@ +package me.belkacemi.iotalarm.domain.model; + +/** + * Pourquoi un evenement capteur n'a pas declenche d'alerte. + * + *

Le domaine ne se contente pas de renvoyer {@code false} : il dit + * pourquoi. 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.

+ */ +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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/SensorEvent.java b/src/main/java/me/belkacemi/iotalarm/domain/model/SensorEvent.java new file mode 100644 index 0000000..c5419c0 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/SensorEvent.java @@ -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. + * + *

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.

+ * + * @param deviceId capteur emetteur + * @param type nature de l'observation + * @param occurredAt horodatage cote capteur + * @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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/SensorEventType.java b/src/main/java/me/belkacemi/iotalarm/domain/model/SensorEventType.java new file mode 100644 index 0000000..d447ac4 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/SensorEventType.java @@ -0,0 +1,53 @@ +package me.belkacemi.iotalarm.domain.model; + +/** + * Nature de ce que le capteur a observe. + * + *

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.

+ */ +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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/model/Severity.java b/src/main/java/me/belkacemi/iotalarm/domain/model/Severity.java new file mode 100644 index 0000000..4181512 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/model/Severity.java @@ -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(); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/AcknowledgeAlertUseCase.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/AcknowledgeAlertUseCase.java new file mode 100644 index 0000000..f2b9da1 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/AcknowledgeAlertUseCase.java @@ -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); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/ArmAlarmUseCase.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/ArmAlarmUseCase.java new file mode 100644 index 0000000..4b4d04a --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/ArmAlarmUseCase.java @@ -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); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/DisarmAlarmUseCase.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/DisarmAlarmUseCase.java new file mode 100644 index 0000000..b3869dc --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/DisarmAlarmUseCase.java @@ -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); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/IngestSensorEventUseCase.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/IngestSensorEventUseCase.java new file mode 100644 index 0000000..b6bb2fc --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/IngestSensorEventUseCase.java @@ -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. + * + *

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.

+ */ +public interface IngestSensorEventUseCase { + + void ingest(IngestSensorEventCommand command); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/ProcessSensorEventUseCase.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/ProcessSensorEventUseCase.java new file mode 100644 index 0000000..76719e3 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/ProcessSensorEventUseCase.java @@ -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. + * + *

Invoque par l'adapter consumer Kafka, jamais par HTTP. Retourne la + * decision de l'agregat pour que l'adapter puisse tracer et metriquer.

+ */ +public interface ProcessSensorEventUseCase { + + AlarmDecision process(SensorEvent event); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/AcknowledgeAlertCommand.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/AcknowledgeAlertCommand.java new file mode 100644 index 0000000..6d7fb7e --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/AcknowledgeAlertCommand.java @@ -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"); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/ArmAlarmCommand.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/ArmAlarmCommand.java new file mode 100644 index 0000000..5b4f207 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/ArmAlarmCommand.java @@ -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"); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/DisarmAlarmCommand.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/DisarmAlarmCommand.java new file mode 100644 index 0000000..ec5cc38 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/DisarmAlarmCommand.java @@ -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"); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/IngestSensorEventCommand.java b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/IngestSensorEventCommand.java new file mode 100644 index 0000000..d9acf87 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/in/command/IngestSensorEventCommand.java @@ -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". + * + *

Emise par l'adapter REST d'ingestion ou par le simulateur. Elle ne + * declenche aucune regle metier : elle depose l'observation sur Kafka.

+ */ +public record IngestSensorEventCommand(SensorEvent event) { + + public IngestSensorEventCommand { + Objects.requireNonNull(event, "event est obligatoire"); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlarmStateRepository.java b/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlarmStateRepository.java new file mode 100644 index 0000000..ad90285 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlarmStateRepository.java @@ -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. + * + *

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.

+ */ +public interface AlarmStateRepository { + + Optional load(String zoneId); + + void save(AlarmSystem alarmSystem); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlertNotifier.java b/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlertNotifier.java new file mode 100644 index 0000000..d78fe02 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlertNotifier.java @@ -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. + * + *

Implemente par un adapter WebSocket/STOMP. Le domaine ignore qu'il existe + * un navigateur a l'autre bout.

+ */ +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); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlertRepository.java b/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlertRepository.java new file mode 100644 index 0000000..54f7f6c --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/out/AlertRepository.java @@ -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). + * + *

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.

+ */ +public interface AlertRepository { + + Alert save(Alert alert); + + Optional findById(AlertId id); +} diff --git a/src/main/java/me/belkacemi/iotalarm/domain/port/out/SensorEventPublisher.java b/src/main/java/me/belkacemi/iotalarm/domain/port/out/SensorEventPublisher.java new file mode 100644 index 0000000..6a9a52e --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/domain/port/out/SensorEventPublisher.java @@ -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. + * + *

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.

+ */ +public interface SensorEventPublisher { + + void publish(SensorEvent event); +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/config/AlarmProperties.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/AlarmProperties.java new file mode 100644 index 0000000..89ada6e --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/AlarmProperties.java @@ -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). + * + *

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.

+ */ +@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) { + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/config/DomainConfig.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/DomainConfig.java new file mode 100644 index 0000000..e21a225 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/DomainConfig.java @@ -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. + * + *

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.

+ */ +@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. + * + *

{@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.

+ */ + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/config/KafkaConfig.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/KafkaConfig.java new file mode 100644 index 0000000..3f0783f --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/KafkaConfig.java @@ -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. + * + *

Les serialiseurs sont construits a la main plutot que declares en + * properties, pour deux raisons precises :

+ *
    + *
  • reutiliser l'{@link ObjectMapper} de Spring, seul a savoir serialiser + * un {@code Instant} en ISO-8601 plutot qu'en tableau de nombres ;
  • + *
  • envelopper le deserialiseur dans un {@link ErrorHandlingDeserializer} : + * sans lui, un seul message malforme sur le topic met le + * consumer dans une boucle de replay infinie.
  • + *
+ */ +@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 producerFactory() { + Map 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 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 kafkaTemplate( + ProducerFactory producerFactory) { + return new KafkaTemplate<>(producerFactory); + } + + @Bean + public ConsumerFactory consumerFactory() { + Map 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 delegate = + new JsonDeserializer<>(SensorEventMessage.class, objectMapper, false); + delegate.addTrustedPackages("me.belkacemi.iotalarm.*"); + + return new DefaultKafkaConsumerFactory<>( + props, + new StringDeserializer(), + new ErrorHandlingDeserializer<>(delegate)); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + kafkaListenerContainerFactory( + ConsumerFactory consumerFactory, + KafkaTemplate kafkaTemplate) { + + var factory = new ConcurrentKafkaListenerContainerFactory(); + 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. + * + *

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.

+ */ + private DefaultErrorHandler errorHandler(KafkaTemplate 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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/config/WebSocketConfig.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/WebSocketConfig.java new file mode 100644 index 0000000..f2bf489 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/config/WebSocketConfig.java @@ -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. + * + *

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.

+ */ +@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(); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/SensorEventKafkaConsumer.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/SensorEventKafkaConsumer.java new file mode 100644 index 0000000..948dfe3 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/SensorEventKafkaConsumer.java @@ -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}. + * + *

Cette classe ne contient aucune regle metier. Elle + * deserialise, delegue a {@link ProcessSensorEventUseCase}, et instrumente. + * Tout ce qui ressemble a une decision se trouve dans l'agregat.

+ */ +@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. + * + *

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.

+ */ + 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(); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/SensorEventKafkaPublisher.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/SensorEventKafkaPublisher.java new file mode 100644 index 0000000..fc11347 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/SensorEventKafkaPublisher.java @@ -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}. + * + *

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.

+ */ +@Component +public class SensorEventKafkaPublisher implements SensorEventPublisher { + + private static final Logger log = LoggerFactory.getLogger(SensorEventKafkaPublisher.class); + + private final KafkaTemplate kafkaTemplate; + private final String topic; + + public SensorEventKafkaPublisher(KafkaTemplate 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()); + }); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/message/SensorEventMessage.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/message/SensorEventMessage.java new file mode 100644 index 0000000..4eeeda6 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/kafka/message/SensorEventMessage.java @@ -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}. + * + *

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.

+ * + *

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()}.

+ */ +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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/notification/AlarmStateNotification.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/notification/AlarmStateNotification.java new file mode 100644 index 0000000..e38c7bf --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/notification/AlarmStateNotification.java @@ -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) { +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/notification/WebSocketAlertNotifier.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/notification/WebSocketAlertNotifier.java new file mode 100644 index 0000000..2154d5d --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/notification/WebSocketAlertNotifier.java @@ -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}. + * + *

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.

+ */ +@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)); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlarmReadModelAdapter.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlarmReadModelAdapter.java new file mode 100644 index 0000000..de4ab7a --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlarmReadModelAdapter.java @@ -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}. + * + *

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.

+ */ +@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 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 recentAlerts(String zoneId, int limit) { + return alertRepository + .findByZoneIdOrderByDetectedAtDesc(zoneId, PageRequest.of(0, limit)) + .stream() + .map(AlertMapper::toView) + .toList(); + } + + @Override + public Optional findAlert(UUID alertId) { + return alertRepository.findById(alertId).map(AlertMapper::toView); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlarmStateRepositoryAdapter.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlarmStateRepositoryAdapter.java new file mode 100644 index 0000000..5bc9312 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlarmStateRepositoryAdapter.java @@ -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}. + * + *

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.

+ */ +@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 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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlertRepositoryAdapter.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlertRepositoryAdapter.java new file mode 100644 index 0000000..f05ffd4 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/AlertRepositoryAdapter.java @@ -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 findById(AlertId id) { + return jpaRepository.findById(id.value()).map(AlertMapper::toDomain); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/entity/AlarmStateEntity.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/entity/AlarmStateEntity.java new file mode 100644 index 0000000..10613fc --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/entity/AlarmStateEntity.java @@ -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. + * + *

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.

+ * + *

{@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.

+ */ +@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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/entity/AlertEntity.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/entity/AlertEntity.java new file mode 100644 index 0000000..04e729a --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/entity/AlertEntity.java @@ -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. + * + *

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.

+ * + *

Noter {@code trigger_type} : {@code trigger} est un mot reserve SQL.

+ */ +@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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/jpa/AlarmStateJpaRepository.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/jpa/AlarmStateJpaRepository.java new file mode 100644 index 0000000..08c0561 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/jpa/AlarmStateJpaRepository.java @@ -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 { +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/jpa/AlertJpaRepository.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/jpa/AlertJpaRepository.java new file mode 100644 index 0000000..fae8578 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/jpa/AlertJpaRepository.java @@ -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 { + + List findByZoneIdOrderByDetectedAtDesc(String zoneId, Pageable pageable); + + Optional findFirstByZoneIdOrderByDetectedAtDesc(String zoneId); + + long countByZoneIdAndAcknowledgedFalse(String zoneId); +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/mapper/AlertMapper.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/mapper/AlertMapper.java new file mode 100644 index 0000000..ac4db35 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/persistence/mapper/AlertMapper.java @@ -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. + * + *

Regroupees ici volontairement : quand un champ est ajoute, les trois + * traductions a mettre a jour sont cote a cote dans un seul fichier.

+ */ +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. + * + *

Utilise par le push WebSocket : l'alerte vient d'etre ecrite, faire un + * aller-retour SQL pour l'afficher n'apporterait rien.

+ */ + 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()); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/infrastructure/simulator/IphoneSensorSimulator.java b/src/main/java/me/belkacemi/iotalarm/infrastructure/simulator/IphoneSensorSimulator.java new file mode 100644 index 0000000..f5e5a0b --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/infrastructure/simulator/IphoneSensorSimulator.java @@ -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. + * + *

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.

+ * + *

Point d'architecture : il passe par le meme port + * {@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.

+ */ +@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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandler.java b/src/main/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandler.java new file mode 100644 index 0000000..54a895e --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandler.java @@ -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). + * + *

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.

+ */ +@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; + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/rest/AlarmCommandController.java b/src/main/java/me/belkacemi/iotalarm/presentation/rest/AlarmCommandController.java new file mode 100644 index 0000000..5e3b3ee --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/presentation/rest/AlarmCommandController.java @@ -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. + * + *

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.

+ */ +@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 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 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 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())); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/rest/AlarmQueryController.java b/src/main/java/me/belkacemi/iotalarm/presentation/rest/AlarmQueryController.java new file mode 100644 index 0000000..5ae2dd9 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/presentation/rest/AlarmQueryController.java @@ -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 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> alerts( + @RequestParam(required = false) Integer limit) { + return ResponseEntity.ok(queryService.recentAlerts(zoneId, limit)); + } + + @GetMapping("/alerts/{alertId}") + @Operation(summary = "Detail d'une alerte") + public ResponseEntity alert(@PathVariable UUID alertId) { + return queryService.alert(alertId) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/rest/SensorIngestController.java b/src/main/java/me/belkacemi/iotalarm/presentation/rest/SensorIngestController.java new file mode 100644 index 0000000..8777bc0 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/presentation/rest/SensorIngestController.java @@ -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. + * + *

Repond 202 Accepted 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.

+ */ +@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> 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())); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/rest/dto/AlarmActionResponse.java b/src/main/java/me/belkacemi/iotalarm/presentation/rest/dto/AlarmActionResponse.java new file mode 100644 index 0000000..e1c2886 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/presentation/rest/dto/AlarmActionResponse.java @@ -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) { +} diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/rest/dto/IngestSensorEventRequest.java b/src/main/java/me/belkacemi/iotalarm/presentation/rest/dto/IngestSensorEventRequest.java new file mode 100644 index 0000000..091d762 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/presentation/rest/dto/IngestSensorEventRequest.java @@ -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. + * + *
+ * {"deviceId": "iphone-biblio", "type": "MOTION_DETECTED"}
+ * 
+ * + *

{@code occurredAt} et {@code confidence} sont optionnels : un capteur + * binaire n'a ni horloge synchronisee ni notion de probabilite.

+ */ +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. + * + *

La frontiere est ici : au-dela de cette methode, plus aucune + * {@code String} non validee ne circule.

+ */ + 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); + } +} diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/websocket/AlarmWebSocketController.java b/src/main/java/me/belkacemi/iotalarm/presentation/websocket/AlarmWebSocketController.java new file mode 100644 index 0000000..1789966 --- /dev/null +++ b/src/main/java/me/belkacemi/iotalarm/presentation/websocket/AlarmWebSocketController.java @@ -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. + * + *

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.

+ */ +@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); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..44e6c94 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -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 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..c158a7a --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..733a13c --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/db/migration/V1__init_alarm_schema.sql b/src/main/resources/db/migration/V1__init_alarm_schema.sql new file mode 100644 index 0000000..054602c --- /dev/null +++ b/src/main/resources/db/migration/V1__init_alarm_schema.sql @@ -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); diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js new file mode 100644 index 0000000..16c1269 --- /dev/null +++ b/src/main/resources/static/app.js @@ -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(); +})(); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..0928b53 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,103 @@ + + + + + + Console de supervision — Studio + + + + + + + + +
+
+ +
+

Studio — Console de supervision

+

Capteur iphone-biblio · bibliotheque, etagere du haut

+
+
+ +
+ +
+ + + +
+
+

Etat du systeme

+

DESARMEE

+

Aucune surveillance active.

+
+ +
+ + +
+
+ + + + +
+
+

Alertes non acquittees

+

—

+
+
+

Derniere intrusion

+

—

+
+
+

Delai de sortie

+

—

+
+
+

Evenements recus

+

0

+
+
+ + + + +
+
+

Journal des alertes

+ +
+
    +
  • Aucune alerte enregistree.
  • +
+
+
+ +
+

+ Java 21 · Spring Boot 3 · Apache Kafka (KRaft) · PostgreSQL · + Architecture hexagonale & CQRS +

+

+ API · + Sante · + Metriques +

+
+ + + + + + + diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css new file mode 100644 index 0000000..05c27f3 --- /dev/null +++ b/src/main/resources/static/style.css @@ -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; } +} diff --git a/src/test/java/me/belkacemi/iotalarm/architecture/HexagonalArchitectureTest.java b/src/test/java/me/belkacemi/iotalarm/architecture/HexagonalArchitectureTest.java new file mode 100644 index 0000000..7526682 --- /dev/null +++ b/src/test/java/me/belkacemi/iotalarm/architecture/HexagonalArchitectureTest.java @@ -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. + * + *

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.

+ */ +@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); + } +} diff --git a/src/test/java/me/belkacemi/iotalarm/domain/model/AlarmSystemTest.java b/src/test/java/me/belkacemi/iotalarm/domain/model/AlarmSystemTest.java new file mode 100644 index 0000000..1bde5e8 --- /dev/null +++ b/src/test/java/me/belkacemi/iotalarm/domain/model/AlarmSystemTest.java @@ -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. + * + *

C'est le benefice concret de l'architecture hexagonale : la totalite de la + * logique de detection se verifie en quelques millisecondes.

+ */ +@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); + } + } +} diff --git a/src/test/java/me/belkacemi/iotalarm/domain/model/DeviceIdTest.java b/src/test/java/me/belkacemi/iotalarm/domain/model/DeviceIdTest.java new file mode 100644 index 0000000..1fa2238 --- /dev/null +++ b/src/test/java/me/belkacemi/iotalarm/domain/model/DeviceIdTest.java @@ -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); + } +}