Signal Reader — comment Jarvis prend le pouls de la maison
Dans les articles précédents, j’ai posé les deux premières pierres : l’index.yaml qui inventorie la maison, et la philosophie GEMMA qui explique pourquoi on ne câble pas des capteurs directement sur des actionneurs. Il est temps de passer au code.
signal_reader.py est l’étape 2 du moteur. C’est elle qui transforme les entités Home Assistant en signaux exploitables.
La place de signal_reader dans l’architecture
Avant d’ouvrir le fichier, un rappel du flux complet :
index.yaml
↓
signal_reader.py → sensor.jarvis_signals (signaux simples)
device_map_reader.py → sensor.jarvis_vectors (capteurs complexes)
↓
moteur_gemma.py → input_text.gemma_etat_courant
↓
comportements.yaml → actionneurs
signal_reader.py couvre les devices simples : une entité Home Assistant = une sortie normalisée True/False (ou une valeur brute pour les sensors). Pas de calcul, pas de logique dérivée — juste une lecture propre.
Les capteurs complexes comme le LD2450 (qui produit des coordonnées X/Y, des vitesses, des zones logicielles) sont traités par device_map_reader.py. C’est la séparation de responsabilités introduite lors de la session du 19 mai : chaque lecteur fait une seule chose, bien.
Ce que fait signal_reader — en trois lignes
Au démarrage, elle :
- Lit
index.yamlpour savoir quelles entités surveiller - S’abonne aux changements d’état de chacune via AppDaemon
- Écrit un snapshot dans
sensor.jarvis_signals— une entité HA qui contient l’état de toute la maison en un seul attribut JSON
À chaque changement d’état, le snapshot est mis à jour et réécrit. moteur_gemma.py le lit pour calculer l’état humain courant.
Pourquoi AppDaemon et pas des automatisations YAML ?
Une automatisation YAML réagit à un événement isolé. Elle ne sait pas ce que font les autres. signal_reader.py est une application Python qui tourne en continu, avec un état en mémoire (self.snapshot), capable de corréler n’importe quelle combinaison de signaux.
C’est la différence entre un panneau de relais et un automate programmable — la même analogie que dans le premier article, appliquée ici concrètement.
Le code, section par section
Initialisation
def initialize(self):
self.index_path = self.args.get("index_path", "/config/index.yaml")
self.index = self._load_index()
self.snapshot = {}
self.presence = self.get_app("presence")
self._subscribe_all()
self._subscribe_presence()
self._take_snapshot()
initialize() est le point d’entrée AppDaemon — l’équivalent de __init__ mais dans le cycle de vie du framework. Trois choses se passent dans l’ordre : chargement de l’index, abonnements aux entités, snapshot initial.
self.get_app("presence") est notable : on récupère ici une brique voisine AppDaemon, presence.py, pour déléguer la gestion des personnes. Chaque application AppDaemon peut appeler les méthodes publiques des autres — c’est le mécanisme d’injection de dépendances du système.
L’itérateur d’entités
def _iter_entities(self):
pieces = self.index.get("pieces", {})
for piece, contenu in pieces.items():
for role in ("recepteurs", "actionneurs", "appareils"):
for item in contenu.get(role, []):
yield piece, role, item
Cette fonction traverse l’index.yaml et génère un tuple (pièce, rôle, item) pour chaque entité déclarée. C’est le seul endroit du code qui connaît la structure de l’index — tout ce qui suit consomme ces tuples sans se soucier de comment ils ont été produits.
Les abonnements
def _subscribe_all(self):
for piece, role, item in self._iter_entities():
entity = item.get("entity")
if not entity:
continue
self.listen_state(
self._on_state_change,
entity,
piece=piece,
role=role,
item=item,
)
listen_state est la méthode AppDaemon qui s’abonne aux changements d’état d’une entité HA. Les kwargs supplémentaires (piece, role, item) sont passés directement au callback — c’est le moyen propre d’éviter les closures ou les variables globales.
À chaque changement d’état, _on_state_change est appelé avec le contexte complet :
def _on_state_change(self, entity, attribute, old, new, kwargs):
piece = kwargs["piece"]
item = kwargs["item"]
self._update_snapshot(piece, item, new)
self._write_snapshot()
Deux lignes. Le callback ne fait qu’une chose : mettre à jour le snapshot et l’écrire.
La normalisation
C’est ici que les états bruts HA deviennent des valeurs exploitables par GEMMA :
def _normalize(self, kind, entity, raw):
if raw in (None, "unavailable", "unknown"):
return None
if kind in ("binary_sensor", "switch"):
return raw == "on"
if kind == "light":
return raw == "on"
if kind == "media_player":
return raw # "playing", "paused", "idle", "off"
if kind == "sensor":
try:
return float(raw)
except (ValueError, TypeError):
return raw
return raw
Quelques points :
binary_sensoretswitch→True/False. Le moteur de scoring n’a jamais à comparer des strings"on"/"off".media_player→ la string est conservée ("playing","paused"…).moteur_gemma.pycompare directement ces valeurs danspoids.yamlvia le champvaleur_active.sensorlux →floatbrut. La conversion en lux utile avec hystérésis est gérée pardevice_map_reader.pypour les capteurs extérieurs, etget_lux()expose le capteur intérieur directement.Nonesi l’entité est indisponible — le moteur ignore les signauxNoneau scoring.
Le snapshot
Chaque signal stocké dans self.snapshot a la même structure :
self.snapshot[signal_id] = {
"entity": "binary_sensor.0xa4c138...",
"piece": "Salon",
"type": "binary_sensor",
"raw": "on",
"value": True,
"updated_at": "2026-05-19T21:43:02",
}
raw conserve la valeur brute HA pour le débogage. value est la valeur normalisée que GEMMA consomme. updated_at permet de détecter les signaux qui ne se sont pas mis à jour depuis longtemps — utile pour la robustesse du scoring.
L’écriture dans HA
def _write_snapshot(self):
self.set_state(
"sensor.jarvis_signals",
state="ok",
attributes=self.snapshot,
)
Le snapshot est écrit dans les attributs d’une entité virtuelle HA — pas dans un fichier disque. Cela rend le snapshot visible directement dans l’interface HA (Développeurs → États), observable avec des templates, et consommable par n’importe quelle autre application AppDaemon via get_state("sensor.jarvis_signals", attribute="all").
La présence humaine
La présence est un cas particulier : elle n’est pas dans index.yaml (les personnes ne sont pas des entités physiques de la maison), mais elle est indispensable au scoring.
signal_reader.py délègue à la brique presence.py via self.presence.persons. Le résultat est injecté dans le snapshot sous deux formes :
maison_occupee→ signal global booléenpresence_{nom}→ signal individuel par personne déclarée
self.snapshot["maison_occupee"] = {
"entity": "presence",
"piece": "global",
"type": "presence",
"value": len(presents) > 0,
...
}
moteur_gemma.py peut ainsi discriminer l’état ABSENT (maison vide) avant même de regarder les capteurs physiques.
L’API publique
Trois méthodes exposées aux briques voisines :
def get_snapshot(self):
"""Retourne le snapshot complet en mémoire."""
return self.snapshot.copy()
def get_signal(self, signal_id):
"""Retourne la valeur normalisée d'un signal par son id."""
entry = self.snapshot.get(signal_id)
return entry["value"] if entry else None
def get_lux(self):
"""Retourne la luminosité ambiante en lux."""
entry = self.snapshot.get("Capteur luminosité")
if entry and entry["value"] is not None:
return float(entry["value"])
return None
moteur_gemma.py appelle get_snapshot() pour construire sa table de signaux. get_lux() est un point d’entrée dédié pour la régulation de l’éclairage — un exemple d’API explicite plutôt que de laisser les consommateurs fouiller dans le snapshot.
Ce que signal_reader ne fait pas
C’est aussi important à dire. signal_reader.py ne :
- calcule pas de valeurs dérivées (seuils, hystérésis, états calculés) — c’est
device_map_reader.py - gère pas les timeouts ou la stabilisation temporelle — c’est ESPHome pour les LD2450
- prend pas de décision sur les actionneurs — c’est
moteur_gemma.py - expose pas les vecteurs bruts (X/Y, vitesse, direction) — ils passent par
device_map_reader.py
Cette délimitation stricte est ce qui rend le système lisible et maintenable. Chaque fichier a une responsabilité, une seule.
Dans le prochain article : device_map_reader.py — comment les capteurs complexes comme le LD2450 et le capteur extérieur sont traités pour produire des signaux binaires exploitables, avec la gestion des seuils et de l’hystérésis.