Sonde de presence video : la camera muette devient un evenement capteur
Le domaine connaissait deja CAMERA_OFFLINE, classe CRITICAL, mais rien ne l'emettait. CameraLivenessProbe interroge l'API du serveur media et comble ce trou. Elle passe par le meme port IngestSensorEventUseCase que l'endpoint REST et le simulateur : trois producteurs de natures tres differentes, un seul contrat d'entree, et le meme trajet Kafka -> agregat ensuite. Si l'alarme est desarmee, une coupure video ne reveille personne - c'est l'agregat qui tranche, pas la sonde. Le seuil est la partie qui compte. Sur un lien 4G le flux tombe regulierement sans que personne n'ait touche au telephone : tunnel, bascule d'antenne, congestion. Alerter des la premiere sonde ratee produirait plusieurs CRITICAL par jour, et on apprend vite a ignorer des alertes qui crient pour rien - y compris le jour ou elles ont raison. Trois sondes consecutives, soit une minute d'absence reelle. Six tests couvrent ce comportement, dont la remise a zero du compteur au retour du flux. Utilisateur d'API dedie cote MediaMTX, restreint au reseau Docker interne. 41 tests au vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5e4dfee665
commit
d017ba4958
12 changed files with 446 additions and 4 deletions
|
|
@ -27,3 +27,8 @@ LOG_LEVEL=INFO
|
||||||
# Genere au deploiement, comme DB_PASSWORD :
|
# Genere au deploiement, comme DB_PASSWORD :
|
||||||
# printf 'STREAM_PUBLISH_PASSWORD=%s\n' "$(openssl rand -hex 16)" >> .env
|
# printf 'STREAM_PUBLISH_PASSWORD=%s\n' "$(openssl rand -hex 16)" >> .env
|
||||||
STREAM_PUBLISH_PASSWORD=CHANGEME
|
STREAM_PUBLISH_PASSWORD=CHANGEME
|
||||||
|
|
||||||
|
# Mot de passe de l'API MediaMTX, utilise par la sonde de presence video.
|
||||||
|
# Genere au deploiement :
|
||||||
|
# printf 'MEDIA_API_PASSWORD=%s\n' "$(openssl rand -hex 16)" >> .env
|
||||||
|
MEDIA_API_PASSWORD=CHANGEME
|
||||||
|
|
|
||||||
36
README.md
36
README.md
|
|
@ -234,15 +234,45 @@ d'entreprise.
|
||||||
| **Lecture** | publique — le flux est une vitrine, c'est assumé |
|
| **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` |
|
| **Publication** | authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux `biblio` |
|
||||||
|
|
||||||
|
### La caméra muette est un événement comme un autre
|
||||||
|
|
||||||
|
Le domaine connaissait déjà `CAMERA_OFFLINE`, classé `CRITICAL` — un capteur
|
||||||
|
qui se tait alors que l'alarme est armée, c'est soit une batterie morte, soit
|
||||||
|
quelqu'un qui l'a trouvé derrière les livres. Rien ne l'émettait jamais.
|
||||||
|
`CameraLivenessProbe` comble ce trou : elle interroge l'API du serveur média et
|
||||||
|
traduit une absence prolongée en événement capteur.
|
||||||
|
|
||||||
|
Elle passe par le **même port** `IngestSensorEventUseCase` que l'endpoint REST
|
||||||
|
et le simulateur. Trois producteurs d'événements de natures totalement
|
||||||
|
différentes — un téléphone, un simulateur, une sonde de supervision — un seul
|
||||||
|
contrat d'entrée, et le même trajet Kafka → agrégat ensuite. Si l'alarme est
|
||||||
|
désarmée, une coupure vidéo ne réveille personne : c'est l'agrégat qui tranche,
|
||||||
|
pas la sonde.
|
||||||
|
|
||||||
|
**Le seuil est la partie qui compte.** Sur un lien 4G, le flux tombe
|
||||||
|
régulièrement sans que personne n'ait touché au téléphone : tunnel, bascule
|
||||||
|
d'antenne, congestion. Alerter dès la première sonde ratée produirait plusieurs
|
||||||
|
`CRITICAL` par jour — et la seule chose qu'on apprend à vivre avec des alertes
|
||||||
|
qui crient pour rien, c'est à les ignorer, y compris le jour où elles ont
|
||||||
|
raison. Il faut donc trois sondes consécutives, soit une minute d'absence
|
||||||
|
réelle.
|
||||||
|
|
||||||
### Brancher un vrai téléphone
|
### Brancher un vrai téléphone
|
||||||
|
|
||||||
Application : **IP Camera Lite** (iOS), menu *Live Push*.
|
**Larix Broadcaster** (iOS/Android, gratuite, sans filigrane) : elle gère SRT,
|
||||||
|
la reconnexion automatique et le débit adaptatif. C'est ce qu'il faut sur un
|
||||||
|
lien mobile. IP Camera Lite sait aussi pousser, mais elle est conçue pour le
|
||||||
|
mode *pull* — lequel ne peut pas fonctionner depuis l'extérieur.
|
||||||
|
|
||||||
```
|
```
|
||||||
SRT srt://<serveur>:8890?streamid=publish:biblio:iphone:<mot-de-passe>
|
SRT srt://<serveur>:8890 · streamid = publish:biblio:iphone:<mot-de-passe>
|
||||||
RTMP rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>
|
RTMP rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Réglages pour la 4G : **H.264, 640×360, 15 fps, 400–600 kbps, latence SRT
|
||||||
|
2000 ms, audio coupé**. À 500 kbps, compter **~225 Mo par heure** — un flux
|
||||||
|
permanent dépasse 5 Go par jour, ce qui n'a de sens que sur le WiFi du studio.
|
||||||
|
|
||||||
Le mot de passe est généré au déploiement dans le `.env` du serveur
|
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.
|
(`STREAM_PUBLISH_PASSWORD`) et n'existe nulle part dans ce dépôt.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,10 @@ echo "==> Rendu de la configuration MediaMTX"
|
||||||
# part dans le depot. Le fichier rendu est gitignore.
|
# part dans le depot. Le fichier rendu est gitignore.
|
||||||
ssh "${HOST}" "cd ${REMOTE_DIR} && \
|
ssh "${HOST}" "cd ${REMOTE_DIR} && \
|
||||||
PASS=\$(grep '^STREAM_PUBLISH_PASSWORD=' .env | cut -d= -f2-) && \
|
PASS=\$(grep '^STREAM_PUBLISH_PASSWORD=' .env | cut -d= -f2-) && \
|
||||||
|
APIPASS=\$(grep '^MEDIA_API_PASSWORD=' .env | cut -d= -f2-) && \
|
||||||
PUBLIC_IP=\$(curl -s --max-time 10 https://api.ipify.org) && \
|
PUBLIC_IP=\$(curl -s --max-time 10 https://api.ipify.org) && \
|
||||||
sed -e \"s|__PUBLISH_PASSWORD__|\${PASS}|\" \
|
sed -e \"s|__PUBLISH_PASSWORD__|\${PASS}|\" \
|
||||||
|
-e \"s|__API_PASSWORD__|\${APIPASS}|\" \
|
||||||
-e \"s|__PUBLIC_IP__|\${PUBLIC_IP}|\" \
|
-e \"s|__PUBLIC_IP__|\${PUBLIC_IP}|\" \
|
||||||
deploy/mediamtx/mediamtx.yml.template > deploy/mediamtx/mediamtx.yml && \
|
deploy/mediamtx/mediamtx.yml.template > deploy/mediamtx/mediamtx.yml && \
|
||||||
chmod 600 deploy/mediamtx/mediamtx.yml"
|
chmod 600 deploy/mediamtx/mediamtx.yml"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@ authInternalUsers:
|
||||||
- action: read
|
- action: read
|
||||||
- action: playback
|
- action: playback
|
||||||
|
|
||||||
|
# Lecture seule de l'API, pour la sonde de presence de l'application.
|
||||||
|
# Restreinte au reseau Docker interne : l'API n'est de toute facon pas
|
||||||
|
# publiee sur l'hote, mais deux verrous valent mieux qu'un.
|
||||||
|
- user: probe
|
||||||
|
pass: __API_PASSWORD__
|
||||||
|
ips: ['172.16.0.0/12']
|
||||||
|
permissions:
|
||||||
|
- action: api
|
||||||
|
|
||||||
- user: iphone
|
- user: iphone
|
||||||
pass: __PUBLISH_PASSWORD__
|
pass: __PUBLISH_PASSWORD__
|
||||||
ips: []
|
ips: []
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
# Demarrage seulement : la sonde tolere un serveur media absent, elle
|
||||||
|
# ne doit pas empecher l'alarme de fonctionner.
|
||||||
|
mediamtx:
|
||||||
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: prod
|
SPRING_PROFILES_ACTIVE: prod
|
||||||
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
||||||
|
|
@ -99,6 +103,9 @@ services:
|
||||||
SIMULATOR_INTERVAL: ${SIMULATOR_INTERVAL:-8s}
|
SIMULATOR_INTERVAL: ${SIMULATOR_INTERVAL:-8s}
|
||||||
ALARM_EXIT_DELAY: ${ALARM_EXIT_DELAY:-30s}
|
ALARM_EXIT_DELAY: ${ALARM_EXIT_DELAY:-30s}
|
||||||
ALARM_MIN_CONFIDENCE: ${ALARM_MIN_CONFIDENCE:-60}
|
ALARM_MIN_CONFIDENCE: ${ALARM_MIN_CONFIDENCE:-60}
|
||||||
|
MEDIA_API_URL: http://mediamtx:9997
|
||||||
|
MEDIA_API_PASSWORD: ${MEDIA_API_PASSWORD}
|
||||||
|
CAMERA_PROBE_ENABLED: ${CAMERA_PROBE_ENABLED:-true}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
# Joint les deux reseaux : "default" pour parler a Kafka et PostgreSQL,
|
# Joint les deux reseaux : "default" pour parler a Kafka et PostgreSQL,
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,35 @@ public record AlarmProperties(
|
||||||
Duration alertCooldown,
|
Duration alertCooldown,
|
||||||
int minimumConfidence,
|
int minimumConfidence,
|
||||||
Kafka kafka,
|
Kafka kafka,
|
||||||
Simulator simulator) {
|
Simulator simulator,
|
||||||
|
Camera camera) {
|
||||||
|
|
||||||
public record Kafka(String topic, String dlqTopic, int partitions, short replicationFactor) {
|
public record Kafka(String topic, String dlqTopic, int partitions, short replicationFactor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sonde de presence du flux video.
|
||||||
|
*
|
||||||
|
* @param livenessProbeEnabled active la sonde
|
||||||
|
* @param probeInterval periode d'interrogation du serveur media
|
||||||
|
* @param offlineThreshold sondes ratees consecutives avant de crier au loup.
|
||||||
|
* Sur un lien 4G, une coupure de quelques secondes
|
||||||
|
* (tunnel, bascule d'antenne) est normale : alerter
|
||||||
|
* des la premiere raterait la cible et apprendrait
|
||||||
|
* surtout a ignorer les alertes.
|
||||||
|
* @param streamPath nom du flux cote serveur media
|
||||||
|
* @param apiUrl base de l'API MediaMTX, sur le reseau interne
|
||||||
|
*/
|
||||||
|
public record Camera(
|
||||||
|
boolean livenessProbeEnabled,
|
||||||
|
Duration probeInterval,
|
||||||
|
int offlineThreshold,
|
||||||
|
String streamPath,
|
||||||
|
String apiUrl,
|
||||||
|
String apiUser,
|
||||||
|
String apiPassword) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulateur de capteur. Sur le deploiement public, l'iPhone n'est pas
|
* Simulateur de capteur. Sur le deploiement public, l'iPhone n'est pas
|
||||||
* joignable : ce simulateur rejoue un trafic realiste pour que la
|
* joignable : ce simulateur rejoue un trafic realiste pour que la
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package me.belkacemi.iotalarm.infrastructure.media;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADAPTER ENTRANT : transforme la perte du flux video en evenement capteur.
|
||||||
|
*
|
||||||
|
* <p>Le domaine connait deja {@link SensorEventType#CAMERA_OFFLINE}, classe
|
||||||
|
* {@code CRITICAL} : un capteur qui se tait alors que l'alarme est armee, c'est
|
||||||
|
* soit une batterie morte, soit quelqu'un qui l'a trouve derriere les livres.
|
||||||
|
* Jusqu'ici, rien n'emettait cet evenement. C'est cette sonde.</p>
|
||||||
|
*
|
||||||
|
* <h2>Pourquoi un seuil et pas une detection immediate</h2>
|
||||||
|
*
|
||||||
|
* <p>Sur un lien 4G, le flux tombe regulierement sans que personne n'ait touche
|
||||||
|
* au telephone : tunnel, bascule d'antenne, congestion. Alerter des la premiere
|
||||||
|
* sonde ratee produirait plusieurs alertes CRITICAL par jour, et la seule chose
|
||||||
|
* qu'on apprend a vivre avec des alertes qui crient pour rien, c'est a les
|
||||||
|
* ignorer - y compris le jour ou elles ont raison.</p>
|
||||||
|
*
|
||||||
|
* <p>Il faut donc {@code offlineThreshold} sondes consecutives ratees, soit une
|
||||||
|
* minute d'absence reelle avec les valeurs par defaut.</p>
|
||||||
|
*
|
||||||
|
* <p>Point d'architecture : la sonde passe par le <em>meme port</em>
|
||||||
|
* {@link IngestSensorEventUseCase} que l'endpoint REST et le simulateur. Trois
|
||||||
|
* producteurs d'evenements tres differents, un seul contrat d'entree - et
|
||||||
|
* l'evenement traverse ensuite Kafka et l'agregat comme n'importe quel autre.
|
||||||
|
* Si l'alarme est desarmee, une coupure video ne reveille personne : c'est
|
||||||
|
* l'agregat qui en decide, pas cette classe.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
prefix = "alarm.camera",
|
||||||
|
name = "liveness-probe-enabled",
|
||||||
|
havingValue = "true")
|
||||||
|
public class CameraLivenessProbe {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CameraLivenessProbe.class);
|
||||||
|
|
||||||
|
private final MediaServerClient mediaServer;
|
||||||
|
private final IngestSensorEventUseCase ingestSensorEvent;
|
||||||
|
private final AlarmProperties.Camera config;
|
||||||
|
private final DeviceId deviceId;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
/** Sondes ratees d'affilee. Remis a zero des que l'image revient. */
|
||||||
|
private int consecutiveFailures;
|
||||||
|
|
||||||
|
/** Evite de re-emettre l'evenement a chaque sonde tant que l'absence dure. */
|
||||||
|
private boolean offlineAlreadyReported;
|
||||||
|
|
||||||
|
public CameraLivenessProbe(MediaServerClient mediaServer,
|
||||||
|
IngestSensorEventUseCase ingestSensorEvent,
|
||||||
|
AlarmProperties properties,
|
||||||
|
Clock clock) {
|
||||||
|
this.mediaServer = mediaServer;
|
||||||
|
this.ingestSensorEvent = ingestSensorEvent;
|
||||||
|
this.config = properties.camera();
|
||||||
|
this.deviceId = DeviceId.of(properties.simulator().deviceId());
|
||||||
|
this.clock = clock;
|
||||||
|
log.info("Sonde de presence video active sur '{}' - {} sondes ratees "
|
||||||
|
+ "consecutives declenchent un CAMERA_OFFLINE",
|
||||||
|
config.streamPath(), config.offlineThreshold());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${alarm.camera.probe-interval}")
|
||||||
|
public void probe() {
|
||||||
|
if (mediaServer.isStreamLive(config.streamPath())) {
|
||||||
|
onStreamAlive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStreamMissing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onStreamAlive() {
|
||||||
|
if (offlineAlreadyReported) {
|
||||||
|
log.info("Flux video retabli sur '{}'", config.streamPath());
|
||||||
|
}
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
offlineAlreadyReported = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onStreamMissing() {
|
||||||
|
consecutiveFailures++;
|
||||||
|
|
||||||
|
if (offlineAlreadyReported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (consecutiveFailures < config.offlineThreshold()) {
|
||||||
|
log.debug("Flux '{}' absent ({}/{} avant signalement)",
|
||||||
|
config.streamPath(), consecutiveFailures, config.offlineThreshold());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("Flux video '{}' absent depuis {} sondes - emission d'un CAMERA_OFFLINE",
|
||||||
|
config.streamPath(), consecutiveFailures);
|
||||||
|
offlineAlreadyReported = true;
|
||||||
|
|
||||||
|
ingestSensorEvent.ingest(new IngestSensorEventCommand(
|
||||||
|
SensorEvent.of(deviceId, SensorEventType.CAMERA_OFFLINE, clock.instant())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package me.belkacemi.iotalarm.infrastructure.media;
|
||||||
|
|
||||||
|
import me.belkacemi.iotalarm.infrastructure.config.AlarmProperties;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADAPTER : interroge l'API de MediaMTX pour savoir si le flux est alimente.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MediaMtxClient implements MediaServerClient {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MediaMtxClient.class);
|
||||||
|
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
public MediaMtxClient(RestClient.Builder builder, AlarmProperties properties) {
|
||||||
|
AlarmProperties.Camera camera = properties.camera();
|
||||||
|
|
||||||
|
// Timeouts serres, poses ici plutot que par un RestClientCustomizer :
|
||||||
|
// ce dernier s'appliquerait a tous les RestClient de l'application.
|
||||||
|
// Les valeurs par defaut de Spring sont infinies - une sonde qui ne
|
||||||
|
// revient jamais immobilise un thread du scheduler, et l'alarme cesse
|
||||||
|
// silencieusement de detecter la perte de camera.
|
||||||
|
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||||
|
requestFactory.setConnectTimeout(Duration.ofSeconds(2));
|
||||||
|
requestFactory.setReadTimeout(Duration.ofSeconds(3));
|
||||||
|
|
||||||
|
this.restClient = builder
|
||||||
|
.baseUrl(camera.apiUrl())
|
||||||
|
.requestFactory(requestFactory)
|
||||||
|
.defaultHeaders(headers ->
|
||||||
|
headers.setBasicAuth(camera.apiUser(), camera.apiPassword()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isStreamLive(String streamPath) {
|
||||||
|
try {
|
||||||
|
PathInfo info = restClient.get()
|
||||||
|
.uri("/v3/paths/get/{path}", streamPath)
|
||||||
|
.retrieve()
|
||||||
|
.body(PathInfo.class);
|
||||||
|
|
||||||
|
return info != null && info.ready();
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
// API injoignable : on ne sait pas si la camera filme, donc on
|
||||||
|
// considere qu'elle ne filme pas. Du point de vue du systeme
|
||||||
|
// d'alarme, "pas d'image" et "serveur media muet" ont exactement
|
||||||
|
// la meme consequence : il n'y a plus de surveillance video.
|
||||||
|
log.debug("API du serveur media injoignable : {}", exception.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reponse partielle de l'API MediaMTX : on n'extrait que ce qui sert. */
|
||||||
|
record PathInfo(String name, boolean ready) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package me.belkacemi.iotalarm.infrastructure.media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interrogation du serveur media : le flux video est-il vivant ?
|
||||||
|
*
|
||||||
|
* <p>Interface volontairement minuscule. Elle n'expose pas MediaMTX mais la
|
||||||
|
* seule question que le systeme d'alarme se pose reellement.</p>
|
||||||
|
*/
|
||||||
|
public interface MediaServerClient {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {@code true} si un publieur alimente ce flux en ce moment
|
||||||
|
*/
|
||||||
|
boolean isStreamLive(String streamPath);
|
||||||
|
}
|
||||||
|
|
@ -18,3 +18,9 @@ spring:
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
me.belkacemi.iotalarm: DEBUG
|
me.belkacemi.iotalarm: DEBUG
|
||||||
|
|
||||||
|
alarm:
|
||||||
|
# Pas de MediaMTX en developpement local : la sonde signalerait une camera
|
||||||
|
# hors ligne toutes les 20 secondes.
|
||||||
|
camera:
|
||||||
|
liveness-probe-enabled: false
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,21 @@ alarm:
|
||||||
partitions: 3
|
partitions: 3
|
||||||
replication-factor: 1
|
replication-factor: 1
|
||||||
|
|
||||||
|
# Sonde de presence du flux video. Traduit une camera muette en evenement
|
||||||
|
# capteur CAMERA_OFFLINE, que le domaine classe CRITICAL - un capteur qui se
|
||||||
|
# tait alors que l'alarme est armee, c'est soit une batterie morte, soit
|
||||||
|
# quelqu'un qui l'a trouve derriere les livres.
|
||||||
|
camera:
|
||||||
|
liveness-probe-enabled: ${CAMERA_PROBE_ENABLED:true}
|
||||||
|
probe-interval: ${CAMERA_PROBE_INTERVAL:20s}
|
||||||
|
# 3 sondes ratees = 60 s reelles sans image. En 4G, le flux tombe
|
||||||
|
# regulierement sans que personne n'ait touche au telephone.
|
||||||
|
offline-threshold: ${CAMERA_OFFLINE_THRESHOLD:3}
|
||||||
|
stream-path: ${CAMERA_STREAM_PATH:biblio}
|
||||||
|
api-url: ${MEDIA_API_URL:http://mediamtx:9997}
|
||||||
|
api-user: probe
|
||||||
|
api-password: ${MEDIA_API_PASSWORD:}
|
||||||
|
|
||||||
simulator:
|
simulator:
|
||||||
enabled: ${SIMULATOR_ENABLED:true}
|
enabled: ${SIMULATOR_ENABLED:true}
|
||||||
interval: ${SIMULATOR_INTERVAL:8s}
|
interval: ${SIMULATOR_INTERVAL:8s}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
package me.belkacemi.iotalarm.infrastructure.media;
|
||||||
|
|
||||||
|
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.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La sonde doit distinguer une coupure 4G d'un capteur reellement neutralise.
|
||||||
|
*
|
||||||
|
* <p>C'est la seule chose qui compte ici : une alerte CRITICAL qui se declenche
|
||||||
|
* a chaque tunnel devient une alerte qu'on apprend a ignorer - y compris le
|
||||||
|
* jour ou elle a raison.</p>
|
||||||
|
*/
|
||||||
|
@DisplayName("CameraLivenessProbe - tolerance aux coupures reseau")
|
||||||
|
class CameraLivenessProbeTest {
|
||||||
|
|
||||||
|
private static final int THRESHOLD = 3;
|
||||||
|
private static final String STREAM = "biblio";
|
||||||
|
|
||||||
|
private MediaServerClient mediaServer;
|
||||||
|
private IngestSensorEventUseCase ingest;
|
||||||
|
private CameraLivenessProbe probe;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mediaServer = mock(MediaServerClient.class);
|
||||||
|
ingest = mock(IngestSensorEventUseCase.class);
|
||||||
|
|
||||||
|
AlarmProperties properties = new AlarmProperties(
|
||||||
|
"studio",
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
Duration.ofSeconds(15),
|
||||||
|
60,
|
||||||
|
new AlarmProperties.Kafka("sensor-events", "sensor-events-dlq", 3, (short) 1),
|
||||||
|
new AlarmProperties.Simulator(false, Duration.ofSeconds(8), "iphone-biblio", 25),
|
||||||
|
new AlarmProperties.Camera(true, Duration.ofSeconds(20), THRESHOLD,
|
||||||
|
STREAM, "http://mediamtx:9997", "probe", "secret"));
|
||||||
|
|
||||||
|
probe = new CameraLivenessProbe(mediaServer, ingest, properties,
|
||||||
|
Clock.fixed(Instant.parse("2026-09-12T22:00:00Z"), ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void streamIs(boolean live) {
|
||||||
|
when(mediaServer.isStreamLive(STREAM)).thenReturn(live);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("flux present : aucun evenement")
|
||||||
|
void aliveStreamEmitsNothing() {
|
||||||
|
streamIs(true);
|
||||||
|
|
||||||
|
probe.probe();
|
||||||
|
probe.probe();
|
||||||
|
|
||||||
|
verify(ingest, never()).ingest(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("coupure breve sous le seuil : aucune alerte")
|
||||||
|
void shortOutageStaysSilent() {
|
||||||
|
streamIs(false);
|
||||||
|
|
||||||
|
probe.probe();
|
||||||
|
probe.probe(); // 2 sondes ratees, seuil a 3
|
||||||
|
|
||||||
|
verify(ingest, never()).ingest(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("seuil atteint : un CAMERA_OFFLINE est emis")
|
||||||
|
void sustainedOutageRaisesCameraOffline() {
|
||||||
|
streamIs(false);
|
||||||
|
|
||||||
|
probe.probe();
|
||||||
|
probe.probe();
|
||||||
|
probe.probe();
|
||||||
|
|
||||||
|
ArgumentCaptor<IngestSensorEventCommand> captor =
|
||||||
|
ArgumentCaptor.forClass(IngestSensorEventCommand.class);
|
||||||
|
verify(ingest).ingest(captor.capture());
|
||||||
|
|
||||||
|
assertThat(captor.getValue().event().type()).isEqualTo(SensorEventType.CAMERA_OFFLINE);
|
||||||
|
assertThat(captor.getValue().event().deviceId().value()).isEqualTo("iphone-biblio");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("coupure prolongee : un seul evenement, pas un par sonde")
|
||||||
|
void outageIsReportedOnlyOnce() {
|
||||||
|
streamIs(false);
|
||||||
|
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
probe.probe();
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(ingest, times(1)).ingest(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("apres retour puis nouvelle coupure, une nouvelle alerte est possible")
|
||||||
|
void counterResetsWhenStreamComesBack() {
|
||||||
|
streamIs(false);
|
||||||
|
probe.probe();
|
||||||
|
probe.probe();
|
||||||
|
probe.probe(); // 1re alerte
|
||||||
|
|
||||||
|
streamIs(true);
|
||||||
|
probe.probe(); // retour du flux : compteur remis a zero
|
||||||
|
|
||||||
|
streamIs(false);
|
||||||
|
probe.probe();
|
||||||
|
probe.probe();
|
||||||
|
probe.probe(); // 2e alerte
|
||||||
|
|
||||||
|
verify(ingest, times(2)).ingest(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("un retour avant le seuil annule le comptage")
|
||||||
|
void recoveryBeforeThresholdCancelsEverything() {
|
||||||
|
streamIs(false);
|
||||||
|
probe.probe();
|
||||||
|
probe.probe(); // 2 ratees
|
||||||
|
|
||||||
|
streamIs(true);
|
||||||
|
probe.probe(); // le flux revient
|
||||||
|
|
||||||
|
streamIs(false);
|
||||||
|
probe.probe();
|
||||||
|
probe.probe(); // 2 ratees a nouveau, pas 4
|
||||||
|
|
||||||
|
verify(ingest, never()).ingest(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue