Daemon C++ — dossier complet
Cette page fusionne les anciens contenus:
- survol du daemon,
- reference detaillee du code,
- code complet commente.
Survol du daemon
Daemon C++
Le daemon (nodes/agent, C++17, namespace oa) est l'automate proprement dit. Il est bâti
autour de gestionnaires spécialisés, câblés par la classe Node.
Arborescence
nodes/agent/
├── CMakeLists.txt
├── include/
│ ├── config.hpp # chargement de node.json
│ ├── gpio_manager.hpp # accès libgpiod
│ ├── grpc_server.hpp # daemon gRPC :9080 (JSON, activable)
│ ├── local_api.hpp # serveur HTTP :8080 (activable)
│ ├── local_db.hpp # persistance SQLite
│ ├── log.hpp # journalisation
│ ├── meter_manager.hpp # compteurs
│ ├── mqtt_client.hpp # client MQTT
│ ├── node.hpp # orchestrateur
│ ├── relay_manager.hpp # relais
│ ├── scheduler.hpp # planification cron
│ ├── secure_onboarding.hpp # vérification X.509 + autorisation
│ ├── valve_manager.hpp # vannes
│ └── watchdog.hpp # watchdog matériel
└── src/ # implémentations correspondantes + main.cpp
GpioManager
Encapsule libgpiod et l'accès à /dev/gpiochip0. Il fournit une abstraction pour
réserver des lignes en entrée ou en sortie et lire/écrire leur niveau, isolant le reste du
code des détails du noyau.
RelayManager
- Pilote des relais actifs à l'état bas (
active-low) : écrire0ferme le relais. - Persiste chaque changement d'état dans
LocalDb, de sorte que la position est restaurée après un redémarrage. - Actions supportées :
on,off,toggle.
ValveManager
- Gère des électrovannes avec une fermeture de sécurité automatique : une vanne ouverte se referme seule après un délai (par défaut 300 s), configurable par commande.
- Actions :
open,close, éventuellement assorties d'une durée en secondes.
MeterManager
- Compte les impulsions (compteurs d'eau, d'énergie…).
- Cumule la valeur et la persiste pour survivre aux redémarrages.
Scheduler
- Déclenche des actions selon une règle cron journalière (minute + heure).
- S'appuie sur la surface de commande de
Nodepour agir sur relais et vannes.
Watchdog
- Ouvre
/dev/watchdoget le rearme périodiquement (~15 s). - Si la boucle principale se bloque, le watchdog matériel redémarre le node, garantissant la disponibilité.
LocalDb
- Base SQLite stockée dans
/var/lib/leukos/node.db. - Persiste l'état des relais et les valeurs des compteurs.
- Restaurée au démarrage par les gestionnaires concernés.
Boucle principale (main.cpp)
Le point d'entrée charge la configuration, construit un Node, appelle start() puis
run() (bloquant). La boucle poll à poll_interval_ms (250 ms) : lecture, logique,
persistance et publication d'état.
LocalApi (HTTP)
- Serveur HTTP/1.1 embarqué (sockets POSIX),
:8080par défaut. - Activable via
api.enableddansnode.json(mécanisme conservé, simplement désactivable). - Endpoints :
GET /status,POST /relay/<id>/<on|off|toggle>,POST /valve/<id>/<open|close>[/seconds].
Onboarding réseau sécurisé (ESP32 Ethernet)
Le daemon implémente le flux sécurisé décrit dans docs/Network.md :
- ingestion d'un device découvert,
- vérification cryptographique du certificat X.509 contre la CA locale,
- contrôle de liste d'autorisation (
allowed_device_ids), - activation manuelle par affectation de pièce,
- persistance SQLite des états
discovered|verified|rejected|active.
Endpoints additionnels :
POST /secure/discover(payload JSON de découverte + certificat PEM)GET /secure/pending(devices vérifiés mais non activés)GET /secure/devices(registre complet)POST /secure/activate/<device_id>/<room>(activation explicite)
GrpcServer (daemon gRPC)
- Daemon gRPC embarqué tournant sur son propre thread,
:9080par défaut. - Activable via
grpc.enabled(désactivé par défaut). - Utilise le service générique de gRPC avec des charges utiles JSON : aucun
protoc/protobuf requis sur la cible, cohérent avec la convention JSON-over-gRPC du central (Saturn↔dnsdhcp). - Service
leukos.AgentService:Health,Status,Relay({id, action}),Valve({id, action, seconds}). - Saturn s'y connecte comme client (
NODE_GRPC_ENABLED,NODE_GRPC_PORT), avec repli automatique sur l'API HTTP.
Dépendances de compilation
libgpiod, jansson (JSON), sqlite, mosquitto, openssl, grpc (grpc++).
Installation systemd (hôte Linux appserver)
Build et packaging .deb local depuis les sources de l'agent :
cd /var/workspaces/supervisor/apps/leukOS
sudo apt-get update
sudo apt-get install -y \
cmake build-essential pkg-config \
libjansson-dev libsqlite3-dev libgpiod-dev libmosquitto-dev \
libgrpc++-dev protobuf-compiler-grpc
cd nodes/agent
cmake -S . -B build-pack -DLEUKOS_AGENT_ENABLE_SYSTEMD_PACKAGING=ON
cmake --build build-pack -j"$(nproc)"
cpack -G DEB --config build-pack/CPackConfig.cmake
Installation du paquet (avec résolution automatique de toutes les dépendances runtime) :
sudo apt-get update
sudo apt-get install -y /var/workspaces/supervisor/apps/leukOS/nodes/agent/leukos-agent_1.0.0_amd64.deb
# postinst bootstrap automatiquement :
# - /etc/leukos/node.json depuis node.json.example si absent
# - /var/lib/leukos
# - activation/restart de leukos-agent et leukos-agent-heartbeat.timer
# - démarrage de mosquitto si mqtt.host est local (127.0.0.1/localhost/::1)
# ajuster ensuite /etc/leukos/node.json selon la machine
# (id node, ports API/gRPC, plugins bridge, etc.)
sudo systemctl enable --now leukos-agent leukos-agent-heartbeat.timer
systemctl status leukos-agent --no-pager -l
systemctl status leukos-agent-heartbeat.timer --no-pager -l
Sur appserver, des conflits de ports peuvent exister avec l'infra centrale.
Exemple de config runtime compatible (/etc/leukos/node.json) :
{
"node": {"id": "appserver-agent", "name": "LeukOS Agent Appserver", "site": "appserver", "poll_interval_ms": 250},
"database": {"path": "/var/lib/leukos/node.db"},
"mqtt": {"host": "127.0.0.1", "port": 1883, "base_topic": "openautomation", "keepalive_s": 30},
"api": {"enabled": true, "bind": "0.0.0.0", "port": 18080},
"grpc": {"enabled": true, "bind": "0.0.0.0", "port": 19080},
"watchdog": {"enabled": true, "timeout_s": 15},
"gpio": {"enabled": false, "chip": "gpiochip0"},
"device_bridge": {
"enabled": true,
"probe_interval_s": 15,
"plugins": [
{"name": "arduino_atmega2560", "enabled": true, "transport": "serial", "port": "/dev/ttyUSB0", "baud": 115200, "heartbeat_cmd": "", "heartbeat_ack": ""},
{"name": "esp32", "enabled": false, "transport": "serial", "port": "/dev/ttyUSB0", "baud": 115200, "heartbeat_cmd": "PING\\n", "heartbeat_ack": "PONG"},
{"name": "esp8266", "enabled": false, "transport": "serial", "port": "/dev/ttyUSB1", "baud": 115200, "heartbeat_cmd": "PING\\n", "heartbeat_ack": "PONG"},
{"name": "stm32", "enabled": false, "transport": "serial", "port": "/dev/ttyACM1", "baud": 115200, "heartbeat_cmd": "PING\\n", "heartbeat_ack": "PONG"},
{"name": "rp2040", "enabled": false, "transport": "serial", "port": "/dev/ttyACM2", "baud": 115200, "heartbeat_cmd": "PING\\n", "heartbeat_ack": "PONG"}
]
},
"secure_network": {
"enabled": true,
"require_activation": true,
"require_crl": false,
"discovery_ttl_s": 300,
"ca_cert_path": "/etc/leukos/pki/ca.crt",
"crl_path": "/etc/leukos/pki/crl.pem",
"mqtt_topic_prefix": "home",
"allowed_device_ids": ["HOME-CTRL-000001", "HOME-CTRL-000002"]
},
"relays": [], "valves": [], "meters": [], "schedules": []
}
Ce profil correspond au cas où un seul port série (/dev/ttyUSB0) est présent sur
la machine hôte, avec ATmega2560 branché dessus.
Heartbeat vers Mercure
Le service/timer leukos-agent-heartbeat publie un état synthétique vers
POST /health/state de Mercure. La flotte visible dans Venus (Network → Flotte)
vient uniquement de Mercure.
Le même heartbeat publie aussi un log structuré vers POST /logs (Mercure),
qui est ensuite routé sur Kafka (leukos-node-logs) puis indexé dans Elasticsearch.
Ce log inclut l'état mqtt/bridge, le statut synthétique (up|degraded|down) et
les plugins bridge actifs dans meta.
Règle de statut effective côté heartbeat :
downsi le service systemdleukos-agentn'est pasactive.upsi le service estactiveet qu'au moins une liaison estup(mqttoubridge).degradedsinon.
Le timer leukos-agent-heartbeat.timer journalise périodiquement l'état des deux
liaisons dans journald (mqtt côté central + bridge côté MCU/plugins) :
sudo systemctl start leukos-agent-heartbeat.service
sudo journalctl -t leukos-agent-heartbeat -n 20 --no-pager
Reference detaillee du code
Daemon C++ — reference detaillee du code
Cette page documente l'intégralité du code de l'agent (nodes/agent), fichier
par fichier, classe par classe et fonction par fonction. Elle complète le
survol du daemon, qui reste volontairement synthetique.
Pour lire le source complet ligne par ligne directement dans la doc, voir la section "Code complet commente" plus bas.
- Langage : C++17, namespace
oa(« OpenAutomation », nom historique). - Binaire :
leukos-node(le paquet et les services s'appellentleukos-agent). - Rôle : automate de terrain autonome pilotant relais, vannes et compteurs via GPIO, exposant son état via HTTP/gRPC/MQTT, et persistant tout localement en SQLite.
- Dépendances natives :
libgpiod,jansson(JSON),sqlite3,libmosquitto(MQTT),grpc++, plus la libc/POSIX (sockets, termios, watchdog).
Vue d'ensemble de l'architecture
La classe Node est l'orchestrateur : elle possède tous les gestionnaires et les
câble entre eux par références. Les trois surfaces de commande (HTTP LocalApi,
gRPC GrpcServer, MQTT MqttClient) ainsi que le Scheduler convergent vers deux
méthodes uniques : Node::cmd_relay() et Node::cmd_valve().
Modèle de threads :
- Le thread principal exécute la boucle de contrôle (
Node::run()) : polling des compteurs, auto-fermeture des vannes, cron, sonde bridge, watchdog. LocalApietGrpcServertournent chacun sur leur propre thread.MqttClientdélègue à la boucle réseau interne de mosquitto (loop_start), dont les callbacks s'exécutent sur un thread mosquitto.- La cohérence est assurée par des
std::mutexdans chaque gestionnaire partagé (RelayManager,ValveManager,MeterManager,LocalDb,GpioManager,DeviceBridgeManager).
main.cpp — point d'entrée
Rôle : parser les arguments, charger la config, installer les gestionnaires de signaux, démarrer le node et lancer la boucle bloquante.
namespace {
oa::Node* g_node = nullptr;
void on_signal(int) { if (g_node) g_node->stop(); }
}
g_nodeest un pointeur global vers l'instance unique, nécessaire parce que les handlers de signaux POSIX ne prennent pas de contexte utilisateur.on_signal()se contente d'appelerNode::stop()(async-signal : il ne fait qu'écrire un booléen atomique — voirNode::run()).
Déroulé de main() :
- Valeur par défaut
config_path = "/etc/leukos/node.json". - Boucle d'arguments :
--config/-c <path>(surcharge le chemin),--help/-h(afficheusage()et sort 0), tout autre argument → erreur (usage()+ code 2). Config::load(); en cas d'échec : logERRORet code 1.- Construction du
Node, publication dansg_node. - Installation des signaux :
SIGINTetSIGTERM→on_signal;SIGPIPEest ignoré (SIG_IGN) pour éviter que l'écriture sur un socket ou un port série fermé ne tue le process. node.start(); échec → logERROR+ code 1.node.run()(bloquant jusqu'austop()), puis retour 0.
config.hpp / config.cpp — configuration
Structures (config.hpp)
Chaque section de node.json a sa structure, avec des valeurs par défaut sûres
directement dans les membres :
| Struct | Champs clés | Défaut notable |
|---|---|---|
RelayConfig | id, line, active_low | active_low = true |
ValveConfig | id, relay, default_seconds | default_seconds = 300 |
MeterConfig | id, line, unit, pulses_per_unit | pulses_per_unit = 1.0 |
ScheduleConfig | id, target, action, cron, seconds | seconds = 0 |
DevicePluginConfig | name, enabled, transport, port, baud, heartbeat_cmd, heartbeat_ack | baud = 115200, heartbeat = PING\n/PONG |
La structure agrégée Config regroupe le node (id/nom/site/poll_interval_ms
= 250), la base (db_path), le MQTT, l'API HTTP (activée par défaut, :8080), le
gRPC (désactivé par défaut, :9080), le watchdog (activé, 15 s), le GPIO
(gpiochip0), le device bridge, et les vecteurs de relais/vannes/compteurs/planifications.
static bool Config::load(path, out, error) : charge le JSON et retourne false
uniquement sur erreur fatale (fichier illisible/non parsable).
Chargement (config.cpp)
Helpers anonymes typés et tolérants (retournent une valeur par défaut si la clé est absente ou du mauvais type) :
jstr(obj, key, def)— chaîne.jint(obj, key, def)— entier, accepte aussi un réel (troncature).jreal(obj, key, def)— réel, accepte aussi un entier.jbool(obj, key, def)— booléen.
builtin_plugins() fournit la liste par défaut de 5 plugins MCU
(arduino_atmega2560 activé, esp32/esp8266/stm32/rp2040 désactivés) : si aucun plugin
n'est fourni par la config, cette liste est utilisée.
Config::load() :
- Pré-remplit
device_pluginsavecbuiltin_plugins()si vide. json_load_file(); erreur → message avecjerr.textet numéro de ligne,false.- Lecture section par section (
node,database,mqtt,api,grpc,watchdog,gpio,device_bridge). Chaque section n'écrase un champ que si elle est présente, grâce aux helpers et à leurs défauts. - Pour
device_bridge.plugins: si un tableau est fourni, il remplace entièrement la liste par défaut (clear()puisjson_array_foreach), et un plugin sansnameest ignoré. - Les tableaux
relays,valves,meters,schedulessont parcourus de même ; toute entrée sansidest ignorée (garde-fou contre les entrées vides). json_decref(root)puisreturn true.
:::note Gestion mémoire jansson
json_object_get() renvoie une référence empruntée (pas d'incrément de compteur),
donc seul le root obtenu de json_load_file est libéré par json_decref.
:::
node.hpp / node.cpp — l'orchestrateur
Déclaration (node.hpp)
Node possède par valeur tous les sous-systèmes (LocalDb, GpioManager,
RelayManager, ValveManager, MeterManager, Watchdog, Scheduler,
MqttClient, DeviceBridgeManager, LocalApi, GrpcServer) et un
std::atomic<bool> running_.
Surface publique :
start(error)/run()/stop()— cycle de vie.- Accès aux gestionnaires (
relays(),valves(),meters(),db()). cmd_relay(id, action)etcmd_valve(id, action, seconds)— surface de commande partagée par l'API, le gRPC, le MQTT et le scheduler.state_json()— instantané complet du node en JSON.
Construction
L'ordre d'initialisation de la liste d'initialisation respecte les dépendances par
référence : relays_(gpio_, db_), valves_(relays_), meters_(gpio_, db_),
scheduler_(*this), mqtt_(*this), api_(*this), grpc_(*this). Les sous-systèmes
reçoivent donc des références vers leurs collaborateurs, tous membres du même Node.
start() — séquence de démarrage
- Base :
db_.open()— échec ⇒ fatal (return false). - GPIO (si
gpio_enabled) :gpio_.open(), puisrelays_.init()etmeters_.init()(échecs fatals). Si GPIO désactivé, unWARNprévient que les configs relais/compteurs seront ignorées. valves_.init(),scheduler_.init(),watchdog_.start()(non fatals).- Device bridge (si activé) :
bridge_.start(); un échec est seulement loggé (WARN), pas fatal. - MQTT :
mqtt_.start(); échec non fatal (le node fonctionne hors-ligne). - API HTTP (si
api_enabled) :api_.start()— échec fatal (port occupé par exemple). - gRPC (si
grpc_enabled) :grpc_.start(); échec non fatal. - Journalise l'événement
starten base et logINFO.
La philosophie : les liaisons réseau facultatives ne bloquent jamais le démarrage, seuls la base, le GPIO demandé et l'API HTTP demandée sont critiques.
run() — boucle de contrôle déterministe
while (running_.load()) {
meters_.tick(); // détection de fronts sur les entrées
valves_.tick(); // auto-fermeture des vannes expirées
scheduler_.tick(); // déclenchement cron
bridge_.tick(); // sonde MCU (throttlée en interne)
watchdog_.feed(); // réarme le watchdog matériel
// ... publication d'état toutes les 2 s, log connexions toutes les 30 s
std::this_thread::sleep_for(period); // period = poll_interval_ms
}
- Publication MQTT d'état toutes les 2 s (
publish_state()). - Journal de connexions toutes les 30 s : chaîne
mqtt=up/down <résumé bridge>, loggée et enregistrée en base (connections/heartbeat). running_est atomique : c'est ce que le handler de signal bascule pour sortir.
stop()
Bascule running_ à false puis arrête proprement, dans l'ordre, le bridge, le
gRPC, l'API, le MQTT et le watchdog, journalise l'événement stop. Chaque sous-système
a un stop()/destructeur idempotent.
cmd_relay() / cmd_valve()
Simples routeurs d'action → gestionnaire :
cmd_relay:"on"→set(true),"off"→set(false),"toggle"→toggle(), sinonfalse.cmd_valve:"open"→open(id, seconds),"close"→close(id), sinonfalse.
state_json()
Construit avec jansson un objet contenant :
- Métadonnées :
node,name,site,ts(epoch secondes). connections:mqtt.status(up/down) + injection du bridge viabridge_.append_json().relays: mapid → bool.valves: mapid → bool(ouverte/fermée).meters: mapid → {pulses, value, unit}.
Sérialisé en JSON_COMPACT. Les const_cast locaux permettent d'appeler les
accesseurs (non-const car ils prennent un mutex) depuis une méthode const.
log.hpp — journalisation
En-tête header-only. enum class LogLevel { Debug, Info, Warn, Error } et
log_write(level, msg) qui écrit sur stderr une ligne horodatée ISO-8601 local :
[2026-08-11T14:03:21] INFO leukos-node: <message>
Écrire sur stderr permet à systemd/journald de capturer nativement les logs.
Macros pratiques : OA_LOG_DEBUG/INFO/WARN/ERROR.
local_db.hpp / local_db.cpp — persistance SQLite
Fin wrapper autour de sqlite3, protégé par un std::mutex.
open(path, error): ouvre la base, active WAL (PRAGMA journal_mode=WAL) pour de meilleures performances/robustesse, et crée trois tables si absentes :events(id, ts, source, kind, detail)— journal horodaté (tspar défaut =strftime('%s','now')).relay_state(id PRIMARY KEY, on_state)— dernier état connu des relais.meter_count(id PRIMARY KEY, count)— cumul d'impulsions par compteur.
exec(sql): helper interne pour les DDL/PRAGMA, loggue unWARNen cas d'erreur.log_event(source, kind, detail):INSERTpréparé et paramétré (SQLITE_TRANSIENTpour que SQLite copie les chaînes).save_relay_state/load_relay_state: upsert (ON CONFLICT ... DO UPDATE) et lecture.load_*renvoiefalsesi aucune ligne (le manager garde alors son défaut).save_meter_count/load_meter_count: idem pour les compteurs (int64).
Toutes les requêtes utilisent sqlite3_prepare_v2 + bind + finalize : aucune
concaténation de SQL, donc pas d'injection. Chaque méthode vérifie db_ != nullptr.
gpio_manager.hpp / gpio_manager.cpp — accès GPIO (libgpiod)
Encapsule la puce libgpiod et gère un ensemble de lignes réservées, mélangeant
librement entrées et sorties. Le code supporte les deux ABIs de libgpiod via la
macro LEUKOS_GPIOD_V2 (définie par le CMake selon la version détectée).
struct Line(privée) : détient soit ungpiod_line_request*(v2), soit ungpiod_line*(v1), avecoffsetetis_output.- Destructeur : libère toutes les requêtes (v2) puis
gpiod_chip_close(). open(chip_name, error): préfixe/dev/si nécessaire, ouvre la puce.add_output(tag, line, initial_high): réserve une ligne en sortie avec une valeur initiale. En v2, allouesettings/line_config/request_config, positionne direction OUTPUT et la valeur,consumer = "leukos-node". En v1,gpiod_line_request_output.add_input(tag, line): réserve une ligne en entrée avec bias pull-up (GPIOD_LINE_BIAS_PULL_UP/..._FLAG_BIAS_PULL_UP).set_output(tag, high)/get_input(tag, high): écrit/lit un niveau physique (la logique active-low est gérée plus haut parRelayManager).
Toutes les mutations sont protégées par mtx_. Les erreurs sont loggées et renvoient
false plutôt que de lever.
relay_manager.hpp / relay_manager.cpp — relais
Fait le pont entre relais logiques et sorties GPIO physiques, en gérant le câblage active-low et la persistance.
init(relays, error): pour chaque relais, restaure l'état depuisLocalDb(défautoff), calcule le niveau physique initial (off_high = active_low,initial_high = on ? !off_high : off_high) et réserve la ligne en sortie. Échec de réservation ⇒errorrenseignée +false.set(id, on): convertit logique→physique (high = active_low ? !on : on), écrit la sortie, met à jour l'état mémoire, persiste (save_relay_state), journalise l'événement et logINFO.toggle(id): lit l'état courant puisset(!cur).get(id, on): lecture protégée depuis la mapstate_.ids(): liste triée des identifiants (issue de la map ordonnée).
Le point clé est le mapping active-low : un relais active_low est « off » quand
la ligne est HIGH, ce qui correspond aux cartes relais classiques à optocoupleur.
valve_manager.hpp / valve_manager.cpp — vannes (fail-safe)
Pilote des électrovannes à travers des relais, avec fermeture automatique de sécurité (irrigation qui ne peut pas rester ouverte indéfiniment).
init(valves): indexe lesValveConfigpar id.open(id, seconds): résout le relais associé et la durée (seconds > 0 ? seconds : default_seconds), commande le relais àtrue, puis enregistre une deadlinenow + durationdansdeadlines_. LogINFO.close(id): commande le relais àfalseet retire la deadline.is_open(id): vrai si une deadline existe pour cet id.tick(): appelé depuis la boucle principale ; ferme toutes les vannes dont la deadline est atteinte. Le balayage des expirées se fait sous verrou, mais lesclose()sont exécutés hors du verrou pour éviter tout inter-blocage avecRelayManager.ids(): liste des identifiants.
C'est le sous-système garantissant qu'une vanne ouverte se referme même si le superviseur central disparaît.
meter_manager.hpp / meter_manager.cpp — compteurs à impulsions
Compte les impulsions (eau, énergie…) par détection de front montant pendant le polling, et convertit en unités d'ingénierie.
struct State { bool last; long long count; }— dernier niveau et cumul.init(meters, error): réserve chaque ligne en entrée, restaure le compteur depuis la base (load_meter_count) et lit le niveau initial.tick(): pour chaque compteur, lit l'entrée ; sur front montant (level && !last) incrémente le cumul et le persiste immédiatement, puis mémorise le niveau. C'est du polling (pas d'interruption), suffisant pour des compteurs à ampoule reed/sortie optique aux fréquences typiques.pulses(id): cumul brut.value(id):count / pulses_per_unit(avec gardeppu > 0).unit(id): unité configurée.ids(): liste des identifiants.
:::caution Fréquence de comptage
La détection par polling est limitée par poll_interval_ms (250 ms par défaut) :
elle convient aux compteurs lents. Pour des trains d'impulsions rapides, il faudrait
des interruptions matérielles (hors périmètre actuel).
:::
scheduler.hpp / scheduler.cpp — planification cron
Évalue des entrées cron journalières simplifiées et déclenche des actions.
struct Entry: la config,minute/hourextraits, etlast_fired_min/last_fired_daypour l'anti-rebond.parse_cron(cron, minute, hour): parse"min hour dom mon dow"mais n'honore que minute et heure ;"*"signifie « tout » (-1), toute autre valeur est fixe.init(schedules): construit les entrées, ignore (avecWARN) les crons invalides, log le nombre d'entrées chargées.tick(): lit l'heure locale (localtime_r), pour chaque entrée vérifie que minute et heure correspondent (ou-1), tire au plus une fois par minute correspondante (comparaisonlast_fired_min/last_fired_dayavectm_yday), puis route l'action :open/close→cmd_valve, sinon →cmd_relay.
L'anti-rebond via tm_yday évite de re-déclencher plusieurs fois dans la même minute
tout en autorisant le déclenchement le lendemain à la même heure.
watchdog.hpp / watchdog.cpp — watchdog matériel
Réarme le watchdog Linux (/dev/watchdog) pour garantir un redémarrage si le
daemon se fige ; dégrade en no-op si le device est absent.
start(enabled, timeout_s): si activé, ouvre/dev/watchdogen écriture. Absent ⇒WARNet fonctionnement « software heartbeat » (no-op réel). Si ouvert, tenteWDIOC_SETTIMEOUTviaioctl.feed(): écrit un octet\0pour réarmer (appelé à chaque tour de boucle).stop(): écrit le magic-close'V'avantclose()pour demander au noyau de ne pas redémarrer lors d'un arrêt propre, puis ferme le descripteur.
device_bridge.hpp / device_bridge.cpp — pont MCU (série)
Sonde des microcontrôleurs (ESP32/STM32/RP2040 et autres) reliés en série et suit leur état de connexion pour l'exposer dans les logs et le statut du node.
struct DevicePluginStatus: image runtime d'un plugin (nom, transport, port, baud, activé, connecté,last_seen_ts,last_error, heartbeat cmd/ack).start(enabled, probe_interval_s, plugins, error): copie les configs en statuts, fixe l'intervalle (défaut 15 s), armenext_probe_à maintenant. Aucun plugin ⇒error.stop(): basculeenabled_àfalse.tick(): throttlé — ne fait rien tant quenow < next_probe_; sinon sonde tous les plugins et replanifienext_probe_.probe_plugin(plugin)(cœur) :- Ignore les plugins désactivés.
stat()du port : absent ⇒last_error = "device missing".- Transport ≠
serial⇒ non supporté. - Sinon
open_serial()(helper :opennon-bloquant,cfmakeraw, baud viabaud_to_termios,CLOCAL|CREAD) ; si unheartbeat_cmdest défini, l'écrit et, si unheartbeat_ackest attendu, lit la réponse avec unselect()de 300 ms et compare. Succès ⇒connected = true,last_seen_tsmis à jour,last_errorvidé. - Loggue tout changement d'état (connecté/déconnecté).
any_connected(): vrai si au moins un plugin activé est connecté.summary(): chaînebridge=enabled/disabled plugins=<name:up/down,...|none>(utilisée dans le log de connexions de la boucle principale).append_json(root): ajoute un objetbridge {enabled, plugins:[{name, enabled, transport, port, baud, connected, last_seen_ts, last_error}]}au JSON d'état.
:::note Sonde par « présence de port »
Si heartbeat_cmd/heartbeat_ack sont vides, la connexion est considérée établie dès
que le port s'ouvre. C'est le profil utilisé sur appserver (ATmega sur /dev/ttyUSB0),
où un simple test de présence suffit.
:::
mqtt_client.hpp / mqtt_client.cpp — client MQTT
Relie le node au superviseur central : publie l'état sous <base>/<node_id>/...
et s'abonne aux topics de commande.
start(host, port, keepalive, base, node_id, error): init libmosquitto,mosquitto_new(node_id), enregistre les callbackson_connect/on_message, arme un Last-Will<base>/<id>/status = "offline"(retained, QoS 1) pour que le superviseur détecte une chute, puisconnect_async+loop_start(boucle réseau sur thread mosquitto).stop(): publie explicitementstatus = "offline", arrête la boucle, détruit le client,lib_cleanup.publish_state(json): publie sur<base>/<id>/state(QoS 0, non retained) si connecté.on_connect(statique) : sur succès,connected_ = true, publiestatus = "online"(retained, QoS 1) et s'abonne à<base>/<id>/cmd/#(QoS 1).on_message(statique) : extrait topic + payload et délègue àhandle_command.handle_command(topic, payload): vérifie le préfixe<base>/<id>/cmd/, découpedomain/target, l'action = payload.relay→cmd_relay.valve→ gère la formeopen:600(durée après:) puiscmd_valve.
Convention de topics : openautomation/node-0001/cmd/relay/relay1 avec payload on.
local_api.hpp / local_api.cpp — serveur HTTP embarqué
Minuscule serveur HTTP/1.1 en sockets POSIX bruts, sur son propre thread.
start(bind, port, error):socket(),SO_REUSEADDR,bind()(adresse viainet_pton, sinonINADDR_ANY),listen(8), lance le threadrun. Toute erreur socket renvoiefalseavecstrerror.stop():running_ = false(échange atomique idempotent),shutdown+closedu socket d'écoute pour débloqueraccept(),joindu thread.run(): boucleaccept()→recv(max 2047 octets) → parse la request-line (method path proto) →handle()→ réponse200 OKapplication/json(Content-Length,Connection: close) avec écriture complète en boucle →close. Connexion par requête (pas de keep-alive).handle(method, path): découpe le chemin en segments (coupe sur?), et route :GET /status(ou chemin vide) →node_.state_json().POST /relay/<id>/<on|off|toggle>→cmd_relay, réponse{"ok":bool}.POST /valve/<id>/<open|close>[/<seconds>]→cmd_valve.- sinon
{"error":"not found"}.
C'est l'API consommée par le heartbeat local et par Saturn en repli HTTP.
grpc_server.hpp / grpc_server.cpp — daemon gRPC (JSON, sans protoc)
Daemon gRPC embarqué exploitant le service générique (codec-less) de gRPC : les
requêtes/réponses sont des octets JSON bruts, donc aucun protobuf/protoc n'est
requis sur la cible. C'est la même convention JSON-over-gRPC que Saturn ↔ dnsdhcp
(Saturn compose avec grpc.CallContentSubtype("json")).
Service leukos.AgentService :
| Méthode | Entrée | Sortie |
|---|---|---|
Health | — | {"service":"up"} |
Status | — | état complet (state_json) |
Relay | {"id","action"} | {"ok":bool} |
Valve | {"id","action","seconds"} | {"ok":bool} |
struct Impl(hors header) :AsyncGenericService,ServerCompletionQueue,Server— isole les en-têtes grpcpp du reste du daemon (PIMPL).struct CallState: une requête en vol, pilotée par la completion queue à travers les étapeskRequested → kRead → kWrite → kFinish, une transition de tag à la fois. Helpersbytebuffer_to_string/string_to_bytebufferpour convertir lesgrpc::ByteBuffer.start(bind, port, error):ServerBuilderavecInsecureServerCredentials, enregistre le service générique, crée la CQ,BuildAndStart. Échec ⇒false. Lance le threadrun.stop():Server::Shutdown()+CompletionQueue::Shutdown(), join, reset PIMPL.run(): amorce une premièreCallState, puis bouclecq->Next(&tag, &ok): àkRequestedré-amorce une nouvelleCallState(pipeline toujours prêt) et lanceRead; àkReadappelledispatch(method, body)etWrite; àkWriteFinish(OK); àkFinishlibère l'objet. Unok == falsedétruit la call.dispatch(method, body): isole le dernier segment de/leukos.AgentService/<Method>.Health/Statusrépondent directement ; sinon parse le JSON d'entrée (helpersfield/int_field) et route verscmd_relay/cmd_valve, renvoyant{"ok":true|false}ou{"ok":false,"error":"unknown method"}.
CMakeLists.txt — build & packaging
- C++17 strict (
CXX_EXTENSIONS OFF), buildReleasepar défaut. - Dépendances via
pkg-config:jansson,sqlite3,libgpiod,libmosquitto,grpc++(repli surfind_package(gRPC CONFIG)si le.pcest absent),Threads. - Si
libgpiod >= 2, définitLEUKOS_GPIOD_V2=1(bascule l'ABI dansgpio_manager.cpp). - Cible
leukos-node= tous lessrc/*.cpp, installée dans/usr/bin. - Option
LEUKOS_AGENT_ENABLE_SYSTEMD_PACKAGING(ON) : installe les wrapperspackaging/bin/*, les unités systemd,node.json(renomménode.json.example), crée/var/lib/leukos, et configure CPack/DEB :CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON(deps ABI auto) +CPACK_DEBIAN_PACKAGE_DEPENDSexplicites :systemd, python3, curl, ca-certificates, mosquitto.- Scripts mainteneur
postinst/prermviaCPACK_DEBIAN_PACKAGE_CONTROL_EXTRA.
packaging/ — intégration système
bin/leukos-agent
Wrapper shell : exec /usr/bin/leukos-node --config "$CONFIG" où CONFIG provient de
LEUKOS_AGENT_CONFIG (défaut /etc/leukos/node.json). C'est la commande lancée par le
service systemd.
bin/leukos-agent-heartbeat
Script POSIX exécuté périodiquement par le timer. Il :
- Lit l'URL de l'API locale depuis
node.json(via un court script Python), en ramenant0.0.0.0/::à127.0.0.1. - Récupère l'état systemd (
systemctl is-active leukos-agent). - Interroge
GET /status(curl) et résume l'état (mqtt=... bridge=... plugins=...) pour journald (logger -t). - Construit un payload santé (Python) : choisit une IP non-loopback, calcule un
statussynthétique :downsi le service n'est pasactive;upsimqtt == upoubridge == up;degradedsinon.
- POST ce payload vers
MERCURE_HEALTH_URL(http://127.0.0.1:8140/health/state).
C'est la source unique alimentant la vue Network → Flotte de Venus.
systemd/
leukos-agent.service:Type=simple,ExecStart=/usr/bin/leukos-agent,Restart=always(2 s),After=network-online.target,RuntimeDirectory=leukos.leukos-agent-heartbeat.service:Type=oneshot, dépend du service agent, pousse vers Mercure (MERCURE_HEALTH_URL).leukos-agent-heartbeat.timer: déclenche toutes les 30 s (OnBootSec=30s,OnUnitActiveSec=30s).
debian/postinst
À la (re)configuration : crée /etc/leukos et /var/lib/leukos, copie
node.json.example → node.json s'il est absent, daemon-reload, active/redémarre
mosquitto uniquement si mqtt.host est local, puis active/redémarre
leukos-agent.service et leukos-agent-heartbeat.timer.
debian/prerm
À la suppression : stoppe et désactive le timer et le service, daemon-reload.
config/node.json
Config d'exemple (installée comme node.json.example) : GPIO désactivé, device
bridge activé avec 5 plugins (arduino_atmega2560 + esp32 activés), listes
relais/vannes/compteurs/planifications vides. C'est le point de départ à adapter par
machine.
Où intervenir selon le besoin
| Besoin | Fichier(s) |
|---|---|
| Nouveau champ de config | include/config.hpp + src/config.cpp |
| Nouvelle action de commande | src/node.cpp (cmd_*) + surface concernée |
| Nouveau type de périphérique piloté | nouveau *_manager.{hpp,cpp} + câblage Node |
| Nouveau endpoint HTTP | src/local_api.cpp (handle) |
| Nouvelle méthode gRPC | src/grpc_server.cpp (dispatch) |
| Nouveau topic MQTT | src/mqtt_client.cpp (handle_command) |
| Champ de statut publié | src/node.cpp (state_json) |
| Comportement de santé flotte | packaging/bin/leukos-agent-heartbeat |
Code complet commente
Daemon C++ — code complet commente
Cette page contient le code complet actuel de nodes/agent, avec une explication courte
avant chaque fichier pour faciliter la lecture et le debug.
Périmètre couvert
- Build et packaging:
CMakeLists.txt,packaging/ - Headers:
include/*.hpp - Implémentations:
src/*.cpp - Config d'exemple installée:
packaging/config/node.json
nodes/agent/CMakeLists.txt
Ce fichier définit la compilation C++17, les dépendances (jansson/sqlite/gpiod/mosquitto/grpc), la cible leukos-node, et le packaging DEB/systemd.
cmake_minimum_required(VERSION 3.16)
project(leukos-node VERSION 1.0.0 LANGUAGES CXX)
option(LEUKOS_AGENT_ENABLE_SYSTEMD_PACKAGING "Install systemd unit and CPack metadata" ON)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
find_package(PkgConfig REQUIRED)
pkg_check_modules(JANSSON REQUIRED IMPORTED_TARGET jansson)
pkg_check_modules(SQLITE3 REQUIRED IMPORTED_TARGET sqlite3)
pkg_check_modules(GPIOD REQUIRED IMPORTED_TARGET libgpiod)
if(GPIOD_VERSION VERSION_GREATER_EQUAL "2")
add_compile_definitions(LEUKOS_GPIOD_V2=1)
endif()
# libmosquitto ships a plain .pc as "libmosquitto"
pkg_check_modules(MOSQUITTO REQUIRED IMPORTED_TARGET libmosquitto)
# gRPC C++ (generic/codec-less service, JSON payloads). Buildroot's grpc package
# installs pkg-config files; fall back to the CMake config package otherwise.
pkg_check_modules(GRPCPP IMPORTED_TARGET grpc++)
if(NOT GRPCPP_FOUND)
find_package(gRPC CONFIG REQUIRED)
endif()
find_package(Threads REQUIRED)
add_executable(leukos-node
src/main.cpp
src/config.cpp
src/local_db.cpp
src/gpio_manager.cpp
src/relay_manager.cpp
src/valve_manager.cpp
src/meter_manager.cpp
src/scheduler.cpp
src/watchdog.cpp
src/mqtt_client.cpp
src/device_bridge.cpp
src/local_api.cpp
src/grpc_server.cpp
src/node.cpp
)
target_include_directories(leukos-node PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(leukos-node PRIVATE
PkgConfig::JANSSON
PkgConfig::SQLITE3
PkgConfig::GPIOD
PkgConfig::MOSQUITTO
Threads::Threads
)
if(GRPCPP_FOUND)
target_link_libraries(leukos-node PRIVATE PkgConfig::GRPCPP)
else()
target_link_libraries(leukos-node PRIVATE gRPC::grpc++)
endif()
install(TARGETS leukos-node RUNTIME DESTINATION /usr/bin)
if(LEUKOS_AGENT_ENABLE_SYSTEMD_PACKAGING)
install(PROGRAMS
packaging/bin/leukos-agent
packaging/bin/leukos-agent-heartbeat
DESTINATION /usr/bin
)
install(FILES
packaging/systemd/leukos-agent.service
packaging/systemd/leukos-agent-heartbeat.service
packaging/systemd/leukos-agent-heartbeat.timer
DESTINATION /lib/systemd/system
)
install(FILES
packaging/config/node.json
DESTINATION /etc/leukos
RENAME node.json.example
)
install(DIRECTORY DESTINATION /var/lib/leukos)
set(CPACK_PACKAGE_NAME "leukos-agent")
set(CPACK_PACKAGE_VENDOR "LeukOS")
set(CPACK_PACKAGE_CONTACT "ops@leukos.local")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "LeukOS automation agent daemon")
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}")
set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-${CMAKE_SYSTEM_NAME}")
set(CPACK_GENERATOR "DEB")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "LeukOS")
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
set(CPACK_DEBIAN_PACKAGE_SECTION "utils")
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
# Keep runtime complete on target hosts. shlibdeps adds ABI-level shared-lib deps,
# this list covers service/runtime tooling and local MQTT broker availability.
set(CPACK_DEBIAN_PACKAGE_DEPENDS "systemd, python3, curl, ca-certificates, mosquitto")
set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA
"${CMAKE_CURRENT_SOURCE_DIR}/packaging/debian/postinst;${CMAKE_CURRENT_SOURCE_DIR}/packaging/debian/prerm"
)
include(CPack)
endif()