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:
parent
94e893af97
commit
d70892dce0
12 changed files with 827 additions and 13 deletions
|
|
@ -20,3 +20,10 @@ ALARM_EXIT_DELAY=30s
|
|||
ALARM_MIN_CONFIDENCE=60
|
||||
|
||||
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
3
.gitignore
vendored
|
|
@ -24,3 +24,6 @@ data/
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
### Genere au deploiement (contient le mot de passe de publication) ###
|
||||
deploy/mediamtx/mediamtx.yml
|
||||
|
|
|
|||
104
README.md
104
README.md
|
|
@ -29,6 +29,39 @@ tests plutôt que par la discipline.
|
|||
|
||||
## Le flux
|
||||
|
||||
### Deux flux, deux chemins
|
||||
|
||||
Le système transporte deux choses de nature très différente, et elles ne
|
||||
suivent volontairement pas le même chemin :
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ iPhone (capteur) │
|
||||
└──────────┬──────────┘
|
||||
événements │ vidéo
|
||||
(quelques octets) │ (800 kbps continu)
|
||||
┌──────────┴──────────┐
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌────────────────┐
|
||||
│ Spring Boot │ │ MediaMTX │
|
||||
│ Kafka · JPA │ │ SRT → WebRTC │
|
||||
│ domaine │ │ transport pur │
|
||||
└───────┬───────┘ └────────┬───────┘
|
||||
└──────────┬───────────┘
|
||||
▼
|
||||
Console web (une seule page)
|
||||
```
|
||||
|
||||
**La vidéo ne traverse jamais la JVM.** Faire transiter 800 kbps continus par
|
||||
Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média
|
||||
est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM
|
||||
garde le domaine métier. Les deux se rejoignent seulement dans le navigateur.
|
||||
|
||||
Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de
|
||||
fonctionner, et inversement.
|
||||
|
||||
### Le flux d'événements en détail
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ iPhone 6s │ Détection de mouvement
|
||||
|
|
@ -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 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/alerts?limit=50` | **Requête** — historique |
|
||||
| `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) |
|
||||
| `POST` | `/webrtc/biblio/whep` | Négociation WebRTC (servi par MediaMTX) |
|
||||
| `GET` | `/hls/biblio/index.m3u8` | Repli HLS (servi par MediaMTX) |
|
||||
|
||||
Documentation OpenAPI : `/swagger-ui.html`
|
||||
|
||||
|
|
@ -310,6 +413,7 @@ services.
|
|||
| Messagerie | Apache Kafka 3.9 (KRaft) |
|
||||
| Persistance | PostgreSQL 16 · Spring Data JPA · Flyway |
|
||||
| Temps réel | WebSocket / STOMP / SockJS |
|
||||
| Vidéo | MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie |
|
||||
| Build | Maven · Docker multi-stage |
|
||||
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
|
||||
| Observabilité | Actuator · Micrometer / Prometheus |
|
||||
|
|
|
|||
|
|
@ -7,22 +7,19 @@
|
|||
# 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.
|
||||
# Les deux orthographes sont servies : "seurveillance" est le sous-domaine
|
||||
# effectivement cree, "surveillance" couvre la faute de frappe dans les deux
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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"
|
||||
|
|
@ -32,6 +29,43 @@ video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me {
|
|||
-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 {
|
||||
output file /var/log/caddy/iot-alarm.log
|
||||
format json
|
||||
|
|
|
|||
|
|
@ -24,11 +24,25 @@ rsync -az --delete \
|
|||
--exclude '.DS_Store' \
|
||||
"${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)"
|
||||
ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose build app"
|
||||
|
||||
echo "==> Redemarrage"
|
||||
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"
|
||||
for i in $(seq 1 30); do
|
||||
|
|
|
|||
106
deploy/mediamtx/mediamtx.yml.template
Normal file
106
deploy/mediamtx/mediamtx.yml.template
Normal 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:
|
||||
|
|
@ -105,6 +105,33 @@ services:
|
|||
# "edge" pour etre joignable par le reverse proxy.
|
||||
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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import org.slf4j.Logger;
|
|||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.annotation.ExceptionHandler;
|
||||
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 :
|
||||
* 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)
|
||||
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);
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"Une erreur interne est survenue.");
|
||||
problem.setTitle("Erreur interne");
|
||||
return problem;
|
||||
return ResponseEntity.internalServerError().body(problem);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,35 @@
|
|||
</div>
|
||||
</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…</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 · 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 -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
|
|
@ -98,6 +127,9 @@
|
|||
<!-- Repli SockJS pour les reseaux qui filtrent l'upgrade WebSocket -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script>
|
||||
<!-- 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="stream.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
243
src/main/resources/static/stream.js
Normal file
243
src/main/resources/static/stream.js
Normal 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();
|
||||
})();
|
||||
|
|
@ -421,3 +421,165 @@ footer a:hover { color: var(--blue); }
|
|||
.controls { width: 100%; }
|
||||
.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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue