Flux video temps reel : l'iPhone pousse, MediaMTX restitue

L'iPhone n'est joignable depuis nulle part - CGNAT operateur en 4G, NAT
de la box sur le WiFi du studio, UPnP indisponible. Le serveur RTSP de
l'application camera est donc inutilisable de l'exterieur. Seule une
connexion sortante traverse les deux NAT : c'est le telephone qui pousse.

- MediaMTX en conteneur : SRT/RTMP en entree, WebRTC (WHEP) et HLS en
  sortie. La video ne traverse jamais la JVM - 800 kbps continus dans
  Spring Boot couteraient un thread par spectateur sans rien apporter.
- SRT privilegie sur RTMP : RTMP est du TCP, un paquet perdu sur un lien
  mobile bloque tout le flux. SRT retransmet selectivement en UDP.
- Lecture publique (choix assume), publication authentifiee : sans ca
  n'importe qui pousserait sa propre video sur le flux "biblio".
- Console : client WHEP maison avec repli HLS automatique, et mise en
  pause quand l'onglet passe en arriere-plan.

Correction d'un bug trouve en deployant : le @ExceptionHandler(Exception)
avalait les exceptions que Spring sait deja traduire, si bien que toute
URL inconnue renvoyait 500 au lieu de 404 - ce qui noyait les vraies
pannes au milieu des 500 provoques par les crawlers. Test de regression
ajoute.

35 tests au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-12 18:08:34 +02:00
parent 94e893af97
commit d70892dce0
12 changed files with 827 additions and 13 deletions

View file

@ -20,3 +20,10 @@ ALARM_EXIT_DELAY=30s
ALARM_MIN_CONFIDENCE=60 ALARM_MIN_CONFIDENCE=60
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Mot de passe de publication du flux video.
# Seul l'iPhone doit pouvoir pousser sur le flux "biblio" : sans ce garde-fou,
# n'importe qui sur Internet remplacerait l'image du studio par la sienne.
# Genere au deploiement, comme DB_PASSWORD :
# printf 'STREAM_PUBLISH_PASSWORD=%s\n' "$(openssl rand -hex 16)" >> .env
STREAM_PUBLISH_PASSWORD=CHANGEME

3
.gitignore vendored
View file

@ -24,3 +24,6 @@ data/
.env .env
.env.* .env.*
!.env.example !.env.example
### Genere au deploiement (contient le mot de passe de publication) ###
deploy/mediamtx/mediamtx.yml

104
README.md
View file

@ -29,6 +29,39 @@ tests plutôt que par la discipline.
## Le flux ## Le flux
### Deux flux, deux chemins
Le système transporte deux choses de nature très différente, et elles ne
suivent volontairement pas le même chemin :
```
┌─────────────────────┐
│ iPhone (capteur) │
└──────────┬──────────┘
événements │ vidéo
(quelques octets) │ (800 kbps continu)
┌──────────┴──────────┐
▼ ▼
┌───────────────┐ ┌────────────────┐
│ Spring Boot │ │ MediaMTX │
│ Kafka · JPA │ │ SRT → WebRTC │
│ domaine │ │ transport pur │
└───────┬───────┘ └────────┬───────┘
└──────────┬───────────┘
▼
Console web (une seule page)
```
**La vidéo ne traverse jamais la JVM.** Faire transiter 800 kbps continus par
Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média
est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM
garde le domaine métier. Les deux se rejoignent seulement dans le navigateur.
Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de
fonctionner, et inversement.
### Le flux d'événements en détail
``` ```
┌──────────────────┐ ┌──────────────────┐
│ iPhone 6s │ Détection de mouvement │ iPhone 6s │ Détection de mouvement
@ -166,6 +199,74 @@ implémentation de `AlarmReadModelPort`.
--- ---
## Le flux vidéo
### Le téléphone pousse, on ne l'interroge pas
C'est la contrainte qui a dicté toute cette partie. Un iPhone n'a **aucune
adresse joignable depuis Internet** : en 4G il est derrière le CGNAT de
l'opérateur, sur le WiFi du studio derrière le NAT de la box, et l'application
signale `UPnP unavailable`. Le serveur RTSP intégré à l'application caméra est
donc inutilisable de l'extérieur, quelle que soit la configuration.
La seule direction qui traverse ces deux NAT est la sortante. Le téléphone
**pousse** son flux vers le serveur :
```
iPhone ──SRT (UDP 8890)──▶ MediaMTX ──┬──▶ WebRTC < 1 s
└─RTMP (TCP 1935)──▶ └──▶ HLS ~ 6 s
```
**SRT plutôt que RTMP** quand le choix est possible : RTMP est du TCP, et sur
un lien mobile un paquet perdu déclenche une retransmission qui bloque tout le
reste. SRT transporte en UDP avec sa propre retransmission sélective — c'est
exactement le protocole conçu pour ce cas.
**WebRTC plutôt que HLS** côté lecture : voir quelqu'un six secondes après son
passage n'a pas le même intérêt. La console tente WebRTC (WHEP) d'abord et
bascule sur HLS si l'UDP est filtré — ce qui arrive précisément sur les réseaux
d'entreprise.
### Ce qui est protégé, ce qui ne l'est pas
| | |
|---|---|
| **Lecture** | publique — le flux est une vitrine, c'est assumé |
| **Publication** | authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux `biblio` |
### Brancher un vrai téléphone
Application : **IP Camera Lite** (iOS), menu *Live Push*.
```
SRT srt://<serveur>:8890?streamid=publish:biblio:iphone:<mot-de-passe>
RTMP rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>
```
Le mot de passe est généré au déploiement dans le `.env` du serveur
(`STREAM_PUBLISH_PASSWORD`) et n'existe nulle part dans ce dépôt.
Pour valider la chaîne sans téléphone, une mire suffit :
```bash
docker run --rm --network iot-alarm-internal jrottenberg/ffmpeg:6.1-alpine \
-re -f lavfi -i testsrc=size=640x360:rate=25 \
-c:v libx264 -preset ultrafast -tune zerolatency -b:v 800k -pix_fmt yuv420p \
-f flv "rtmp://mediamtx:1935/biblio?user=iphone&pass=$PASS"
```
### Le piège du reverse proxy
MediaMTX répond d'abord une redirection vers un chemin **absolu**
(`/biblio/index.m3u8?cookieCheck=1`). Derrière un `handle_path /hls/*` qui
retire le préfixe, le navigateur suivait cette redirection jusqu'à
l'application Spring Boot et recevait du JSON là où il attendait une playlist.
Le `header_down Location` du [snippet Caddy](deploy/Caddyfile.snippet) réécrit
le préfixe. Symptôme trompeur : `curl -I` renvoyait un 404 sans rapport, le
serveur HLS ne gérant simplement pas la méthode HEAD.
---
## Les décisions non évidentes ## Les décisions non évidentes
Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce
@ -259,6 +360,8 @@ mvn verify -Pintegration # Testcontainers (Kafka + PostgreSQL réels)
| `GET` | `/api/v1/alarm/status` | **Requête** — état courant | | `GET` | `/api/v1/alarm/status` | **Requête** — état courant |
| `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique | | `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique |
| `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) | | `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) |
| `POST` | `/webrtc/biblio/whep` | Négociation WebRTC (servi par MediaMTX) |
| `GET` | `/hls/biblio/index.m3u8` | Repli HLS (servi par MediaMTX) |
Documentation OpenAPI : `/swagger-ui.html` Documentation OpenAPI : `/swagger-ui.html`
@ -310,6 +413,7 @@ services.
| Messagerie | Apache Kafka 3.9 (KRaft) | | Messagerie | Apache Kafka 3.9 (KRaft) |
| Persistance | PostgreSQL 16 · Spring Data JPA · Flyway | | Persistance | PostgreSQL 16 · Spring Data JPA · Flyway |
| Temps réel | WebSocket / STOMP / SockJS | | Temps réel | WebSocket / STOMP / SockJS |
| Vidéo | MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie |
| Build | Maven · Docker multi-stage | | Build | Maven · Docker multi-stage |
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers | | Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
| Observabilité | Actuator · Micrometer / Prometheus | | Observabilité | Actuator · Micrometer / Prometheus |

View file

@ -7,22 +7,19 @@
# les services existants. Caddy obtient et renouvelle les certificats # les services existants. Caddy obtient et renouvelle les certificats
# Let's Encrypt tout seul - il n'y a pas de Certbot a installer. # 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 # Les deux orthographes sont servies : "seurveillance" est le sous-domaine
# demande, "surveillance" couvre la faute de frappe dans les deux sens. # effectivement cree, "surveillance" couvre la faute de frappe dans les deux
# Caddy demande un certificat pour chaque nom qui resout vers ce serveur. # sens. Caddy demande un certificat pour chaque nom qui resout vers ce serveur.
#
# Trois destinations derriere un seul domaine :
# /webrtc/* -> MediaMTX, signalisation WebRTC (flux video temps reel)
# /hls/* -> MediaMTX, repli HLS
# le reste -> l'application Spring Boot (console, API, websocket STOMP)
# #
# Le proxy WebSocket n'a rien a declarer : Caddy propage l'upgrade nativement. # Le proxy WebSocket n'a rien a declarer : Caddy propage l'upgrade nativement.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me { 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 { header {
Strict-Transport-Security "max-age=31536000; includeSubDomains" Strict-Transport-Security "max-age=31536000; includeSubDomains"
@ -32,6 +29,43 @@ video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me {
-Server -Server
} }
# 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
# --- Flux video : WebRTC (latence < 1 s) --------------------------------
# flush_interval -1 desactive la bufferisation de la reponse. Sans ca, la
# negociation WHEP et les segments basse latence seraient retenus par le
# proxy le temps de remplir un tampon - ce qui annule tout l'interet.
handle_path /webrtc/* {
reverse_proxy iot-alarm-mediamtx:8889 {
flush_interval -1
}
}
# --- Flux video : HLS (repli, ~6 s) -------------------------------------
# Pas de compression ici : les segments video sont deja compresses, gziper
# par-dessus ne gagne rien et consomme du CPU sur un serveur a 2 vCPU.
handle_path /hls/* {
reverse_proxy iot-alarm-mediamtx:8888 {
flush_interval -1
# MediaMTX repond d'abord une redirection vers un chemin ABSOLU
# (/biblio/index.m3u8?cookieCheck=1), qui ignore le prefixe /hls
# retire par handle_path. Sans cette reecriture, le navigateur
# suit la redirection vers l'application Spring Boot au lieu du
# serveur media - et recoit du JSON la ou il attend une playlist.
header_down Location "^/(.*)$" "/hls/${1}"
}
}
# --- Application Spring Boot (console, API, WebSocket STOMP) ------------
handle {
encode gzip zstd
reverse_proxy iot-alarm-app:8080
}
log { log {
output file /var/log/caddy/iot-alarm.log output file /var/log/caddy/iot-alarm.log
format json format json

View file

@ -24,11 +24,25 @@ rsync -az --delete \
--exclude '.DS_Store' \ --exclude '.DS_Store' \
"${LOCAL_DIR}/" "${HOST}:${REMOTE_DIR}/" "${LOCAL_DIR}/" "${HOST}:${REMOTE_DIR}/"
echo "==> Rendu de la configuration MediaMTX"
# Le mot de passe de publication vit dans le .env du serveur et n'existe nulle
# part dans le depot. Le fichier rendu est gitignore.
ssh "${HOST}" "cd ${REMOTE_DIR} && \
PASS=\$(grep '^STREAM_PUBLISH_PASSWORD=' .env | cut -d= -f2-) && \
PUBLIC_IP=\$(curl -s --max-time 10 https://api.ipify.org) && \
sed -e \"s|__PUBLISH_PASSWORD__|\${PASS}|\" \
-e \"s|__PUBLIC_IP__|\${PUBLIC_IP}|\" \
deploy/mediamtx/mediamtx.yml.template > deploy/mediamtx/mediamtx.yml && \
chmod 600 deploy/mediamtx/mediamtx.yml"
echo "==> Build de l'image (tests unitaires + ArchUnit inclus)" echo "==> Build de l'image (tests unitaires + ArchUnit inclus)"
ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose build app" ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose build app"
echo "==> Redemarrage" echo "==> Redemarrage"
ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose up -d" ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose up -d"
# `up -d` ne recree pas un conteneur dont seule la configuration montee a
# change : MediaMTX garderait l'ancienne en memoire.
ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose restart mediamtx"
echo "==> Attente du healthcheck" echo "==> Attente du healthcheck"
for i in $(seq 1 30); do for i in $(seq 1 30); do

View file

@ -0,0 +1,106 @@
###############################################################################
# MediaMTX - serveur media du systeme d'alarme.
#
# Role : recevoir le flux pousse par l'iPhone (SRT ou RTMP) et le re-publier
# dans des formats lisibles par un navigateur (WebRTC, HLS).
#
# Pourquoi un service separe et pas du code Java : transporter de la video est
# un probleme resolu, temps-reel et gourmand en I/O. Le faire transiter par la
# JVM n'apporterait rien et couterait cher. L'application Spring Boot garde le
# domaine d'alarme ; le media passe a cote.
#
# Pourquoi le telephone POUSSE au lieu d'etre interroge : en 4G comme derriere
# la box du studio, l'iPhone n'a pas d'adresse joignable depuis Internet
# (CGNAT operateur, NAT domestique, UPnP indisponible). Seule une connexion
# sortante depuis le telephone fonctionne dans les deux cas.
#
# CE FICHIER EST GENERE depuis mediamtx.yml.template - ne pas l'editer a la main.
###############################################################################
logLevel: info
logDestinations: [stdout]
readTimeout: 10s
writeTimeout: 10s
# API interne, utilisee pour la sonde de sante. Non publiee sur l'hote.
api: yes
apiAddress: :9997
###############################################################################
# Authentification
#
# Lecture publique : choix assume, le flux est une vitrine.
# Publication authentifiee : sans ca, n'importe qui sur Internet pourrait
# pousser sa propre video sur "biblio" et remplacer l'image du studio.
###############################################################################
authMethod: internal
authInternalUsers:
- user: any
pass:
ips: []
permissions:
- action: read
- action: playback
- user: iphone
pass: __PUBLISH_PASSWORD__
ips: []
permissions:
- action: publish
path: biblio
###############################################################################
# Entrees : ce que l'iPhone peut utiliser pour pousser
###############################################################################
# SRT - a privilegier. UDP avec retransmission : encaisse la perte de paquets
# d'un lien mobile la ou RTMP (TCP) se met a bufferiser puis decroche.
srt: yes
srtAddress: :8890
# RTMP - repli. Plus universellement supporte par les applications mobiles.
rtmp: yes
rtmpAddress: :1935
rtmpEncryption: "no"
# MoQ (Media over QUIC) : experimental, aucun navigateur ne le consomme
# aujourd'hui et la console ne l'utilise pas. Desactive.
moq: no
# RTSP desactive : l'iPhone n'est pas joignable en entrant, ce protocole
# n'aurait aucun usage ici et ouvrirait une surface d'attaque de plus.
rtsp: no
###############################################################################
# Sorties : ce que le navigateur consomme
###############################################################################
# WebRTC - chemin principal, latence inferieure a la seconde.
webrtc: yes
webrtcAddress: :8889
webrtcAllowOrigins: ['*']
webrtcLocalUDPAddress: :8189
# Le conteneur ne voit qu'une IP privee ; sans cette declaration, les
# candidats ICE annonces seraient inutilisables depuis l'exterieur.
webrtcIPsFromInterfaces: no
webrtcAdditionalHosts: [__PUBLIC_IP__]
# HLS - repli universel, ~6 s de latence. Couvre les reseaux d'entreprise
# qui filtrent l'UDP et donc WebRTC.
hls: yes
hlsAddress: :8888
hlsAllowOrigins: ['*']
hlsVariant: lowLatency
hlsSegmentCount: 7
hlsSegmentDuration: 1s
hlsPartDuration: 200ms
hlsAlwaysRemux: no
###############################################################################
# Flux
###############################################################################
paths:
# Le capteur du studio. Nom volontairement aligne sur le deviceId utilise
# par le domaine d'alarme (iphone-biblio).
biblio:

View file

@ -105,6 +105,33 @@ services:
# "edge" pour etre joignable par le reverse proxy. # "edge" pour etre joignable par le reverse proxy.
networks: [default, edge] networks: [default, edge]
# ---------------------------------------------------------------------------
# Serveur media - ingestion du flux iPhone, restitution navigateur
#
# L'iPhone POUSSE vers ce service (SRT ou RTMP). Il ne peut pas etre
# interroge : en 4G comme derriere la box du studio, il n'a pas d'adresse
# joignable depuis Internet. Seul un flux sortant traverse le CGNAT.
#
# Les ports d'ingestion sont publies sur l'hote, ce sont les seuls du projet
# a l'etre. Les sorties HTTP (HLS 8888, WebRTC 8889) restent internes et
# passent par Caddy, qui leur apporte le TLS du domaine.
# ---------------------------------------------------------------------------
mediamtx:
image: bluenviron/mediamtx:1.21.0
container_name: iot-alarm-mediamtx
restart: unless-stopped
volumes:
- ./deploy/mediamtx/mediamtx.yml:/mediamtx.yml:ro
ports:
- "1935:1935" # RTMP (TCP) - ingestion, repli
- "8890:8890/udp" # SRT (UDP) - ingestion, a privilegier
- "8189:8189/udp" # WebRTC ICE - media vers les navigateurs
mem_limit: 256m
# Image sans shell (base scratch) : pas de healthcheck par commande.
# La disponibilite se lit sur /actuator du cote applicatif et dans les
# logs du conteneur.
networks: [default, edge]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Nginx - profil "standalone" uniquement # Nginx - profil "standalone" uniquement
# #

View file

@ -5,6 +5,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail; import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
@ -66,14 +68,26 @@ public class GlobalExceptionHandler {
/** /**
* Filet de securite. On journalise la stacktrace mais on ne la renvoie pas : * Filet de securite. On journalise la stacktrace mais on ne la renvoie pas :
* l'endpoint d'ingestion est expose publiquement. * l'endpoint d'ingestion est expose publiquement.
*
* <p>Les exceptions que Spring sait deja traduire implementent
* {@link ErrorResponse} et portent leur propre statut : ressource absente
* (404), methode non supportee (405), media non supporte (415). Les avaler
* dans un 500 generique transformerait une URL inconnue en panne serveur -
* et, plus grave, noierait les vraies pannes au milieu de ces faux
* positifs dans les logs et les alertes.</p>
*/ */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ProblemDetail onUnexpectedError(Exception exception) { public ResponseEntity<ProblemDetail> onUnexpectedError(Exception exception) {
if (exception instanceof ErrorResponse errorResponse) {
return ResponseEntity.status(errorResponse.getStatusCode())
.body(errorResponse.getBody());
}
log.error("Erreur inattendue", exception); log.error("Erreur inattendue", exception);
ProblemDetail problem = ProblemDetail.forStatusAndDetail( ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
"Une erreur interne est survenue."); "Une erreur interne est survenue.");
problem.setTitle("Erreur interne"); problem.setTitle("Erreur interne");
return problem; return ResponseEntity.internalServerError().body(problem);
} }
} }

View file

@ -47,6 +47,35 @@
</div> </div>
</section> </section>
<!-- ---------------------------------------------------------------- -->
<!-- Flux video temps reel pousse par l'iPhone -->
<!-- ---------------------------------------------------------------- -->
<section class="video-panel" id="video-panel" data-state="connecting">
<div class="video-frame">
<video id="video" playsinline autoplay muted></video>
<div class="video-overlay" id="video-overlay">
<p class="video-overlay-title" id="video-overlay-title">Connexion au capteur&hellip;</p>
<p class="video-overlay-hint" id="video-overlay-hint">Recherche du flux <code>biblio</code></p>
</div>
<div class="video-badge" id="video-badge">
<span class="video-badge-dot" aria-hidden="true"></span>
<span id="video-badge-label">EN DIRECT</span>
</div>
<p class="video-transport" id="video-transport"></p>
</div>
<div class="video-caption">
<div>
<p class="video-caption-title">Capteur <code>iphone-biblio</code></p>
<p class="video-caption-sub">iPhone recycle, bibliotheque &middot; flux pousse en SRT vers le serveur</p>
</div>
<button type="button" id="btn-video-reload" class="btn-ghost">Relancer le flux</button>
</div>
</section>
<!-- ---------------------------------------------------------------- --> <!-- ---------------------------------------------------------------- -->
<!-- Metriques --> <!-- Metriques -->
<!-- ---------------------------------------------------------------- --> <!-- ---------------------------------------------------------------- -->
@ -98,6 +127,9 @@
<!-- Repli SockJS pour les reseaux qui filtrent l'upgrade WebSocket --> <!-- Repli SockJS pour les reseaux qui filtrent l'upgrade WebSocket -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script>
<!-- Repli HLS pour les reseaux qui filtrent l'UDP et donc WebRTC -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
<script src="stream.js"></script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,243 @@
/* ---------------------------------------------------------------------------
* Lecture du flux video pousse par l'iPhone.
*
* Deux transports, essayes dans cet ordre :
*
* 1. WebRTC (WHEP) - latence inferieure a la seconde. C'est ce qu'on veut
* pour de la surveillance : voir quelqu'un six secondes
* apres son passage n'a pas le meme interet.
* 2. HLS - repli. Plus de latence, mais passe partout, y compris
* sur les reseaux d'entreprise qui filtrent l'UDP et
* donc bloquent WebRTC.
*
* Le flux est absent la plupart du temps (l'iPhone ne pousse que lorsqu'il est
* en place et alimente). Ce n'est pas une erreur : c'est l'etat nominal, et
* l'interface doit le dire calmement plutot qu'afficher un echec rouge.
* --------------------------------------------------------------------------- */
(function () {
'use strict';
const STREAM = 'biblio';
const WHEP_URL = '/webrtc/' + STREAM + '/whep';
const HLS_URL = '/hls/' + STREAM + '/index.m3u8';
const RETRY_DELAY_MS = 8000;
const ICE_TIMEOUT_MS = 3000;
const el = {
panel: document.getElementById('video-panel'),
video: document.getElementById('video'),
title: document.getElementById('video-overlay-title'),
hint: document.getElementById('video-overlay-hint'),
transport: document.getElementById('video-transport'),
reload: document.getElementById('btn-video-reload')
};
let peerConnection = null;
let hlsPlayer = null;
let retryTimer = null;
let stopped = false;
/* ------------------------------ Affichage ----------------------------- */
function setState(state, title, hint, transport) {
el.panel.dataset.state = state;
el.title.textContent = title;
el.hint.innerHTML = hint;
el.transport.textContent = transport || '';
}
/* ------------------------------- Nettoyage ---------------------------- */
function teardown() {
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
if (hlsPlayer) {
hlsPlayer.destroy();
hlsPlayer = null;
}
el.video.srcObject = null;
el.video.removeAttribute('src');
el.video.load();
}
function scheduleRetry() {
if (stopped) { return; }
clearTimeout(retryTimer);
retryTimer = setTimeout(start, RETRY_DELAY_MS);
}
/* -------------------------------- WebRTC ------------------------------ */
/**
* Attend la fin de la collecte ICE plutot que d'implementer le trickle.
* Le serveur et le navigateur sont tous deux sur Internet public, la
* collecte prend quelques dizaines de millisecondes - le timeout n'est
* qu'un garde-fou pour ne pas rester bloque indefiniment.
*/
function waitForIce(pc) {
if (pc.iceGatheringState === 'complete') {
return Promise.resolve();
}
return new Promise(resolve => {
const done = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', done);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', done);
setTimeout(resolve, ICE_TIMEOUT_MS);
});
}
async function tryWebRtc() {
const pc = new RTCPeerConnection({ iceServers: [] });
peerConnection = pc;
// recvonly : la console regarde, elle n'emet jamais. Sans cette
// precision, le navigateur demanderait l'acces a la webcam du visiteur.
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.ontrack = event => {
el.video.srcObject = event.streams[0];
setState('live', '', '', 'WebRTC · latence < 1 s');
};
pc.onconnectionstatechange = () => {
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)
&& peerConnection === pc) {
setState('offline', 'Flux interrompu',
'Reconnexion automatique dans quelques secondes.', '');
teardown();
scheduleRetry();
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIce(pc);
const response = await fetch(WHEP_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
// 404 = aucun publieur sur ce flux. L'iPhone n'emet pas, c'est tout.
if (response.status === 404) {
const error = new Error('no-publisher');
error.noPublisher = true;
throw error;
}
if (!response.ok) {
throw new Error('WHEP ' + response.status);
}
await pc.setRemoteDescription({
type: 'answer',
sdp: await response.text()
});
}
/* --------------------------------- HLS -------------------------------- */
function tryHls() {
return new Promise((resolve, reject) => {
// Safari lit le HLS nativement, sans bibliotheque.
if (el.video.canPlayType('application/vnd.apple.mpegurl')) {
el.video.src = HLS_URL;
el.video.addEventListener('loadedmetadata', () => {
setState('live', '', '', 'HLS natif · ~6 s de latence');
resolve();
}, { once: true });
el.video.addEventListener('error', () => reject(new Error('hls-natif')),
{ once: true });
return;
}
if (typeof Hls === 'undefined' || !Hls.isSupported()) {
reject(new Error('hls-indisponible'));
return;
}
const hls = new Hls({ lowLatencyMode: true, backBufferLength: 10 });
hlsPlayer = hls;
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setState('live', '', '', 'HLS · ~6 s de latence');
resolve();
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (data.fatal) {
hls.destroy();
hlsPlayer = null;
reject(new Error('hls-' + data.type));
}
});
hls.loadSource(HLS_URL);
hls.attachMedia(el.video);
});
}
/* ------------------------------ Orchestration -------------------------- */
async function start() {
clearTimeout(retryTimer);
teardown();
setState('connecting', 'Connexion au capteur...',
'Recherche du flux <code>' + STREAM + '</code>', '');
try {
await tryWebRtc();
return;
} catch (error) {
teardown();
if (error.noPublisher) {
setState('offline', 'Capteur hors ligne',
'L\'iPhone ne pousse aucun flux pour le moment. '
+ 'La detection d\'intrusion, elle, continue de fonctionner.', '');
scheduleRetry();
return;
}
console.warn('WebRTC indisponible, repli HLS :', error.message);
}
try {
await tryHls();
} catch (error) {
console.warn('HLS indisponible :', error.message);
teardown();
setState('offline', 'Flux indisponible',
'Ni WebRTC ni HLS n\'ont abouti. Nouvelle tentative automatique.', '');
scheduleRetry();
}
}
/* -------------------------------- Demarrage ---------------------------- */
el.reload.addEventListener('click', start);
// Un onglet en arriere-plan n'a pas besoin de decoder de la video :
// ca economise la batterie du visiteur et la bande passante du serveur.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopped = true;
clearTimeout(retryTimer);
teardown();
setState('offline', 'Flux en pause', 'Onglet en arriere-plan.', '');
} else {
stopped = false;
start();
}
});
start();
})();

View file

@ -421,3 +421,165 @@ footer a:hover { color: var(--blue); }
.controls { width: 100%; } .controls { width: 100%; }
.btn { flex: 1; justify-content: center; } .btn { flex: 1; justify-content: center; }
} }
/* ---------------------------------------------------------------------------
Flux video
--------------------------------------------------------------------------- */
.video-panel {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg-panel);
overflow: hidden;
}
.video-frame {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
/* Le fond noir doit rester visible meme si la video n'a pas ce ratio :
une camera de surveillance verticale ne doit pas etre deformee. */
display: flex;
align-items: center;
justify-content: center;
}
.video-frame video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
background: #000;
}
/* --- Voile d'attente, masque des que l'image arrive --- */
.video-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
padding: 24px;
background: linear-gradient(180deg, #0d1016, #05070a);
transition: opacity 400ms ease;
}
.video-panel[data-state="live"] .video-overlay {
opacity: 0;
pointer-events: none;
}
.video-overlay-title {
margin: 0;
font-family: var(--mono);
font-size: 14px;
color: var(--text-dim);
letter-spacing: 0.02em;
}
.video-overlay-hint {
margin: 0;
font-size: 12.5px;
color: var(--text-faint);
max-width: 46ch;
line-height: 1.6;
}
.video-overlay-hint code {
font-family: var(--mono);
color: var(--blue);
background: rgba(96, 165, 250, 0.1);
padding: 1px 5px;
border-radius: 4px;
}
.video-panel[data-state="offline"] .video-overlay-title { color: var(--amber); }
.video-panel[data-state="error"] .video-overlay-title { color: var(--red); }
/* --- Pastille "EN DIRECT" --- */
.video-badge {
position: absolute;
top: 14px;
left: 14px;
display: none;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 999px;
background: rgba(10, 12, 16, 0.78);
backdrop-filter: blur(8px);
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.1em;
color: var(--text);
}
.video-panel[data-state="live"] .video-badge { display: flex; }
.video-badge-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 9px var(--red);
animation: pulse 1.6s ease-in-out infinite;
}
/* --- Transport reellement utilise (WebRTC ou HLS) --- */
.video-transport {
position: absolute;
bottom: 12px;
right: 14px;
margin: 0;
padding: 4px 9px;
border-radius: 6px;
background: rgba(10, 12, 16, 0.7);
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
}
.video-transport:empty { display: none; }
/* --- Legende sous l'image --- */
.video-caption {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
border-top: 1px solid var(--border);
}
.video-caption-title {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.video-caption-title code {
font-family: var(--mono);
font-size: 12.5px;
color: var(--blue);
}
.video-caption-sub {
margin: 3px 0 0;
font-size: 12px;
color: var(--text-faint);
}
/* Quand une intrusion est en cours, le cadre video se souligne en rouge :
c'est l'image qu'on veut regarder a ce moment precis. */
.status-panel[data-state="TRIGGERED"] ~ .video-panel {
border-color: var(--red);
box-shadow: 0 0 28px rgba(244, 63, 94, 0.18);
}

View file

@ -0,0 +1,68 @@
package me.belkacemi.iotalarm.presentation.advice;
import me.belkacemi.iotalarm.domain.exception.DomainException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression : le filet de securite {@code @ExceptionHandler(Exception.class)}
* avalait les exceptions que Spring sait deja traduire.
*
* <p>Consequence observee en production : toute URL inconnue renvoyait 500
* "Erreur interne" au lieu de 404. Au-dela du code faux, ca noyait les vraies
* pannes au milieu des 500 provoques par les crawlers et les liens morts.</p>
*/
@DisplayName("GlobalExceptionHandler")
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
@DisplayName("une ressource absente reste un 404, pas un 500")
void unknownResourceStaysNotFound() {
NoResourceFoundException exception =
new NoResourceFoundException(HttpMethod.GET, "/route-inconnue");
ResponseEntity<ProblemDetail> response = handler.onUnexpectedError(exception);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@DisplayName("une exception reellement inattendue donne un 500")
void unexpectedExceptionIsInternalServerError() {
ResponseEntity<ProblemDetail> response =
handler.onUnexpectedError(new IllegalStateException("pool JDBC epuise"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
@Test
@DisplayName("le message interne ne fuite jamais vers le client")
void internalMessageIsNeverLeaked() {
ResponseEntity<ProblemDetail> response = handler.onUnexpectedError(
new IllegalStateException("jdbc:postgresql://postgres:5432 mot de passe refuse"));
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getDetail())
.isEqualTo("Une erreur interne est survenue.")
.doesNotContain("jdbc", "postgres", "mot de passe");
}
@Test
@DisplayName("une regle metier violee est un 409, pas un 400")
void domainRuleViolationIsConflict() {
ProblemDetail problem = handler.onDomainException(
new DomainException("L'alarme est deja armee"));
assertThat(problem.getStatus()).isEqualTo(HttpStatus.CONFLICT.value());
assertThat(problem.getDetail()).isEqualTo("L'alarme est deja armee");
}
}