diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..09a3620 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,472 @@ +# Changelog + +Toutes les modifications notables de ce projet sont documentées dans ce fichier. + +Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/), +et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : +`MAJEUR.MINEUR.CORRECTIF`. + +- **MAJEUR** : refonte ou rupture de compatibilité +- **MINEUR** : nouvelle fonctionnalité +- **CORRECTIF** : correction de bug ou ajustement mineur + +La version courante est également disponible dans [`version.json`](version.json). + +## [5.5.15] - 2026-09-27 + +### Corrigé +- `README.rst` : l'affichage sur GitHub était cassé par le titre « OnRaspberry Plugins », + indenté d'un espace (erreur reStructuredText « Unexpected section title »). Le titre et + la liste mal indentée juste au-dessus sont corrigés, et le fichier est validé sans + aucun avertissement par docutils. +- `README.rst` : chemin `hardware/Link_PMQ.py` corrigé en `hardware/link_zmq.py`. Nouvelles + sections : Documentation (lien vers le wiki DAP), Configuration (clés du TOML, dont + `timeout_ms`), Simulation mode, Tests. Options `--port` et `--verbose` de `main.py`. + La section « OnRaspberry Plugins » est complétée. +- `src_raspberry/README.md` : renvoi vers le package d'installation rapide du wiki. + +## [5.5.14] - 2026-09-27 + +### Corrigé +- `src_raspberry/hardware/sensors.py`, `backend.py` : en simulation, chaque lecture de + capteur dure désormais le temps d'une vraie lecture sur Raspberry + (`SIMULATED_READ_TIME` : AHT10 80 ms, TMP102 et EMC2101 2 ms, PT100 8 ms). Le + serveur simulé répondait instantanément : avec deux viewers en acquisition + continue sans temps d'attente, il enchaînait près de 10 000 requêtes par seconde + et figeait l'interface PyMoDAQ (le journal Windows enregistrait un gel, + `AppHangTransient`). Le rythme tombe à environ 23 acquisitions par seconde pour + les deux viewers, comme sur le vrai banc, sans modifier les presets. Le chemin + matériel n'est pas modifié. + +## [5.5.13] - 2026-09-27 + +### Corrigé +- `DAQ_0DViewer_ViewRasp.grab_data` : la ligne `Viewer Data : [...]` était écrite + en INFO à chaque acquisition, soit plus de 4 500 lignes dans le journal PyMoDAQ + pour une courte session. Elle passe en DEBUG. Les erreurs restent en WARNING, + avec un statut visible dans PyMoDAQ. + +## [5.5.12] - 2026-09-27 + +### Corrigé +- `src_raspberry/transport/zmq_server.py` : chaque requête et chaque réponse + étaient journalisées en INFO. Avec une acquisition continue sans temps + d'attente, cela faisait des centaines de lignes par seconde : la console + devenait le goulot d'étranglement et ne gardait que quelques secondes + d'historique. Ces lignes passent en DEBUG. Les connexions et déconnexions + restent en INFO. +- `src_raspberry/main.py` : option `--verbose` pour réafficher chaque requête et sa + réponse, par exemple pour montrer le protocole en direct. + +## [5.5.11] - 2026-09-27 + +### Corrigé +- `src_raspberry/hardware/actuators.py` : « Connexion à pigpio établie. » était + journalisé en INFO à chaque lecture ou consigne d'actionneur, donc deux fois + par lecture de capteur en simulation depuis le modèle thermique (5.5.0). Le + message est désormais écrit une seule fois, à la création du gestionnaire, et la + vérification de connexion reste silencieuse. + +## [5.5.10] - 2026-09-27 + +### Corrigé +- `src_raspberry/main.py` : le port d'écoute, jusqu'ici codé en dur à 5555, se + choisit avec `python main.py --port ` (5555 par défaut). README mis à jour. + +## [5.5.9] - 2026-09-27 + +### Corrigé +- `DAQ_0DViewer_ViewRasp.grab_data` : les listes d'adresses et de broches étaient + prises par position (`access_variables[0]`, `[1]`) et suivaient donc l'ordre + des clés du TOML. Si `pin` était écrit avant `address`, les broches partaient + comme des adresses et toutes les voies valaient `nan`. Une clé d'accès + supplémentaire décalait aussi les libellés. Les adresses et les broches sont + désormais lues par leur nom, dans l'ordre de réponse de la carte (adresses puis + broches). + +## [5.5.8] - 2026-09-27 + +### Corrigé +- `DAQ_Move_MoveRasp` : suppression de l'import inutilisé + `from pint.facets.numpy import quantity`, qui visait un module interne de pint, + susceptible de disparaître d'une version à l'autre. `ini_attributes` appelait + `update_move_settings` une fois par actionneur avec le même axe : un seul appel + suffit. + +## [5.5.7] - 2026-09-27 + +### Corrigé +- Compatibilité avec Python 3.8 et 3.9, déclarés par `pyproject.toml` et par + PyMoDAQ 5 (`requires-python >= 3.8`) : + - `hardware/link_zmq.py` : `from __future__ import annotations`. Les annotations + `list[str] | list[int]` et `str | int` étaient évaluées à la définition des + méthodes et levaient une `TypeError` à l'import avant Python 3.10. + - `hardware/config_components.py` : l'union de dictionnaires `a | b` (Python 3.9+) + est remplacée par `{**a, ...}`. + +## [5.5.6] - 2026-09-27 + +### Corrigé +- `hardware/link_zmq.py` : le bloc `__main__` contenait l'adresse de développement + `172.17.50.41` et pilotait une broche. Il devient une vérification de connexion + en lecture seule, qui prend l'adresse en argument : + `python -m pymodaq_plugins_raspberry.hardware.link_zmq [port]`. + +## [5.5.5] - 2026-09-27 + +### Corrigé +- `hardware/link_zmq.py` : les validations d'entrée d'`open()` (adresse IP absente) + et de `multi_acquisition()` (ni adresse ni broche) lèvent une `ValueError` + explicite. Les `assert` utilisés jusqu'ici disparaissent sous `python -O`. + +## [5.5.4] - 2026-09-27 + +### Corrigé +- `config_template.toml` : `address_Rasp` passe de `192.158.235.2`, adresse + publique issue d'une coquille, à `192.168.235.2`, adresse de réseau privé. + +## [5.5.3] - 2026-09-27 + +### Ajouté +- `tests/test_link_zmq.py` : quatre tests de la chaîne sans matériel. Un faux + serveur ROUTER tourne dans un thread, et un vrai `ZMQLink` y est branché. Les + tests couvrent une acquisition, un pilotage, une réponse d'erreur (lecture ratée + en `nan`, pilotage refusé) et un timeout (carte muette : erreur en moins de 2 s, + sans gel). Écrits avec `unittest`, ils ne demandent aucun paquet supplémentaire : + `python -m unittest discover -s tests -p "test_link_zmq.py"`. Ils sont aussi + exécutés par pytest en CI. Un appel bloqué fait échouer le test au bout de 5 s + au lieu de figer la suite. Vérifié : réintroduire la sentinelle `-1` ou retirer + `RCVTIMEO` fait échouer les tests. + +## [5.5.2] - 2026-09-27 + +### Ajouté +- `src_raspberry/README.md` : section « Démonstration sans Raspberry » en cinq + étapes (lancer le serveur en simulation, installer la configuration de démo, + initialiser les plugins, piloter la résistance et le ventilateur, montrer la + réaction à un serveur arrêté). + +## [5.5.1] - 2026-09-27 + +### Ajouté +- `resources/config_demo.toml` : configuration de démonstration côté PC, alignée + sur le banc par défaut de `src_raspberry/config.py`. Adresse `127.0.0.1`, + ventilateur (broche 18) et résistance (broche 23), cinq capteurs I2C (0x48, + 0x49, 0x4B, 0x4A, 0x38). À copier vers `~/.pymodaq/config_raspberry.toml`. Avec + la configuration du modèle, les composants n'existent pas sur la carte simulée + et toutes les voies valent `nan`. + +## [5.5.0] - 2026-09-27 + +### Ajouté +- `src_raspberry/hardware/sensors.py` : `CThermalModel`, un modèle thermique du + premier ordre utilisé **uniquement en simulation**. La résistance chauffe le banc + (+40 °C à pleine chauffe, constante de temps de 60 s), le ventilateur accélère + les pertes (plateau ramené vers +12 °C), l'ambiance dérive lentement + (22 °C ± 0,5 °C) et chaque mesure porte un léger bruit. L'humidité relative + baisse quand l'air se réchauffe. +- `CDriverSimule` : relié au modèle, il renvoie des valeurs qui évoluent et + réagissent aux actionneurs, au lieu de tirages aléatoires indépendants. Sans + modèle, il garde l'ancien comportement. +- `src_raspberry/hardware/backend.py` : en simulation, les capteurs simulés + partagent ce modèle, alimenté par les consignes des actionneurs (repérés par leur + nom : `resistance`, `ventilateur`). Le chemin matériel réel n'est pas modifié. +- `src_raspberry/config.py` : clé optionnelle `sim_coupling` par capteur, qui règle + sa proximité avec la résistance (0 : air ambiant, 1 : au contact). + +## [5.4.24] - 2026-09-27 + +### Supprimé +- `src_raspberry/handlers/json_handler.py` : suppression de `PI-MULTI` (P1.4). Aucun + appelant côté plugin, pas d'exemple documenté, et le pilotage d'un seul + actionneur par déplacement PyMoDAQ n'en a pas l'usage. Une requête `PI-MULTI` + reçoit désormais « Type de requête inconnu ». +- `src_raspberry/README.md` : section protocole mise à jour. Elle décrit quatre types + de requêtes (`scan`, `AQ`, `AQ-MULTI`, `PI`) et une forme de réponse unique, le + pilotage par broche uniquement, et la place d'une erreur dans une réponse + `AQ-MULTI`. + +## [5.4.23] - 2026-09-27 + +### Corrigé +- Pilotage par adresse I2C (P1.1) : le plugin savait construire une requête + `PI` par adresse, mais la carte l'ignorait et répondait « Format invalide pour + 'pin' ». La carte n'a en effet aucun pilote d'actionneur I2C : + `CActuatorManager` indexe les actionneurs par broche GPIO, et le champ `address` + de `CActuatorConfig` n'est jamais utilisé. + - `src_raspberry/handlers/json_handler.py` : un `PI` avec `register: "add"` + reçoit une erreur explicite (« Pilotage par adresse I2C non supporté »). + - `hardware/link_zmq.py` : `pilotage(value, pin)` ne pilote plus que par broche, + la branche adresse est retirée. + - `DAQ_Move_MoveRasp.move_value` : pilote l'actionneur par sa broche. Un + actionneur sans broche produit une erreur visible au lieu d'une requête vouée + à l'échec. L'attribut devenu inutile `name_access_variables` est retiré. + - `config_template.toml` : le commentaire indique qu'un actionneur est piloté + par sa broche (`address = "None"`). + +## [5.4.22] - 2026-09-27 + +### Corrigé +- `DAQ_Move_MoveRasp.move_value` : un pilotage refusé ou sans réponse (broche + inconnue, timeout) n'apparaissait que dans le journal. Il s'affiche désormais + aussi en statut dans PyMoDAQ, comme pour le viewer. + +## [5.4.21] - 2026-09-27 + +### Corrigé +- `DAQ_0DViewer_ViewRasp.grab_data` : une acquisition sans aucun composant coché + levait `TypeError: Data should be an non-empty list` (PyMoDAQ refuse les données + vides). Le viewer émet désormais une voie `nan` intitulée + « no component selected » et affiche un statut invitant à cocher un composant. + +## [5.4.20] - 2026-09-27 + +### Corrigé +- `DAQ_0DViewer_ViewRasp.grab_data` : quand toute l'acquisition échoue (carte + injoignable, timeout, réponse `ERROR`), le viewer émettait une seule valeur `0`, + quel que soit le nombre de voies affichées. Il émet désormais une valeur `nan` + par voie, et le message d'erreur s'affiche en statut dans PyMoDAQ + (`Update_Status`), en plus du journal. + +## [5.4.19] - 2026-09-27 + +### Corrigé +- `hardware/link_zmq.py`, `multi_acquisition()` : le message d'erreur renvoyé par la + carte était écrasé par `-1` avant d'être journalisé (le journal affichait toujours + `READ ERROR - -1`) (P0.3). Il est désormais journalisé avec le composant concerné, + par exemple `READ ERROR - add 0x99 : Capteur introuvable`. +- Une lecture ratée vaut `nan` au lieu de `-1` (une température plausible) : un + capteur en panne ne ressemble plus à un capteur qui mesure (P1.3). Vérifié : + `DataFromPlugins` et l'afficheur `Viewer0D` acceptent `nan`. + +## [5.4.18] - 2026-09-27 + +### Corrigé +- `hardware/link_zmq.py` : `get_link_status()` renvoyait toujours vrai, car + `connect()` est asynchrone en ZeroMQ et n'échoue jamais (P0.2). `open()` envoie + désormais une requête `scan` : le lien n'est considéré comme établi que si la + Raspberry répond `ACK` avant le timeout. +- `DAQ_0DViewer_ViewRasp.ini_detector`, `DAQ_Move_MoveRasp.ini_stage` : si la + carte ne répond pas, l'initialisation échoue en 2 s avec un message citant + l'adresse et le port tentés, au lieu d'afficher « Initialized » puis de geler à + la première acquisition. +- `close()` des deux plugins : protégé quand l'initialisation a échoué + (`controller` à `None`). + +## [5.4.17] - 2026-09-27 + +### Corrigé +- `hardware/link_zmq.py` : le plugin gelait (interface PyMoDAQ figée) quand la + Raspberry ne répondait pas, car `recv()` bloquait sans limite (P0.1). Le socket + DEALER reçoit désormais `RCVTIMEO`/`SNDTIMEO` (2000 ms par défaut) et `LINGER=0`. + Un timeout renvoie `{"state": "ERROR", "value": "TIMEOUT - ..."}` au lieu de + bloquer, et le socket est recréé : une réponse tardive n'est plus prise pour la + réponse à la requête suivante. +- `multi_acquisition()` : une réponse `ERROR` de la carte (ou un timeout) est + renvoyée sous forme de message `"ERROR : ..."`. Elle n'est plus parcourue + caractère par caractère comme une liste de valeurs. +- `config_template.toml` : nouvelle clé `timeout_ms` dans `[Raspberry]`, + transmise à `ZMQLink` par `DAQ_0DViewer_ViewRasp` et `DAQ_Move_MoveRasp`. + +## [5.4.16] - 2026-09-27 + +### Corrigé +- `hardware/link_zmq.py` : `close()` ferme le socket sans attente (`linger=0`) et + termine le contexte ZeroMQ ; `open()` libère le socket et le contexte précédents + avant d'en créer de nouveaux. Ouvrir et fermer plusieurs fois le lien ne fuit + plus. `close()` peut être appelé plusieurs fois sans erreur. +- `hardware/link_zmq.py` : `print()` remplacé par le logger PyMoDAQ. + +## [5.4.15] - 2026-09-27 + +### Corrigé +- `src_raspberry/handlers/json_handler.py` : la réponse à `scan` passe par `_Ack`, + comme toutes les autres : `{"state": "ACK", "value": {"actuator": [...], "detector": [...]}}`. + Toutes les requêtes ont désormais une seule forme de réponse (P1.2). Aucun code + du plugin ne consommait `scan` sous l'ancienne forme. + +## [5.4.14] - 2026-09-27 + +### Corrigé +- `src_raspberry/hardware/backend.py`, `actuators.py` : la bascule en simulation est + journalisée en WARNING avec sa cause (`smbus2` absent, bus I2C introuvable, pigpio + non installé ou démon injoignable). Sur une vraie Raspberry, une dépendance + manquante ne passe plus inaperçue derrière des valeurs simulées. + +## [5.4.13] - 2026-09-27 + +### Corrigé +- `src_raspberry/transport/zmq_server.py` : Ctrl+C n'arrêtait pas le serveur, bloqué + dans un `recv_multipart()` sans fin. La boucle attend désormais par `poll()` de + 500 ms et rend la main à Python entre deux attentes. +- Le thread de surveillance des connexions se termine sans trace d'erreur quand + `stop()` ferme son socket (`ZMQError: not a socket`). + +## [5.4.12] - 2026-09-27 + +### Corrigé +- `src_raspberry/hardware/actuators.py` : en simulation, les actionneurs relisaient + toujours `1` (conversion d'un `MagicMock`). Un `CSimulatedPi` mémorise désormais + la consigne de chaque broche : régler le ventilateur à 200 relit 200. + +## [5.4.11] - 2026-09-27 + +### Corrigé +- `src_raspberry/hardware/actuators.py` : `pigpio` devient optionnel. Son absence + (PC, Windows) bascule les actionneurs en simulation au lieu de faire échouer le + démarrage. `main.py` démarre désormais en simulation sous Windows. + +## [5.4.10] - 2026-09-27 + +### Corrigé +- `src_raspberry/hardware/backend.py`, `scanner.py` : import de `smbus2` rendu local. + `smbus2` dépend de `fcntl` (Unix uniquement) : sous Windows, l'import échouait au + chargement du module et `main.py` ne démarrait pas. L'échec d'import déclenche + désormais le mode simulation, comme un bus I2C absent. + +## [5.4.9] - 2026-09-27 + +### Corrigé +- `version.json` et `CHANGELOG.md` resynchronisés avec l'historique git : les + versions 5.4.5 à 5.4.8 avaient été commitées sans mise à jour de ces fichiers. + +## [5.4.8] - 2026-06-30 + +### Corrigé +- `DAQ_Move_MoveRasp` : gestion des unités — la mise à l'échelle PyMoDAQ + (`set_position_with_scaling`) est réactivée, le paramètre `scaling` est masqué, + et `get_actuator_value` renvoie un `DataActuator` avec l'unité de l'axe. +- `config_template.toml` : unités par défaut corrigées (`""` pour l'actionneur, + `"V"` pour le détecteur). + +## [5.4.7] - 2026-06-30 + +### Corrigé +- `DAQ_Move_MoveRasp` : les bornes de l'axe sont retrouvées par `title` (nom + d'axe affiché) au lieu de `name`. + +## [5.4.6] - 2026-06-29 + +### Corrigé +- `move_home` envoie un `DataActuator` au lieu d'un entier nu. +- Texte de retour d'initialisation simplifié (`"Initialized"`). + +## [5.4.5] - 2026-06-29 + +### Modifié +- Retours de relecture de la pull request : modules renommés en `link_zmq.py` et + `config_components.py`, `DAQ_Move_MoveRasp` passé en multi-axes + (`is_multiaxes = True`), mise à jour des bornes factorisée dans + `update_move_settings`. + +## [5.4.4] - 2026-06-10 + +### Modifié +- `README.rst` : ajout d'une note précisant que le viewer PiCamera n'est disponible + que sur Linux/Raspberry (dépendance `picamera2`). +- Documentation et commentaires nettoyés de références internes peu compréhensibles + hors du contexte de développement. + +## [5.4.3] - 2026-06-10 + +### Corrigé +- Installation impossible sur Windows/macOS : la dépendance `picamera2` (qui tire + `python-prctl`, Linux-only) est désormais conditionnée à Linux via un marqueur + d'environnement (`picamera2; platform_system == "Linux"`). La machine de contrôle + peut installer le plugin (actionneur/détecteur distants) ; le viewer PiCamera + reste disponible sur le Raspberry. + +## [5.4.2] - 2026-06-10 + +### Modifié +- `README.rst` réécrit du point de vue de l'utilisateur du plugin (contrôle d'un + dispositif expérimental via un Raspberry). Mise en avant des trois axes + d'adaptabilité : communication PyMoDAQ ⇄ Raspberry, communication + Raspberry ⇄ composants, et ajout de nouvelles requêtes JSON des deux côtés. + +## [5.4.1] - 2026-06-10 + +### Corrigé +- `CONTRIBUTING.md` : convention de tags alignée sur l'historique du dépôt + (`5.0.0`, `5.0.1`) — les tags de production sont au format `MAJEUR.MINEUR.CORRECTIF` + sans préfixe `v`. + +## [5.4.0] - 2026-06-10 + +### Ajouté +- Documentation du plugin dans `README.rst` : liste des instruments + (`MoveRasp`, `ViewRasp`, `picamera`) et description du serveur Raspberry + (`src_raspberry/`). + +### Modifié +- `README.rst` : mise à jour des auteurs et de la version PyMoDAQ requise (>= 5). + +### Vérifié +- Conformité à `tests/test_plugin_package_structure.py` (conventions de nommage et + méthodes obligatoires des plugins `DAQ_Move_MoveRasp` et `DAQ_0DViewer_ViewRasp`). + +## [5.3.0] - 2026-06-10 + +### Ajouté +- `hardware/Link_PMQ.py` : client `ZMQLink` (lien ZeroMQ DEALER vers le serveur Raspberry). +- `hardware/Config_Components.py` : lecture des composants (actionneurs/détecteurs) + depuis le fichier de configuration TOML. +- `daq_move_plugins/daq_move_MoveRasp.py` : plugin actionneur `DAQ_Move_MoveRasp`. +- `daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py` : plugin détecteur + `DAQ_0DViewer_ViewRasp`. + +### Modifié +- `resources/config_template.toml` : section de configuration `[Raspberry]`. +- `pyproject.toml` : ajout de la dépendance `pyzmq` (requise par `ZMQLink`). +- Le plugin `DAQ_2DViewer_PiCamera` n'est pas modifié. + +### Corrigé +- Remplacement des f-strings à guillemets imbriqués identiques (`f"{d["k"]}"`), + valides seulement en Python 3.12+, par une forme compatible Python 3.8+. + +## [5.2.0] - 2026-06-10 + +### Ajouté +- Pilote de capteur `PT-100` (sonde de température via CAN ADS1115), enregistré + dans `SENSOR_DRIVER_REGISTRY` (import de la dépendance Adafruit isolé localement). +- Pilotes d'actionneurs **interchangeables** derrière l'interface `CActuatorDriver`, + sélectionnés par le champ `driver` de la configuration, et enregistrés dans + `ACTUATOR_DRIVER_REGISTRY` : + - `PWM` — pilotage en rapport cyclique ; + - `DIGITAL` — pilotage tout-ou-rien. +- `config_examples/config_pizero.py` : configuration d'exemple d'un banc tout-ou-rien + avec sonde PT100. + +### Modifié +- `CActuatorManager` instancie désormais le pilote adapté à chaque actionneur + (au lieu d'un pilotage PWM codé en dur), permettant de mélanger des modes de + pilotage différents sur un même banc. +- `CActuatorConfig` accepte un champ `driver` (défaut `PWM`) ; `pwm_frequency` + devient optionnel (inutile pour les actionneurs tout-ou-rien). +- `config.py` documente le choix du pilote par actionneur. + +### Supprimé +- Moniteur de sécurité (`safety_monitor`) : retiré car il lisait des constantes de + configuration inexistantes et n'était jamais démarré par le `main`. + +### Corrigé +- Construction de la cartographie des capteurs unifiée dans `HardwareBackend` + (suppression d'une fonction morte aux références non importées). + +## [5.1.0] - 2026-06-10 + +### Ajouté +- `version.json` à la racine (version du projet, SemVer). +- `CHANGELOG.md` et `CONTRIBUTING.md` (stratégie de branches/tags, processus de version). +- Serveur Raspberry dans `src_raspberry/` selon une topologie en couches, chaque + maillon étant une **classe interchangeable** derrière une interface : + - `ITransport` / `ZmqServer` — communication avec le client PyMoDAQ (ZeroMQ) ; + - `IRequestHandler` / `JsonRequestHandler` — décodage et routage des requêtes JSON, + sans aucun accès matériel ; + - `IHardwareBackend` / `HardwareBackend` — communication avec les capteurs et actionneurs ; + - `main.py` — boucle principale qui assemble les trois couches. + +### Modifié +- La gestion des requêtes et la communication matérielle sont deux entités distinctes + (`JsonRequestHandler` d'un côté, `HardwareBackend` de l'autre). +- Le transport ZeroMQ est isolé de la logique de framing/décodage des messages. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9e43b52 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contribuer + +## Versioning et notes de version + +### Numérotation +Format obligatoire : `MAJEUR.MINEUR.CORRECTIF` (ex. `5.1.0`). + +- **MAJEUR** : refonte ou rupture de compatibilité +- **MINEUR** : nouvelle fonctionnalité +- **CORRECTIF** : correction de bug ou ajustement mineur + +La version courante est portée par [`version.json`](version.json) à la racine du projet. + +### Fichier `CHANGELOG.md` +- Présent à la racine, mis à jour à **chaque** modification livrée. +- Une entrée par version, avec les sections : `Ajouté`, `Modifié`, `Corrigé`, + `Supprimé`, `Sécurité`. +- Aucun merge en production sans mise à jour du `CHANGELOG.md`. + +### Branches Git +- `main` : version en production +- `develop` : version en cours +- `feature/nom-court` : nouvelles fonctionnalités +- `hotfix/nom-court` : correctifs urgents + +### Tags +- Tag Git obligatoire à chaque déploiement en production (ex. `5.1.0`), + aligné sur `version.json` et sur la convention de l'historique (sans préfixe `v`). diff --git a/README.rst b/README.rst index d99012e..2b81ed2 100644 --- a/README.rst +++ b/README.rst @@ -19,12 +19,29 @@ Raspberry plugin :target: https://github.com/PyMoDAQ/pymodaq_plugins_raspberry/actions/workflows/Test.yml -Set of instrument plugins to be used from or on your Raspberry Pi +PyMoDAQ plugin to control an experimental device (or test bench) through a +Raspberry Pi. + +A small server runs **on the Raspberry Pi** and drives the components of the +device (sensors, actuators); PyMoDAQ talks to that server over the network. From +PyMoDAQ you then get a detector (to read the sensors) and an actuator (to drive +the outputs) as if they were local instruments. + Authors ======= * Sebastien J. Weber (sebastien.weber@cnrs.fr) +* Fabien Villedieu + + +Documentation +============= + +A step-by-step documentation of the ZMQ plugins (architecture, JSON protocol, +Raspberry-side server, bench configuration) and ready-to-use installation packages +(control computer and Raspberry Pi) are available on the DAP wiki: +https://wiki-plugins-dap-pymodaq.github.io/ Instruments @@ -32,6 +49,18 @@ Instruments Below is the list of instruments included in this plugin +Actuators ++++++++++ + +* **MoveRasp**: drive the outputs of the device (e.g. PWM or all-or-nothing + actuators) wired to the Raspberry + +Viewer0D +++++++++ + +* **ViewRasp**: read the sensors of the device (e.g. I2C sensors) wired to the + Raspberry + Viewer1D ++++++++ @@ -41,21 +70,130 @@ Viewer1D Viewer2D ++++++++ -* **picamera**: control of the integrated pi camera using the Picamera2 library +* **picamera**: control of the integrated pi camera using the Picamera2 library. + This viewer relies on ``picamera2``, which is **only available on Linux/Raspberry** + (it depends on a Linux-only package). On a Windows/macOS control machine the + plugin still installs and works for the remote actuator/detector, but the + PiCamera viewer is simply not loaded. + + +Adapting the plugin to your setup +================================= + +Beware: some plugins are meant to be used with PyMoDAQ installed on the Raspberry directly: + +* **daqhats** +* **picamera** + +Some other are using the raspberry and the components plugged on it as an external DAQ connected to a computer. +The computer and the Raspberry communicate over ZMQ: + +* **MoveRasp** +* **ViewRasp** + + +ZMQ plugins +=========== + +Communication ++++++++++++++ + +The plugin is built to be adapted to a wide range of benches. Three things can be +changed independently: -.. if needed use this field +* **The PyMoDAQ ⇄ Raspberry communication.** It uses a ZeroMQ link by default, + but the transport is isolated behind a dedicated layer: it can be replaced by + another communication mean (serial, HTTP, ...) without touching the rest. On the + Raspberry side, implement the transport interface (``ITransport``) and wire it in + ``main.py``; on the PyMoDAQ side, provide a class exposing the same methods as + ``ZMQLink`` (``hardware/link_zmq.py``). - PID Models - ========== +* **The Raspberry ⇄ components communication.** Each sensor and each actuator is + driven by an interchangeable driver selected from the bench configuration + (``src_raspberry/config.py``). Adding a new component is just a matter of writing + a small driver class and registering it: + - a new sensor → a class in ``src_raspberry/hardware/sensors.py`` registered in + ``SENSOR_DRIVER_REGISTRY``; + - a new actuator control mode → a class in ``src_raspberry/hardware/actuators.py`` + registered in ``ACTUATOR_DRIVER_REGISTRY`` (``PWM`` and ``DIGITAL`` are provided). - Extensions - ========== +* **The set of JSON requests.** The PyMoDAQ side and the Raspberry side exchange + JSON messages, and new request types can be added easily on both ends: + + - **Raspberry side**: add an entry to the routing table ``_requestHandlers`` in + ``src_raspberry/handlers/json_handler.py`` with its handler method, which + delegates any hardware access to the hardware backend (``IHardwareBackend``); + - **PyMoDAQ side**: add a method to ``ZMQLink`` (``hardware/link_zmq.py``) that + builds and sends the new request, then call it from the move/viewer plugins. + + +Raspberry-side server ++++++++++++++++++++++ + +The code that must run on the Raspberry Pi lives in the ``src_raspberry/`` folder +at the root of this repository. It is organised in independent layers — network +transport, JSON request handling, and hardware communication — each behind an +interface, which is what makes the points above easy to adapt. See +``src_raspberry/README.md`` for installation and the JSON protocol. + +Start it with ``python main.py`` (options: ``--port ``, ``--verbose`` to log every +request). It can also be installed as a service starting with the Raspberry Pi, using the +installation package of the wiki. + + +Configuration ++++++++++++++ + +The PyMoDAQ side reads the ``[Raspberry]`` section of the plugin configuration file +(``config_raspberry.toml`` in the PyMoDAQ user folder, created from +``resources/config_template.toml``): + +* ``address_Rasp`` and ``port``: address of the Raspberry-side server; +* ``timeout_ms``: maximum wait for a response (2000 ms by default). An unreachable or + frozen Raspberry gives an error message in PyMoDAQ, never a frozen interface; +* ``[Raspberry.ACTUATOR.COMPONENTn]`` / ``[Raspberry.DETECTOR.COMPONENTn]``: one section + per component, identified by its GPIO ``pin`` (actuators) or its I2C ``address`` + (sensors). + + +Simulation mode (no hardware needed) +++++++++++++++++++++++++++++++++++++ + +If the I2C bus or the ``pigpio`` daemon are unavailable, or if no sensor answers, the +server falls back to simulation: it then runs on any computer (Windows, macOS, Linux). +The simulated temperatures follow a simple thermal model that reacts to the actuators +(the heater warms, the fan cools), with realistic reading times. To try the whole chain +on a single PC: + +#. start the server: ``python src_raspberry/main.py``; +#. use ``resources/config_demo.toml`` as plugin configuration (``127.0.0.1``); +#. in PyMoDAQ, initialize **ViewRasp** and **MoveRasp**. + + +Tests ++++++ + +``tests/test_link_zmq.py`` checks the PyMoDAQ ⇄ Raspberry link against a fake server, +without hardware (acquisition, control, error response, timeout):: + + python -m unittest discover -s tests -p "test_link_zmq.py" Installation instructions -========================= ++++++++++++++++++++++++++ * PyMoDAQ’s version >= 5 +* On a Windows/macOS control machine the plugin installs as is; the ``picamera2`` + dependency (Linux-only) is automatically skipped, so only the PiCamera viewer is + unavailable there. +* The Raspberry-side server requires the I2C bus and the ``pigpio`` daemon + (see ``src_raspberry/README.md``, or the Raspberry installation package on the wiki). + + +OnRaspberry Plugins +=================== + +* PyMoDAQ’s version >= 5, installed on the Raspberry Pi itself * Tested on/with a raspberry pi 4 diff --git a/pyproject.toml b/pyproject.toml index 2144b51..b63c382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,11 @@ name = "pymodaq_plugins_raspberry" description = 'Set of instrument plugins to use with a raspberry' dependencies = [ "pymodaq>=5.0.0", - 'picamera2', + 'pyzmq', + # picamera2 ne s'installe que sur Linux/Raspberry (il dépend de python-prctl, + # Linux-only). Sur la machine de contrôle (Windows/macOS), le plugin reste + # installable ; seul le viewer PiCamera est alors indisponible. + 'picamera2; platform_system == "Linux"', ] authors = [ diff --git a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py new file mode 100644 index 0000000..d2862e8 --- /dev/null +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -0,0 +1,217 @@ +from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, main, + DataActuator, DataActuatorType) + +from pymodaq_utils.utils import ThreadCommand +from pymodaq_gui.parameter import Parameter + +from ..hardware.link_zmq import ZMQLink, DEFAULT_TIMEOUT_MS +from ..hardware.config_components import get_actuators_hardware + +from pymodaq_plugins_raspberry import config + +from pymodaq.utils.logger import set_logger, get_module_name +logger = set_logger(get_module_name(__file__)) + +class DAQ_Move_MoveRasp(DAQ_Move_base): + """ Plugin class for controlling components through a raspberry + + Before using you need to link the raspberry to your hardware + and to your local network with a valid ip address + + ==================== ================================ + **Attributes** **Type** + *config* elements put in the TOML file + *params* dictionary list + *task* + ==================== ================================ + + See Also + -------- + refresh_hardware + """ + config: config + controller: ZMQLink # the class that link pymodaq to the raspberry + output_value = 0 # temp variable to transport the return value + + # TOML ACTUATOR LOAD + #################################################################################################################### + actuators = get_actuators_hardware(config) + #################################################################################################################### + + # SETUP AXES VARIABLES + #################################################################################################################### + is_multiaxes = True + _axis_names = [actuator['title'] for actuator in actuators] + _controller_units = [actuator['units'] for actuator in actuators] + _epsilon = [0.1 for actuator in actuators] + data_actuator_type = DataActuatorType.DataActuator + current_component = None + #################################################################################################################### + + # SETUP SPECIFIC CONFIG FOR PRESET + #################################################################################################################### + params = comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + #################################################################################################################### + + def ini_attributes(self): + self.controller: ZMQLink = None + + self.update_move_settings(self.axis_name) + + self.settings['bounds', 'is_bounds'] = True + + pass + + def user_condition_to_reach_target(self) -> bool: + """ Implement a condition for exiting the polling mechanism and specifying that the + target value has been reached + + Returns + ------- + bool: if True, PyMoDAQ considers the target value has been reached + """ + return True + + def close(self): + """Terminate the communication protocol""" + if self.is_master and self.controller is not None: + self.controller.close() + pass + + def commit_settings(self, param: Parameter): + """Apply the consequences of a change of value in the detector settings + + Parameters + ---------- + param: Parameter + A given parameter (within detector_settings) whose value has been changed by the user + """ + if param.name() == 'axis': + self.update_move_settings(param.value()) + + def update_move_settings(self, param : str): + """Update the settings of the actuators + + Parameters + ---------- + param: str + the name of the parameter (within detector_settings) whose value has been changed by the user + """ + for elem in self.actuators: + if elem['title'] == param: + self.current_component = elem + self.settings['bounds', 'max_bound'] = elem['max'] + self.settings['bounds', 'min_bound'] = elem['min'] + self.settings['units'] = elem['units'] + break + + def ini_stage(self, controller=None): + """Actuator communication initialization + + Parameters + ---------- + controller: (ZMQLink) + custom object of a PyMoDAQ plugin (Slave case). None if only one actuator by controller (Master case) + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True + """ + if self.is_master: + address, port = config("Raspberry", "address_Rasp"), config("Raspberry", "port") + timeout_ms = config.get(("Raspberry", "timeout_ms"), DEFAULT_TIMEOUT_MS) + self.controller = ZMQLink(address, port, timeout_ms) + if not self.controller.get_link_status(): + self.controller.close() + return (f"Raspberry unreachable at tcp://{address}:{port} (no answer within {timeout_ms} ms):" + f" check the address and that the raspberry's script is running"), False + initialized = True + else: + self.controller = controller + initialized = True + + self.settings.child('scaling').setOpts(visible=False) + return "Initialized", initialized + + def move_abs(self, value: DataActuator): + """ Move the actuator to the absolute target defined by value + + Parameters + ---------- + value: (DataActuator) value of the absolute target positioning + """ + + value = self.check_bound(value) + self.target_value = value + value = self.set_position_with_scaling(value) + + self.move_value(value) + + def move_rel(self, value: DataActuator): + """ Move the actuator to the relative target actuator value defined by value + + Parameters + ---------- + value: (DataActuator) value of the relative target positioning + """ + value = self.check_bound(self.current_position + value) - self.current_position + self.target_value = value + self.current_position + value = self.set_position_relative_with_scaling(value) + + self.move_value(value) + + def move_home(self): + """Call the reference method of the controller""" + self.move_value(DataActuator(name='set_value_home', data=0, units=self.axis_unit)) + + def move_value(self, value): + """ Move the actuator to the target actuator value defined by value + Used by move_rel() and move_abs() + Parameters + ---------- + value: value of the target positioning + """ + output = 0 + access_variables = None + + if isinstance(value, DataActuator): + value = value.value(self.axis_unit) + + # value = (value * 255) / 10000 # Formula for converting the PWM duty cycle percentage to a raw value in PigPio + if self.current_component is not None and (isinstance(value, float) or isinstance(value, int)): + + # Actuators are driven by their GPIO pin: the raspberry has no I2C actuator driver + access_variables = self.current_component.get('pin', 'None') + + # SPECIFIC LINES # +######################################################################################################################## + if access_variables != 'None': + output = self.controller.pilotage(value=value, pin=access_variables) + else: + output = (f"ERROR : actuator '{self.current_component['title']}' has no pin," + f" piloting by I2C address is not supported by the raspberry") +######################################################################################################################## + + if not isinstance(output, str): + self.output_value = output + logger.info(f"Move : {access_variables} -> {value} | Output : {output}") + else: + logger.warning(output) + self.emit_status(ThreadCommand('Update_Status', [f"Move {output}", 'log'])) + + else: + logger.warning("Input value not in the correct format") + + def get_actuator_value(self): + """Get the current value from the hardware with scaling conversion. + + Returns + ------- + float: The position obtained after scaling conversion. + """ + return DataActuator(data=self.output_value, units=self.axis_unit) + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_raspberry/daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py b/src/pymodaq_plugins_raspberry/daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py new file mode 100644 index 0000000..ca0e77c --- /dev/null +++ b/src/pymodaq_plugins_raspberry/daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py @@ -0,0 +1,229 @@ +import numpy as np + +from pymodaq_utils.utils import ThreadCommand +from pymodaq_data.data import DataToExport +from pymodaq.utils.data import DataFromPlugins +from pymodaq_gui.parameter import Parameter + +from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main + +from ...hardware.link_zmq import ZMQLink, DEFAULT_TIMEOUT_MS +from ...hardware.config_components import get_actuators_hardware, get_detectors_hardware, get_access_variables + +from pymodaq_plugins_raspberry import config + +from pymodaq.utils.logger import set_logger, get_module_name +logger = set_logger(get_module_name(__file__)) + + +class DAQ_0DViewer_ViewRasp(DAQ_Viewer_base): + """ Plugin class for acquisition of signals through a raspberry + + Before using you need to link the raspberry to your hardware + and to your local network with a valid ip address + + ==================== ================================ + **Attributes** **Type** + *config* elements put in the TOML file + *params* dictionary list + *task* + ==================== ================================ + + See Also + -------- + refresh_hardware + """ + config: config + controller: ZMQLink # the class that link pymodaq to the raspberry + selected_components : dict + + # SETUP CONFIGURATION FILE + #################################################################################################################### + + # list of all the detectors and actuators detected on the raspberry + compo_group = [ + {'title': 'Actuators', + 'name': 'load_actuator_group', + 'type': 'group', + 'children': get_actuators_hardware(config) + }, + + {'title': 'Detectors', + 'name': 'load_detector_group', + 'type': 'group', + 'children': get_detectors_hardware(config) + } + ] + + #################################################################################################################### + + # SETUP SPECIFIC CONFIG FOR PRESET + #################################################################################################################### + params = comon_parameters + [ + { + 'title': '--- SELECT COMPONENTS TO LOAD ---', + 'name': 'load_compo_group', + 'type': 'group', + 'expanded': True, + 'children': compo_group + } + ] + #################################################################################################################### + + def ini_attributes(self): + self.controller: ZMQLink = None + self.selected_components = get_access_variables(config) # dict of all the selected components, sorted by address and pin + + actuator_settings = self.settings.child('load_compo_group', 'load_actuator_group') + for elem in actuator_settings: + for type_access_variables in self.selected_components: + + try: + if (elem.opts[type_access_variables] != "None" and elem.value() + and elem not in self.selected_components[type_access_variables]): + + self.selected_components[type_access_variables].append(elem) + except Exception as e: + logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") + + detectors_settings = self.settings.child('load_compo_group', 'load_detector_group') + for elem in detectors_settings: + for type_access_variables in self.selected_components: + + try: + if (elem.opts[type_access_variables] != "None" and elem.value() + and elem not in self.selected_components[type_access_variables]): + + self.selected_components[type_access_variables].append(elem) + except Exception as e: + logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") + + logger.info(f"Selected Components : {self.selected_components}") + + def ini_detector(self, controller=None): + """Detector communication initialization + + Parameters + ---------- + controller: (ZMQLink) + custom object of a PyMoDAQ plugin (Slave case). None if only one actuator/detector by controller + (Master case) + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True + """ + if self.is_master: + address, port = config("Raspberry", "address_Rasp"), config("Raspberry", "port") + timeout_ms = config.get(("Raspberry", "timeout_ms"), DEFAULT_TIMEOUT_MS) + self.controller = ZMQLink(address, port, timeout_ms) + if not self.controller.get_link_status(): + self.controller.close() + return (f"Raspberry unreachable at tcp://{address}:{port} (no answer within {timeout_ms} ms):" + f" check the address and that the raspberry's script is running"), False + initialized = True + else: + self.controller = controller + initialized = True + + return "Initialized", initialized + + def close(self): + """Terminate the communication protocol""" + if self.is_master and self.controller is not None: + self.controller.close() + pass + + def commit_settings(self, param: Parameter): + if param.type() == 'bool': + if param.parent().name() == 'load_actuator_group' or param.parent().name() == 'load_detector_group': + for type_access_variables in self.selected_components: + + try : + if param.opts[type_access_variables] != "None": + exist_param_in_add = (False, 0) + for i, elem in enumerate(self.selected_components[type_access_variables]): + if elem.name() == param.name(): + exist_param_in_add = (True, i) + + if param.value() and not exist_param_in_add[0]: + self.selected_components[type_access_variables].append(param) + elif exist_param_in_add[0]: + self.selected_components[type_access_variables].pop(exist_param_in_add[1]) + except Exception as e: + logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") + + logger.info(f"{param}, Bool : {param.value()}\n{self.selected_components}") + pass + + def grab_data(self, Naverage=1, **kwargs): + """Start a grab from the detector + + Parameters + ---------- + Naverage: int + Number of hardware averaging (if hardware averaging is possible, self.hardware_averaging should be set to + True in class preamble, and you should code this implementation) + kwargs: dict + others optionals arguments + """ + # Explicit order, whatever the order of the keys in the TOML file: + # the raspberry answers all the addresses first, then all the pins + addresses = [] + pins = [] + labels_tab = [] + + for type_access_variables, access_variables in (('address', addresses), ('pin', pins)): + for elem in self.selected_components.get(type_access_variables, []): + try: + access_variables.append(elem.opts[type_access_variables]) + labels_tab.append(elem.opts['name']) + except Exception as e: + logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") + + if not labels_tab: + # PyMoDAQ refuses empty data: emit a visible placeholder instead of crashing the grab + mess = "Viewer : no component selected, tick at least one component in the settings" + logger.warning(mess) + self.emit_status(ThreadCommand('Update_Status', [mess, 'log'])) + self.dte_signal.emit(DataToExport(name='ZMQViewer', data=[DataFromPlugins( + name='Viewer', data=[np.array([float('nan')])], dim='Data0D', labels=['no component selected'])])) + return + # SPECIFIC LINE # +######################################################################################################################## + data_tot = self.controller.multi_acquisition(addresses=addresses, pins=pins) +######################################################################################################################## + + if isinstance(data_tot, str) and "ERROR" in data_tot: + mess = f"Viewer {data_tot}" + data_tot = [float('nan')] * len(labels_tab) # one value per channel, never mistaken for a measure + logger.warning(mess) + self.emit_status(ThreadCommand('Update_Status', [mess, 'log'])) + else: + logger.debug(f"Viewer Data : {data_tot}") + + if isinstance(data_tot, list): + if len(labels_tab) == 1: + data_tot = [np.array(data_tot)] + else: + data_tot = list(map(lambda x: np.array([x]), data_tot)) + + self.dte_signal.emit( + DataToExport(name='ZMQViewer', + data=[DataFromPlugins( + name='Viewer', + data=data_tot, + dim='Data0D', + labels=labels_tab) + ] + ) + ) + + def stop(self): + """Stop the current grab hardware wise if necessary""" + return '' + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_raspberry/hardware/config_components.py b/src/pymodaq_plugins_raspberry/hardware/config_components.py new file mode 100644 index 0000000..c4bd60d --- /dev/null +++ b/src/pymodaq_plugins_raspberry/hardware/config_components.py @@ -0,0 +1,67 @@ +from pymodaq_utils import Config + +from pymodaq.utils.logger import set_logger, get_module_name +logger = set_logger(get_module_name(__file__)) + +def get_actuators_hardware(config : Config) -> list: + """ + Scan the TOML file for actuators hardware and + return a list of dictionary of the elements of each actuators hardware + + :param config: The TOML file scanned in a dictionary + :return: list of actuators hardware + """ + + temp_tab = [] + + for component in config("Raspberry", "ACTUATOR"): + if 'COMPONENT' in component: + try: + temp_tab.append({**config("Raspberry", "ACTUATOR", component), 'type': 'bool', 'default': False, 'value': False}) + except Exception as e: + logger.info(str(e)) + return temp_tab + + +def get_detectors_hardware(config: Config) -> list: + """ + Scan the TOML file for detectors hardware and + return a list of dictionary of the elements of each detectors hardware + + :param config: The TOML file scanned in a dictionary + :return: list of detectors hardware + """ + + temp_tab = [] + + for component in config("Raspberry", "DETECTOR"): + if 'COMPONENT' in component: + try: + temp_tab.append({**config("Raspberry", "DETECTOR", component), 'type': 'bool', 'default': False, 'value': False}) + except Exception as e: + logger.info(str(e)) + + return temp_tab + +def get_access_variables(config : Config) -> dict : + """ + Take every access variables that are not basics variables and put them in a dictionary + + :param config: The TOML file scanned in a dictionary + :return: dictionary of each access variables + """ + base_elems = ["title", "name", "units", "min", "max"] + temp_tab = {} + + for component in config("Raspberry", "ACTUATOR"): + for elem in config("Raspberry", "ACTUATOR", component): + if elem not in base_elems and elem not in temp_tab: + temp_tab[elem] = [] + + + for component in config("Raspberry", "DETECTOR"): + for elem in config("Raspberry", "DETECTOR", component): + if elem not in base_elems and elem not in temp_tab: + temp_tab[elem] = [] + + return temp_tab diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py new file mode 100644 index 0000000..d439ce2 --- /dev/null +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -0,0 +1,253 @@ +# annotations such as list[str] | list[int] are evaluated at definition time before Python 3.10 +from __future__ import annotations + +import zmq +import json +import uuid + +from pymodaq.utils.logger import set_logger, get_module_name +logger = set_logger(get_module_name(__file__)) + +DEFAULT_TIMEOUT_MS = 2000 # maximum wait for a raspberry's response, in milliseconds + +class ZMQLink: + """ + Set up the connection between the Pymodaq Dashboard and the raspberry's script + -------------------- + To work, you need to install and launch the raspberry's script and get his ip address. + """ + + __isLinked : bool + __context : zmq.Context + __socket : zmq.Socket + __id_socket : str + __address : str + __timeout_ms : int + + def __init__(self, ip_address : str, port : str, timeout_ms : int = DEFAULT_TIMEOUT_MS): + """ + Init the object and start the connection + -------------------- + :param ip_address: The raspberry's ip address + :param port: The raspberry's communication port (5555 by default) + :param timeout_ms: Maximum wait for a response, in milliseconds (2000 by default) + :return: void - start the ZMQ connection + """ + self.__isLinked = False + self.__id_socket = "" + self.__context = None + self.__socket = None + self.__address = "" + self.__timeout_ms = int(timeout_ms) + self.open(ip_address, port) + return + + def open(self, ip_address : str, port : str): + """ + Start the connection + -------------------- + :param ip_address: The raspberry's ip address + :param port: The raspberry's communication port (5555 by default) + :return: void - Start the ZMQ connection + """ + if ip_address is None: + raise ValueError("ERROR - ip address not set") + + self.close() # releases the previous socket and context when reopening + self.__context = zmq.Context() + self.__address = f"tcp://{ip_address}:{port}" + self.__new_socket() + + # connect() is asynchronous and never fails, even towards a wrong address: + # only an answered 'scan' request proves that the raspberry's script is reachable + response = self.__write({"type": "scan"}) + self.__isLinked = isinstance(response, dict) and response.get("state") == "ACK" + + if self.__isLinked: + logger.info(f"ZMQ LINK -> CONNECTED |" + f" IP ROUTER : {ip_address} |" + f" PORT ROUTER : {port} |" + f" ID DEALER : {self.__id_socket}") + else: + logger.warning(f"ZMQ LINK -> NOT CONNECTED | {self.__address} | {response}") + return + + def close(self): + """ + Stop the connection + -------------------- + :return: Close the ZMQ connection + """ + if self.__socket is not None: + self.__socket.close(linger=0) + self.__socket = None + if self.__context is not None: + self.__context.term() + self.__context = None + self.__isLinked = False + return + + def __new_socket(self): + """ + Create the DEALER socket, with bounded waits, and connect it to the raspberry + -------------------- + :return: void - replace the current socket + """ + if self.__socket is not None: + self.__socket.close(linger=0) + + self.__socket = self.__context.socket(zmq.DEALER) + self.__id_socket = str(uuid.uuid4()) + self.__socket.setsockopt_string(zmq.IDENTITY, self.__id_socket) + self.__socket.setsockopt(zmq.RCVTIMEO, self.__timeout_ms) + self.__socket.setsockopt(zmq.SNDTIMEO, self.__timeout_ms) + self.__socket.setsockopt(zmq.LINGER, 0) + self.__socket.connect(self.__address) + + def __write(self, request : dict): + """ + Send a JSON request to the raspberry's script + -------------------- + :param request: A dictionary formatted for a request + :return: The response from the raspberry's script, + or {"state": "ERROR", "value": } if the raspberry does not answer + """ + if self.__socket is None: + return {"state": "ERROR", "value": "link closed"} + try: + self.__socket.send(json.dumps(request).encode('utf-8')) + except zmq.Again: + return self.__timeout_error() + return self.__read() + + def __read(self): + """ + Receive a JSON response from the raspberry's script + -------------------- + :return: The response from a request, sent by the raspberry's script, + or {"state": "ERROR", "value": } on timeout or invalid response + """ + try: + inp_mq = self.__socket.recv() + except zmq.Again: + return self.__timeout_error() + + if isinstance(inp_mq, bytes): + inp_mq = inp_mq.decode('utf-8') + try: + return json.loads(inp_mq) + except ValueError: + return {"state": "ERROR", "value": f"invalid response from {self.__address} : {inp_mq}"} + + def __timeout_error(self) -> dict: + """ + Handle a raspberry that does not answer in time + -------------------- + :return: A structured error response + """ + message = f"TIMEOUT - no response from {self.__address} within {self.__timeout_ms} ms" + logger.warning(message) + # A late response would be read as the answer to the next request: + # a new socket (new identity) discards it + self.__new_socket() + return {"state": "ERROR", "value": message} + + def get_link_status(self) -> bool: + """ + Get the status of the link (True -> the raspberry answered, False -> no answer or closed) + -------------------- + :return: The status of the connection + """ + return self.__isLinked + + def multi_acquisition(self, addresses : list[str] | list[int] = None, pins : list[str] | list[int] = None) -> list | str: + """ + Send a JSON multi-acquisition request to the raspberry's script + -------------------- + :param addresses: A list of addresses linked to multiples components + :param pins: A list of pins linked to multiples components + :return: A list of value read by each component, + order of the list : all the value of addresses, next, all the value of pins. + A failed reading is replaced by nan (and logged with the raspberry's message) + """ + if addresses is None and pins is None: + raise ValueError("ERROR: hardware should have an address or a pin") + + output = { + "type": "AQ-MULTI", + "components": [] + } + + if addresses is not None : + for i in range(len(addresses)): + output["components"].append( + { + "register": "add", + "add": addresses[i] + } + ) + + if pins is not None: + for i in range(len(pins)): + output["components"].append( + { + "register": "pin", + "pin": pins[i] + } + ) + + inp_mq = self.__write(output) + + if not isinstance(inp_mq, dict): + return "ERROR : input type incorrect, dict required" + if inp_mq.get("state") != "ACK": + return f"ERROR : {inp_mq.get('value')}" + + for i, elem in enumerate(inp_mq["value"]): + if type(elem) != int and type(elem) != float: + # log the raspberry's message before replacing it: nan cannot be mistaken for a measure + component = output["components"][i] if i < len(output["components"]) else {} + register = component.get("register", "?") + reason = elem.get("value", elem) if isinstance(elem, dict) else elem + logger.warning(f"READ ERROR - {register} {component.get(register, '?')} : {reason}") + inp_mq["value"][i] = float('nan') + + return inp_mq["value"] + + def pilotage(self, value : str | int, pin : str | int) -> float | str: + """ + Send a JSON control request to the raspberry's script + -------------------- + Actuators are driven by their GPIO pin: the raspberry's script has no I2C actuator driver + :param value: The value wanted for the component + :param pin: The pin of the component + :return: The value read by the component after control + """ + inp_mq = self.__write( + { + "type": "PI", + "register": "pin", + "pin": pin, + "value" : value + } + ) + + if isinstance(inp_mq, dict): + if inp_mq["state"] == "ACK": + return inp_mq["value"] + else: + return f"{inp_mq['state']} : {inp_mq['value']}" + else: + return "ERROR : input type incorrect, dict required" + +if __name__ == '__main__': + """Quick link check from a terminal (read only, nothing is driven): + python -m pymodaq_plugins_raspberry.hardware.link_zmq [port]""" + import sys + + if len(sys.argv) < 2: + sys.exit("usage: python -m pymodaq_plugins_raspberry.hardware.link_zmq [port]") + + link = ZMQLink(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else '5555') + print(f"Raspberry reachable : {link.get_link_status()}") + link.close() diff --git a/src/pymodaq_plugins_raspberry/resources/config_demo.toml b/src/pymodaq_plugins_raspberry/resources/config_demo.toml new file mode 100644 index 0000000..2241923 --- /dev/null +++ b/src/pymodaq_plugins_raspberry/resources/config_demo.toml @@ -0,0 +1,65 @@ +# Demonstration configuration: the Raspberry's script (src_raspberry/main.py) runs in +# simulation mode on the same PC as PyMoDAQ. +# Usage: copy this file to ~/.pymodaq/config_raspberry.toml (user configuration, it takes +# precedence over the system-wide one), then restart PyMoDAQ. +# The components below match the default bench described in src_raspberry/config.py. + +[Raspberry] +address_Rasp = '127.0.0.1' +port = '5555' +timeout_ms = 2000 # maximum wait for a Raspberry's response, in milliseconds + +# Actuators: driven by their GPIO pin (PWM duty cycle 0-255) +[Raspberry.ACTUATOR.COMPONENT1] +title = "Ventilateur" +name = "ventilateur" +units = "" +min = "0" +max = "255" +address = "None" +pin = "18" + +[Raspberry.ACTUATOR.COMPONENT2] +title = "Resistance" +name = "resistance" +units = "" +min = "0" +max = "255" +address = "None" +pin = "23" + +# Detectors: read by their I2C address +[Raspberry.DETECTOR.COMPONENT1] +title = "T resistance" +name = "t_resistance" +units = "°C" +address = "0x48" +pin = "None" + +[Raspberry.DETECTOR.COMPONENT2] +title = "T dissipateur" +name = "t_dissipateur" +units = "°C" +address = "0x49" +pin = "None" + +[Raspberry.DETECTOR.COMPONENT3] +title = "T sortie" +name = "t_sortie" +units = "°C" +address = "0x4B" +pin = "None" + +[Raspberry.DETECTOR.COMPONENT4] +title = "T entree" +name = "t_entree" +units = "°C" +address = "0x4A" +pin = "None" + +[Raspberry.DETECTOR.COMPONENT5] +title = "RH sortie" +name = "rh_sortie" +units = "RH" +address = "0x38" +pin = "None" diff --git a/src/pymodaq_plugins_raspberry/resources/config_template.toml b/src/pymodaq_plugins_raspberry/resources/config_template.toml index 3736213..ae3eb41 100644 --- a/src/pymodaq_plugins_raspberry/resources/config_template.toml +++ b/src/pymodaq_plugins_raspberry/resources/config_template.toml @@ -1,2 +1,45 @@ -#this is the configuration file of the plugin +[Raspberry] +# Extra variable, it is used to identify the Raspberry +# Here, for a ZMQ link, we use an IPv4 address to connect to the Raspberry via Ethernet +address_Rasp = '192.168.235.2' +port = '5555' +timeout_ms = 2000 # maximum wait for a Raspberry's response, in milliseconds +# +[Raspberry.ACTUATOR.COMPONENT1] +# Basic variables, you need them for the plugin to work correctly ! +title = "MyActuator" # IT HAVE TO BE UNIQUE AMONG THE OTHER COMPONENT'S NAME +name = "nameActuator" # IT HAVE TO BE UNIQUE AMONG THE OTHER COMPONENT'S NAME +units = "" +min = "0" +max = "100" +# +# Extra variables, they are used to identify the component +# An actuator is driven by its GPIO pin: keep address = "None" (the Raspberry has no I2C actuator driver) +address = "None" +pin = "00" +# + +#[Raspberry.ACTUATOR.COMPONENT2] +#title = "" +#name = "" +#units = "" +#min = "" +#max = "" + +[Raspberry.DETECTOR.COMPONENT1] +# Basic variables, you need them for the plugin to work correctly ! +title = "MyDetector" # IT HAVE TO BE UNIQUE AMONG THE OTHER COMPONENT'S NAME +name = "nameDetector" # IT HAVE TO BE UNIQUE AMONG THE OTHER COMPONENT'S NAME +units = "V" +# +# Extra variables, they are used to identify the component +# Here, for a I2C network, we use an address and a pin variable to identify and use the component "MyActuator" +address = "0x00" +pin = "None" +# + +#[Raspberry.DETECTOR.COMPONENT2] +#title = "" +#name = "" +#units = "" diff --git a/src_raspberry/README.md b/src_raspberry/README.md new file mode 100644 index 0000000..7211124 --- /dev/null +++ b/src_raspberry/README.md @@ -0,0 +1,139 @@ +# Serveur d'acquisition et de pilotage Raspberry Pi (PyMoDAQ) + +Ce dossier contient le code serveur qui tourne sur la Raspberry Pi. Il fait +l'interface entre le matériel (capteurs I2C, actionneurs) et le réseau via un +serveur ZeroMQ, permettant à un client PyMoDAQ distant de piloter l'ensemble +via des trames JSON. + +> Ce dossier est un ajout au plugin et n'est **pas packagé** : il ne fait pas +> partie de la distribution Python du plugin PyMoDAQ et ne le modifie pas. + +## 🏗️ Architecture en couches + +Le serveur est découpé selon une topologie où **chaque maillon est une classe +interchangeable** derrière une interface, sauf le `main` qui les assemble : + +``` + ZmqServer ──► JsonRequestHandler ──► HardwareBackend + (transport) (gestion requêtes) (comm. composants) + ▲ ▲ ▲ + ITransport IRequestHandler IHardwareBackend + assemblés par main.py (boucle) +``` + +- **`transport/`** — communication avec le client PyMoDAQ. + - `base.py` : interface `ITransport`. + - `zmq_server.py` : implémentation `ZmqServer` (ZeroMQ ROUTER). Ne gère que le + réseau et le framing ; remplaçable par un autre transport. +- **`handlers/`** — gestion des requêtes. + - `base.py` : interface `IRequestHandler`. + - `json_handler.py` : implémentation `JsonRequestHandler` (décodage + routage + JSON). **Aucun accès matériel** : tout est délégué au backend. +- **`hardware/`** — communication avec les composants. + - `base.py` : interface `IHardwareBackend`. + - `backend.py` : implémentation `HardwareBackend` (capteurs + actionneurs). + - `sensors.py` : pilotes de capteurs et `SENSOR_DRIVER_REGISTRY` + (`AHT10`, `TMP102`, `EMC2101`, `PT-100`, `SIMULE`). + - `actuators.py` : pilotes d'actionneurs et `ACTUATOR_DRIVER_REGISTRY` + (`PWM`, `DIGITAL`), plus le gestionnaire `CActuatorManager`. + - `scanner.py` : détection des adresses I2C. +- **`config.py`** — description du banc de test (broches, capteurs, actionneurs). + C'est le **seul fichier à adapter** d'un banc à l'autre. +- **`config_examples/`** — configurations prêtes à l'emploi pour d'autres bancs + (ex. `config_pizero.py` : actionneurs tout-ou-rien + sonde PT100). +- **`main.py`** — point d'entrée : instancie et câble les trois couches. + +### Adapter le serveur à un banc + +Toute la différence entre deux bancs de test tient dans +`config.py` : un capteur choisit son pilote via le champ `driver` (clé de +`SENSOR_DRIVER_REGISTRY`), un actionneur via son propre champ `driver` +(`PWM` ou `DIGITAL`, clé de `ACTUATOR_DRIVER_REGISTRY`). Ajouter un nouveau +matériel = créer une classe de pilote et l'enregistrer dans le registre +correspondant ; ni le transport, ni la gestion des requêtes, ni le `main` +ne changent. + +Pour repartir d'un banc existant, copiez le fichier voulu de `config_examples/` +vers `config.py`. + +## 🛠️ Prérequis et installation + +> **Installation rapide** : le wiki DAP (https://wiki-plugins-dap-pymodaq.github.io/, +> page *Downloads*) fournit un package qui réalise les étapes ci-dessous et installe +> le serveur comme service démarrant avec la carte (`sudo bash install.sh`). + +1. **Activer l'I2C** : `sudo raspi-config` → Interfacing Options → I2C. +2. **Démon pigpio** (pilotage matériel des GPIO) : + ```bash + sudo apt-get update + sudo apt-get install pigpio python3-pigpio + sudo systemctl enable pigpiod + sudo systemctl start pigpiod + ``` +3. **Dépendances Python** : + ```bash + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + +## 🚀 Lancement + +```bash +python main.py # port 5555 +python main.py --port 5556 # autre port (à reporter dans le TOML côté PC) +python main.py --verbose # affiche aussi chaque requête reçue et sa réponse +``` + +> **Mode simulation** : si le bus I2C ou le démon pigpio sont inaccessibles +> (ex. exécution sur un PC), le serveur bascule automatiquement en simulation +> (bus I2C factice, actionneurs simulés qui mémorisent leur consigne) pour tester +> la communication réseau sans la Raspberry Pi. + +### Démonstration sans Raspberry (Windows, macOS, Linux) + +1. Sur le PC, avec PyMoDAQ installé : `pip install pyzmq`, puis `python src_raspberry/main.py` (log `Mode simulation activé`). +2. Copier `src/pymodaq_plugins_raspberry/resources/config_demo.toml` vers `~/.pymodaq/config_raspberry.toml`. +3. Dans le Dashboard, ajouter `ViewRasp` et `MoveRasp`, cocher les composants du viewer, puis initialiser. +4. Régler `Resistance` à 255 : les températures montent ; régler `Ventilateur` à 255 : elles redescendent. +5. Arrêter le serveur avec Ctrl+C ; serveur arrêté, les voies passent en `nan` avec un message d'erreur visible. + +## 📡 Protocole de communication (JSON) + +Le serveur écoute des trames JSON sur un socket ZeroMQ (ROUTER) port `5555`. +Quatre types de requêtes : `scan`, `AQ`, `AQ-MULTI` et `PI`. Toutes les réponses +ont la même forme : `{"state": "ACK" | "ERROR", "value": }`. + +### Scan du matériel +```json +{"type": "scan"} +``` +Réponse : `{"state": "ACK", "value": {"actuator": [...], "detector": [...]}}`. + +### Acquisition (`AQ`) +```json +{"type": "AQ", "register": "add", "add": "0x38", "channel": "temp"} +{"type": "AQ", "register": "pin", "pin": 18} +``` + +### Pilotage (`PI`) +```json +{"type": "PI", "register": "pin", "pin": 18, "value": 128} +``` +Les actionneurs sont pilotés par leur broche GPIO. Un pilotage par adresse I2C +(`"register": "add"`) est refusé : aucun pilote d'actionneur I2C n'existe. + +### Acquisition multiple (`AQ-MULTI`) +```json +{ + "type": "AQ-MULTI", + "components": [ + {"register": "add", "add": "0x38", "channel": "hum"}, + {"register": "pin", "pin": 18} + ] +} +``` +Les valeurs sont renvoyées dans l'ordre demandé. Une lecture ratée est remplacée +à sa place par sa propre réponse d'erreur, par exemple : +`{"state": "ACK", "value": [45.2, {"state": "ERROR", "value": "Capteur introuvable"}]}`. +Le plugin la journalise et l'affiche en `nan`. diff --git a/src_raspberry/config.py b/src_raspberry/config.py new file mode 100644 index 0000000..46179c4 --- /dev/null +++ b/src_raspberry/config.py @@ -0,0 +1,121 @@ +"""! +@file config.py +@brief Configuration du banc de test : adresses I2C, broches matérielles et + description des actionneurs / capteurs. + +Chaque Raspberry possède son propre banc de test : ce fichier est l'unique endroit +à adapter pour décrire le matériel présent. Les couches transport / gestion des +requêtes / communication composants ne changent pas d'un banc à l'autre. + +Ce fichier décrit le banc par défaut (actionneurs pilotés en PWM). Des exemples de +configurations pour d'autres bancs sont fournis dans le dossier `config_examples/` +(ex. `config_pizero.py` pour un banc tout-ou-rien avec sonde PT100) : il suffit de +copier le contenu voulu ici. +""" +#region Constantes I2C +## @brief ID du bus I2C matériel +I2C_BUS_ID = 1 +#endregion + +#region Broches matérielles +## @brief Broche matérielle contrôlant le ventilateur +VENTILATEUR_PIN = 18 + +## @brief Broche matérielle contrôlant la résistance chauffante +RESISTANCE_PIN = 23 +#endregion + +#region Adresses I2C des capteurs +## @brief Adresse I2C du capteur de température et d'humidité AHT10 +CAPTEUR_AHT10 = 0x38 + +## @brief Adresse I2C du capteur de température principal TMP102 +CAPTEUR_TMP102 = 0x48 + +## @brief Adresse I2C du contrôleur de ventilateur / capteur EMC2101 +CAPTEUR_EMC2101 = 0x4C +#endregion + +#region Configuration Actionneurs +## @brief Configuration des actionneurs (ventilateurs, résistances, etc.) +ACTUATORS_CONFIG = [ + { + 'pin': VENTILATEUR_PIN, + 'title': 'Ventilateur', + 'name': 'ventilateur', + 'driver': 'PWM', # mode de pilotage : 'PWM' ou 'DIGITAL' + 'units': '%', + 'min': 0, + 'max': 255, + 'address': None, + 'pwm_frequency': 25000, + }, + { + 'pin': RESISTANCE_PIN, + 'title': 'Resistance', + 'name': 'resistance', + 'driver': 'PWM', + 'units': '%', + 'min': 0, + 'max': 255, + 'address': None, + 'pwm_frequency': 100, + }, +] +#endregion + +#region Configuration Capteurs +## @brief Configuration des capteurs détectables sur le bus I2C +# 'sim_coupling' (optionnel, mode simulation uniquement) : proximité du capteur +# avec la résistance chauffante, de 0 (air ambiant) à 1 (au contact). +SENSORS_CONFIG = { + CAPTEUR_AHT10: { + 'driver': 'AHT10', + 'title': 'aht10', + 'name': 'rh_sortie', + 'units': 'RH', + 'sim_coupling': 0.5, + }, + CAPTEUR_TMP102: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_resistance', + 'units': '°C', + 'sim_coupling': 1.0, + }, + 0x49: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_dissipateur', + 'units': '°C', + 'sim_coupling': 0.7, + }, + 0x4A: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_entree', + 'units': '°C', + 'sim_coupling': 0.1, + }, + 0x4B: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_sortie', + 'units': '°C', + 'sim_coupling': 0.5, + }, + CAPTEUR_EMC2101: { + 'driver': 'EMC2101', + 'title': 'emc2101', + 'name': 'T_emc', + 'units': '°C', + 'sim_coupling': 0.3, + }, + 'default': { + 'driver': 'Unknown', + 'title': 'Unknown Sensor', + 'name': 'unknow_sensor', + 'units': '', + }, +} +#endregion diff --git a/src_raspberry/config_examples/config_pizero.py b/src_raspberry/config_examples/config_pizero.py new file mode 100644 index 0000000..fcb9d58 --- /dev/null +++ b/src_raspberry/config_examples/config_pizero.py @@ -0,0 +1,106 @@ +"""! +@file config_pizero.py +@brief Exemple de configuration de banc avec actionneurs tout-ou-rien (TOR) et + sonde PT100. + +Pour l'utiliser : copier ce contenu dans `src_raspberry/config.py`. +Aucun autre fichier n'a besoin d'être modifié — seuls les pilotes sélectionnés +par la configuration changent (DIGITAL au lieu de PWM, ajout du capteur PT-100). +""" +#region Constantes I2C +## @brief ID du bus I2C matériel +I2C_BUS_ID = 1 +#endregion + +#region Broches matérielles +## @brief Broche matérielle contrôlant le ventilateur (tout-ou-rien) +VENTILATEUR_PIN = 13 + +## @brief Broche matérielle contrôlant la résistance chauffante (tout-ou-rien) +RESISTANCE_PIN = 19 +#endregion + +#region Adresses I2C des capteurs +## @brief Adresse I2C du capteur de température et d'humidité AHT10 +CAPTEUR_AHT10 = 0x38 + +## @brief Adresse I2C de la sonde PT100 (via CAN ADS1115) +CAPTEUR_PT100 = 0x48 + +## @brief Adresse I2C du contrôleur de ventilateur / capteur EMC2101 +CAPTEUR_EMC2101 = 0x4C +#endregion + +#region Configuration Actionneurs +## @brief Actionneurs pilotés en tout-ou-rien (driver 'DIGITAL') +ACTUATORS_CONFIG = [ + { + 'pin': VENTILATEUR_PIN, + 'title': 'Ventilateur', + 'name': 'ventilateur', + 'driver': 'DIGITAL', + 'units': 'Etat', + 'min': 0, + 'max': 1, + 'address': None, + }, + { + 'pin': RESISTANCE_PIN, + 'title': 'Resistance', + 'name': 'resistance', + 'driver': 'DIGITAL', + 'units': 'Etat', + 'min': 0, + 'max': 1, + 'address': None, + }, +] +#endregion + +#region Configuration Capteurs +## @brief Configuration des capteurs détectables sur le bus I2C +SENSORS_CONFIG = { + CAPTEUR_AHT10: { + 'driver': 'AHT10', + 'title': 'aht10', + 'name': 'rh_sortie', + 'units': 'RH', + }, + CAPTEUR_PT100: { + 'driver': 'PT-100', + 'title': 'pt-100', + 'name': 'pt100', + 'units': '°C', + }, + 0x49: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_dissipateur', + 'units': '°C', + }, + 0x4A: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_entree', + 'units': '°C', + }, + 0x4B: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_sortie', + 'units': '°C', + }, + CAPTEUR_EMC2101: { + 'driver': 'EMC2101', + 'title': 'emc2101', + 'name': 'T_emc', + 'units': '°C', + }, + 'default': { + 'driver': 'Unknown', + 'title': 'Unknown Sensor', + 'name': 'unknow_sensor', + 'units': '', + }, +} +#endregion diff --git a/src_raspberry/handlers/__init__.py b/src_raspberry/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src_raspberry/handlers/base.py b/src_raspberry/handlers/base.py new file mode 100644 index 0000000..1659559 --- /dev/null +++ b/src_raspberry/handlers/base.py @@ -0,0 +1,26 @@ +#region Imports +from abc import ABC, abstractmethod +#endregion + + +#region Interface Gestion des requêtes +class IRequestHandler(ABC): + """! + @brief Couche de gestion des requêtes. + + Décode une requête brute, route vers l'action correspondante et renvoie une + réponse sérialisée. Cette couche ne contient AUCUN accès matériel : toute + opération sur les composants est déléguée à un IHardwareBackend. Elle peut + donc être remplacée (autre format de protocole) indépendamment du transport + et du matériel. + """ + + @abstractmethod + def handle(self, request: str) -> str: + """! + @brief Traite une requête brute et renvoie la réponse sérialisée. + @param request La requête brute reçue du transport. + @return La réponse sérialisée (chaîne) à renvoyer au client. + """ + raise NotImplementedError +#endregion diff --git a/src_raspberry/handlers/json_handler.py b/src_raspberry/handlers/json_handler.py new file mode 100644 index 0000000..78fd250 --- /dev/null +++ b/src_raspberry/handlers/json_handler.py @@ -0,0 +1,166 @@ +#region Imports +import json +import logging + +from .base import IRequestHandler +#endregion + +#region Logger +## @brief Logger du module de gestion des requêtes +logger = logging.getLogger(__name__) +#endregion + + +#region Gestion des requêtes JSON +class JsonRequestHandler(IRequestHandler): + """! + @brief Décode les requêtes JSON entrantes et les route vers le backend matériel. + + Cette couche ne touche jamais directement au matériel : elle traduit une requête + en appels sur un IHardwareBackend (injecté), puis formate la réponse. Elle est + ainsi indépendante du transport (en amont) et du matériel (en aval). + """ + + def __init__(self, hardwareBackend): + """! + @brief Constructeur d'initialisation. + @param hardwareBackend Instance de IHardwareBackend à piloter. + """ + ## @brief Backend matériel délégué + self.backend = hardwareBackend + + ## @brief Dictionnaire de routage des requêtes + self._requestHandlers = { + "scan": self._HandleScan, + "AQ": self._HandleAcquisition, + "AQ-MULTI": self._HandleMultiAcquisition, + "PI": self._HandlePiloting, + } + + def handle(self, request: str) -> str: + """! + @brief Traite une requête brute et renvoie la réponse sérialisée en JSON. + @param request La trame JSON reçue. + @return La réponse JSON sérialisée. + """ + try: + responseDict = self._Route(request) + except Exception as exc: + logger.exception("Erreur de traitement non gérée.") + responseDict = {"state": "ERROR", "value": str(exc)} + return json.dumps(responseDict) + + def _Route(self, jsonString: str) -> dict: + """! + @brief Décode et route une trame JSON entrante. + @param jsonString La trame reçue. + @return Le dictionnaire de réponse. + """ + try: + requestDict = json.loads(jsonString) + except json.JSONDecodeError: + return self._Error("Format JSON invalide") + + requestType = requestDict.get("type") + handlerMethod = self._requestHandlers.get(requestType) + + if handlerMethod is None: + return self._Error(f"Type de requête inconnu : '{requestType}'") + + try: + return handlerMethod(requestDict) + except Exception as exc: + return self._Error(f"Erreur interne : {exc}") + + def _HandleScan(self, _request: dict) -> dict: + """! + @brief Gère la requête de scan des périphériques. + @param _request Données de la requête (inutilisé). + @return Dict de réponse dont la valeur liste les périphériques + ({'actuator': [...], 'detector': [...]}). + """ + return self._Ack(self.backend.scan()) + + def _HandleAcquisition(self, requestData: dict) -> dict: + """! + @brief Gère une requête d'acquisition simple. + @param requestData La requête. + @return Dict de réponse. + """ + registerType = requestData.get("register") + + if registerType == "add": + addressStr = requestData.get("add") + try: + numericAddress = int(addressStr, 16) + except (ValueError, TypeError): + return self._Error("Format d'adresse invalide") + + channelName = requestData.get("channel") + try: + sensorValue = self.backend.read_sensor(numericAddress, channelName) + except KeyError: + return self._Error("Capteur introuvable") + + return self._Ack(sensorValue) if sensorValue is not None else self._Error("Échec lecture") + + elif registerType == "pin": + pinTarget = self._ParseInt(requestData.get("pin"), "pin") + if isinstance(pinTarget, dict): + return pinTarget + try: + return self._Ack(self.backend.read_pin(pinTarget)) + except Exception as exc: + return self._Error(str(exc)) + + return self._Error("Type de registre invalide") + + def _HandleMultiAcquisition(self, requestData: dict) -> dict: + """! @brief Gère la requête AQ-MULTI. """ + componentsList = requestData.get("components", []) + valuesList = [] + for comp in componentsList: + compResult = self._HandleAcquisition(comp) + valuesList.append(compResult["value"] if compResult.get("state") == "ACK" else compResult) + return self._Ack(valuesList) + + def _HandlePiloting(self, requestData: dict) -> dict: + """! @brief Gère la requête de pilotage (par broche uniquement). """ + if requestData.get("register") == "add": + # Les actionneurs sont indexés par broche GPIO (CActuatorManager) : + # aucun pilote d'actionneur I2C n'existe côté carte. + return self._Error("Pilotage par adresse I2C non supporté : les actionneurs sont pilotés par broche") + + pinTarget = self._ParseInt(requestData.get("pin"), "pin") + powerValue = self._ParseInt(requestData.get("value"), "value") + + if isinstance(pinTarget, dict): + return pinTarget + if isinstance(powerValue, dict): + return powerValue + + try: + self.backend.set_pin(pinTarget, powerValue) + return self._Ack(self.backend.read_pin(pinTarget)) + except Exception as exc: + return self._Error(str(exc)) + + @staticmethod + def _Ack(valueData) -> dict: + """! @brief Formate un succès. """ + return {"state": "ACK", "value": valueData} + + @staticmethod + def _Error(errorMessage: str) -> dict: + """! @brief Formate une erreur. """ + logger.debug(f"Réponse d'erreur : {errorMessage}") + return {"state": "ERROR", "value": errorMessage} + + @staticmethod + def _ParseInt(rawValue, fieldName: str): + """! @brief Convertit de manière sécurisée en entier. """ + try: + return int(rawValue) + except (ValueError, TypeError): + return JsonRequestHandler._Error(f"Format invalide pour '{fieldName}'") +#endregion diff --git a/src_raspberry/hardware/__init__.py b/src_raspberry/hardware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src_raspberry/hardware/actuators.py b/src_raspberry/hardware/actuators.py new file mode 100644 index 0000000..d7b95ce --- /dev/null +++ b/src_raspberry/hardware/actuators.py @@ -0,0 +1,307 @@ +#region Imports +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + +# pigpio n'est installé que sur la Raspberry : son absence (PC de démonstration, +# Windows) ne doit pas empêcher le serveur de démarrer en mode simulation. +try: + import pigpio +except ImportError: + pigpio = None + +## @brief Mode « sortie » d'une broche (valeur de pigpio.OUTPUT) +GPIO_OUTPUT = pigpio.OUTPUT if pigpio is not None else 1 +#endregion + +#region Logger +## @brief Logger du module actuators +logger = logging.getLogger(__name__) +#endregion + +#region Classes de Configuration et Exceptions +@dataclass +class CActuatorConfig: + """! + @brief Représente la configuration d'un actionneur. + """ + title: str + name: str + pin: int + driver: str = 'PWM' + pwmFrequency: int = 0 + units: str = '' + minVal: int = 0 + maxVal: int = 255 + address: Optional[int] = None + + @classmethod + def FromDict(cls, data: dict) -> "CActuatorConfig": + """! + @brief Construit un objet de configuration à partir d'un dictionnaire. + @param data Dictionnaire contenant les paramètres. + @return CActuatorConfig + """ + return cls( + title = data["title"], + name = data["name"], + pin = data["pin"], + driver = data.get("driver", "PWM"), + pwmFrequency = data.get("pwm_frequency", 0), + units = data.get("units", ''), + minVal = data.get("min", 0), + maxVal = data.get("max", 255), + address = data.get("address"), + ) + +class CPigpioNotConnectedError(RuntimeError): + """! @brief Exception levée quand pigpiod n'est pas joignable. """ + pass + +class CPinNotFoundError(KeyError): + """! @brief Exception levée quand le pin demandé n'existe pas. """ + pass +#endregion + +#region Pilotes d'actionneurs (classes interchangeables) +class CActuatorDriver(ABC): + """! + @brief Classe de base abstraite pour tous les pilotes d'actionneurs. + + Chaque mode de pilotage (PWM, tout-ou-rien, ...) est une classe interchangeable + enregistrée dans ACTUATOR_DRIVER_REGISTRY et sélectionnée par le champ 'driver' + de la configuration de l'actionneur. C'est ce qui permet d'adapter le serveur + au type de pilotage utilisé par chaque banc de test. + """ + + def __init__(self, piClient, actuatorConfig: CActuatorConfig): + """! + @brief Constructeur d'initialisation. + @param piClient Interface pigpio (ou mock en simulation). + @param actuatorConfig Configuration de l'actionneur piloté. + """ + ## @brief Interface pigpio + self.pi = piClient + ## @brief Configuration de l'actionneur + self.cfg = actuatorConfig + + @abstractmethod + def setup(self) -> None: + """! @brief Initialise la broche matérielle. """ + pass + + @abstractmethod + def set_value(self, value: int) -> None: + """! @brief Applique une consigne à l'actionneur. """ + pass + + @abstractmethod + def get_value(self) -> int: + """! @brief Retourne la valeur/état courant de l'actionneur. """ + pass + + def reset(self) -> None: + """! @brief Remet l'actionneur dans son état de repos (éteint). """ + self.set_value(0) + + +class CPwmActuator(CActuatorDriver): + """! + @brief Actionneur piloté en PWM (rapport cyclique 0-255), via pigpio. + """ + + def setup(self) -> None: + self.pi.set_PWM_frequency(self.cfg.pin, self.cfg.pwmFrequency) + self.pi.set_PWM_dutycycle(self.cfg.pin, 0) + logger.debug("PWM configuré : %s (pin %s, %d Hz)", self.cfg.title, self.cfg.pin, self.cfg.pwmFrequency) + + def set_value(self, value: int) -> None: + clampedValue = max(self.cfg.minVal, min(self.cfg.maxVal, value)) + if clampedValue != value: + logger.warning("Valeur %s hors plage pour %s → bornée à %d.", value, self.cfg.title, clampedValue) + self.pi.set_PWM_dutycycle(self.cfg.pin, clampedValue) + + def get_value(self) -> int: + return int(self.pi.get_PWM_dutycycle(self.cfg.pin)) + + +class CDigitalActuator(CActuatorDriver): + """! + @brief Actionneur tout-ou-rien (TOR), piloté en sortie logique 0/1 via pigpio. + """ + + def setup(self) -> None: + self.pi.set_mode(self.cfg.pin, GPIO_OUTPUT) + self.pi.write(self.cfg.pin, 0) + logger.debug("TOR configuré : %s (pin %s)", self.cfg.title, self.cfg.pin) + + def set_value(self, value: int) -> None: + state = 1 if value >= 1 else 0 + self.pi.write(self.cfg.pin, state) + logger.info("Changement d'état : %s (pin %s) → %d", self.cfg.title, self.cfg.pin, state) + + def get_value(self) -> int: + return int(self.pi.read(self.cfg.pin)) + + +## @brief Registre des pilotes d'actionneurs. +# Pour ajouter un mode de pilotage : créer une classe héritant de CActuatorDriver +# puis l'enregistrer ici sous le nom utilisé dans le champ 'driver' de la config. +ACTUATOR_DRIVER_REGISTRY: dict = { + 'PWM': CPwmActuator, + 'DIGITAL': CDigitalActuator, +} +#endregion + +#region Simulation +class CSimulatedPi: + """! + @brief Remplaçant de pigpio.pi utilisé en mode simulation. + + Mémorise la consigne appliquée à chaque broche pour que la valeur relue soit + celle écrite (un MagicMock renverrait toujours 1). + """ + + ## @brief Toujours « connecté » : aucun démon à joindre + connected = True + + def __init__(self): + ## @brief Dernière consigne appliquée, indexée par broche + self._levels: dict = {} + + def set_mode(self, pin: int, mode: int) -> None: + pass + + def set_PWM_frequency(self, pin: int, frequency: int) -> int: + return frequency + + def set_PWM_dutycycle(self, pin: int, dutycycle: int) -> None: + self._levels[pin] = dutycycle + + def get_PWM_dutycycle(self, pin: int) -> int: + return self._levels.get(pin, 0) + + def write(self, pin: int, level: int) -> None: + self._levels[pin] = level + + def read(self, pin: int) -> int: + return self._levels.get(pin, 0) + + def stop(self) -> None: + pass +#endregion + +#region Gestionnaire Actionneurs +class CActuatorManager: + """! + @brief Gestionnaire des actionneurs via GPIO. + + Instancie pour chaque actionneur le pilote indiqué par sa configuration + (champ 'driver'), ce qui permet de mélanger des modes de pilotage différents + sur un même banc sans changer cette classe. + """ + + def __init__(self, actuatorsConfig: list): + """! + @brief Initialise la connexion au démon pigpio et charge la configuration. + @param actuatorsConfig Liste des dictionnaires de configuration des actionneurs. + """ + ## @brief Interface de communication avec la librairie pigpio + self.piClient = pigpio.pi() if pigpio is not None else None + + if self.piClient is None or not self.piClient.connected: + reason = "pigpio non installé" if pigpio is None else "démon pigpiod injoignable" + logger.warning("%s : actionneurs en mode simulation.", reason) + self.piClient = CSimulatedPi() + + ## @brief Dictionnaire des pilotes d'actionneurs indexé par PIN + self._actuators: dict = {} + + self._CheckConnected() + logger.info("Connexion à pigpio établie.") + self._LoadConfig(actuatorsConfig) + + def _CheckConnected(self) -> None: + """! + @brief Vérifie la connexion matérielle. + @raises CPigpioNotConnectedError Si non connecté. + """ + if not self.piClient.connected: + raise CPigpioNotConnectedError("Impossible de se connecter à pigpiod.") + + def _LoadConfig(self, actuatorsConfig: list) -> None: + """! + @brief Instancie le pilote adapté à chaque actionneur configuré. + @param actuatorsConfig Liste des dictionnaires de configuration. + """ + for rawConfig in actuatorsConfig: + actuatorDef = CActuatorConfig.FromDict(rawConfig) + driverClass = ACTUATOR_DRIVER_REGISTRY.get(actuatorDef.driver) + if driverClass is None: + logger.warning("Driver actionneur '%s' inconnu (pin %s) — ignoré.", + actuatorDef.driver, actuatorDef.pin) + continue + actuator = driverClass(self.piClient, actuatorDef) + actuator.setup() + self._actuators[actuatorDef.pin] = actuator + logger.info(f"{len(self._actuators)} actionneur(s) configuré(s).") + + def GetActuatorsInfo(self) -> list: + """! + @brief Retourne la liste des configurations d'actionneurs. + @return list de CActuatorConfig + """ + return [actuator.cfg for actuator in self._actuators.values()] + + def SetPin(self, pinTarget: int, powerValue: int) -> None: + """! + @brief Applique une consigne à un actionneur. + @param pinTarget Numéro GPIO BCM. + @param powerValue Consigne souhaitée. + """ + self._CheckConnected() + self._GetActuator(pinTarget).set_value(powerValue) + + def GetPinValue(self, pinTarget: int) -> int: + """! + @brief Retourne la valeur/état actuel du pin. + @param pinTarget Numéro GPIO. + @return int Valeur courante. + """ + self._CheckConnected() + actuator = self._GetActuator(pinTarget) + try: + return actuator.get_value() + except Exception as exc: + raise RuntimeError(f"Impossible de lire la valeur du pin {pinTarget}.") from exc + + def Cleanup(self) -> None: + """! + @brief Éteint tous les actionneurs et ferme la connexion. + """ + if not self.piClient.connected: + return + logger.info("Arrêt des actionneurs...") + for actuator in self._actuators.values(): + actuator.reset() + self.piClient.stop() + logger.info("Connexion pigpio fermée.") + + def __enter__(self) -> "CActuatorManager": + return self + + def __exit__(self, *_) -> None: + self.Cleanup() + + def _GetActuator(self, pinTarget: int) -> CActuatorDriver: + """! + @brief Récupère le pilote d'un actionneur selon son PIN. + @param pinTarget Numéro de broche. + @return CActuatorDriver + """ + try: + return self._actuators[pinTarget] + except KeyError: + raise CPinNotFoundError(f"Pin {pinTarget} introuvable.") +#endregion diff --git a/src_raspberry/hardware/backend.py b/src_raspberry/hardware/backend.py new file mode 100644 index 0000000..c98a799 --- /dev/null +++ b/src_raspberry/hardware/backend.py @@ -0,0 +1,165 @@ +#region Imports +import dataclasses +import logging +from typing import Optional + +from .base import IHardwareBackend +from .scanner import CScanner +from .sensors import SENSOR_DRIVER_REGISTRY, SIMULATED_READ_TIME, CThermalModel +from .actuators import CActuatorManager +#endregion + +#region Logger +## @brief Logger du module backend +logger = logging.getLogger(__name__) +#endregion + + +#region Backend matériel +class HardwareBackend(IHardwareBackend): + """! + @brief Communication avec les composants d'un banc de test. + + Construit la cartographie des capteurs (via SENSOR_DRIVER_REGISTRY et la + configuration) et le gestionnaire d'actionneurs. Toute la spécificité du banc + provient de la configuration injectée ; cette classe ne dépend d'aucun modèle + de Raspberry particulier. + """ + + def __init__(self, configModule): + """! + @brief Constructeur d'initialisation. + @param configModule Référence au module de configuration du banc. + """ + isSimulationEnv = False + try: + # Import local : smbus2 dépend de fcntl (Unix uniquement). Sous Windows, + # l'import échoue et le serveur doit tout de même démarrer en simulation. + from smbus2 import SMBus + i2cBus = SMBus(configModule.I2C_BUS_ID) + except (ImportError, PermissionError, FileNotFoundError, OSError) as exc: + from unittest.mock import MagicMock + i2cBus = MagicMock() + isSimulationEnv = True + # WARNING : sur une vraie Raspberry, cela signale une dépendance ou un + # bus manquant, et les valeurs servies seront simulées. + logger.warning("Mode simulation activé pour l'I2C (%s).", exc) + + ## @brief Module de configuration du banc + self.config = configModule + ## @brief Instance de communication sur bus + self.bus = i2cBus + ## @brief Gestionnaire des actionneurs + self.actuatorManager = CActuatorManager(configModule.ACTUATORS_CONFIG) + ## @brief Dictionnaire des capteurs instanciés indexé par adresse + self.sensorMap = self._BuildSensorMap(self.bus, isSimulationEnv) + + def _BuildSensorMap(self, targetBus, isSimulation: bool = False) -> dict: + """! + @brief Scanne le bus I2C et instancie les drivers des capteurs détectés. + @param targetBus Objet bus. + @param isSimulation Flag indiquant si l'on est en simulation. + @return Dictionnaire de capteurs indexé par adresse. + """ + instantiatedMap = {} + detectedList = [] + + if not isSimulation: + detectedList = CScanner.ScanBus(self.config.I2C_BUS_ID) + if not detectedList: + logger.warning("Bascule en simulation automatique, aucun matériel.") + isSimulation = True + + if isSimulation: + detectedList = [a for a in self.config.SENSORS_CONFIG.keys() if isinstance(a, int)] + + for currentAddr in detectedList: + sensorConfig = self.config.SENSORS_CONFIG.get(currentAddr) + if sensorConfig is None: + continue + + driverName = 'SIMULE' if isSimulation else sensorConfig['driver'] + driverClass = SENSOR_DRIVER_REGISTRY.get(driverName) + + if driverClass: + instantiatedMap[currentAddr] = driverClass(targetBus, currentAddr) + logger.info(f"Capteur '{sensorConfig.get('name', '?')}' ({driverName}) instancié @ {hex(currentAddr)}") + else: + logger.warning("Driver '%s' inconnu pour %s — ignoré.", driverName, hex(currentAddr)) + + if isSimulation: + # Simulation uniquement : les capteurs simulés partagent un modèle thermique + # piloté par les actionneurs (la résistance chauffe, le ventilateur refroidit) + thermalModel = CThermalModel(self.actuatorManager, self.config.ACTUATORS_CONFIG) + for currentAddr, sensorObj in instantiatedMap.items(): + sensorConfig = self.config.SENSORS_CONFIG[currentAddr] + sensorObj.AttachModel(thermalModel, + sensorConfig.get('sim_coupling', 0.5), + 'hum' if sensorConfig.get('units') == 'RH' else 'temp', + SIMULATED_READ_TIME.get(sensorConfig.get('driver'), 0.002)) + + return instantiatedMap + + def scan(self) -> dict: + """! + @brief Décrit le matériel disponible (actionneurs et capteurs). + @return Dictionnaire {'actuator': [...], 'detector': [...]}. + """ + actuatorsInfo = [] + for actuatorObj in self.actuatorManager.GetActuatorsInfo(): + actuatorDict = dataclasses.asdict(actuatorObj) + actuatorDict['frequency'] = actuatorDict.pop('pwmFrequency') + actuatorsInfo.append(actuatorDict) + + detectorsInfo = [] + for addr in self.sensorMap: + sensorCfg = self.config.SENSORS_CONFIG.get(addr) + if not sensorCfg: + continue + detectorsInfo.append({ + "name": sensorCfg.get('name', 'unknown'), + "units": sensorCfg.get('units', ''), + "address": hex(addr), + "pin": None, + }) + + return {"actuator": actuatorsInfo, "detector": detectorsInfo} + + def read_sensor(self, address: int, channel: Optional[str] = None) -> Optional[float]: + """! + @brief Lit la valeur d'un capteur par son adresse. + @param address Adresse du capteur. + @param channel Canal de mesure optionnel. + @return La valeur lue, ou None en cas d'échec de lecture. + @raises KeyError Si aucun capteur n'est connu à cette adresse. + """ + sensorObj = self.sensorMap.get(address) + if sensorObj is None: + raise KeyError(f"Capteur introuvable @ {hex(address)}") + if channel: + return sensorObj.ReadValue(channel=channel) + return sensorObj.ReadValue() + + def read_pin(self, pin: int) -> int: + """! + @brief Lit la valeur courante d'un actionneur par sa broche. + @param pin Numéro GPIO. + @return Valeur courante. + """ + return self.actuatorManager.GetPinValue(pin) + + def set_pin(self, pin: int, value: int) -> None: + """! + @brief Applique une consigne à un actionneur par sa broche. + @param pin Numéro GPIO. + @param value Consigne à appliquer. + """ + self.actuatorManager.SetPin(pin, value) + + def cleanup(self) -> None: + """! + @brief Interrompt proprement la communication matérielle. + """ + self.actuatorManager.Cleanup() + self.bus.close() +#endregion diff --git a/src_raspberry/hardware/base.py b/src_raspberry/hardware/base.py new file mode 100644 index 0000000..91ff604 --- /dev/null +++ b/src_raspberry/hardware/base.py @@ -0,0 +1,61 @@ +#region Imports +from abc import ABC, abstractmethod +from typing import Optional +#endregion + + +#region Interface Communication composants +class IHardwareBackend(ABC): + """! + @brief Couche de communication avec les composants (capteurs et actionneurs). + + Toute la spécificité d'un banc de test (capteurs présents, mode de pilotage + des actionneurs : PWM, tout-ou-rien, ...) est encapsulée ici. Le banc est + décrit par la configuration ; cette interface reste identique quel que soit + le matériel sous-jacent, ce qui rend le backend interchangeable. + """ + + @abstractmethod + def scan(self) -> dict: + """! + @brief Décrit le matériel disponible. + @return Dictionnaire {'actuator': [...], 'detector': [...]}. + """ + raise NotImplementedError + + @abstractmethod + def read_sensor(self, address: int, channel: Optional[str] = None) -> Optional[float]: + """! + @brief Lit la valeur d'un capteur par son adresse. + @param address Adresse du capteur. + @param channel Canal de mesure optionnel. + @return La valeur lue, ou None en cas d'échec de lecture. + @raises KeyError Si aucun capteur n'est connu à cette adresse. + """ + raise NotImplementedError + + @abstractmethod + def read_pin(self, pin: int) -> int: + """! + @brief Lit la valeur/état courant d'un actionneur par sa broche. + @param pin Numéro de broche (GPIO BCM). + @return La valeur courante de l'actionneur. + """ + raise NotImplementedError + + @abstractmethod + def set_pin(self, pin: int, value: int) -> None: + """! + @brief Applique une consigne à un actionneur par sa broche. + @param pin Numéro de broche (GPIO BCM). + @param value Consigne à appliquer. + """ + raise NotImplementedError + + @abstractmethod + def cleanup(self) -> None: + """! + @brief Libère proprement les ressources matérielles. + """ + raise NotImplementedError +#endregion diff --git a/src_raspberry/hardware/scanner.py b/src_raspberry/hardware/scanner.py new file mode 100644 index 0000000..0fadd15 --- /dev/null +++ b/src_raspberry/hardware/scanner.py @@ -0,0 +1,42 @@ +#region Imports +import logging +#endregion + +#region Logger +## @brief Logger du module scanner +logger = logging.getLogger(__name__) +#endregion + +#region Classes +class CScanner: + """! + @brief Classe utilitaire pour scanner le bus I2C. + """ + + @staticmethod + def ScanBus(busId: int) -> list: + """! + @brief Parcourt toutes les adresses I2C possibles pour identifier les capteurs connectés. + @param busId Identifiant du bus I2C matériel. + @return list Liste des adresses (int) qui ont répondu. + """ + detectedAddresses = [] + logger.info("Scan du bus I2C...") + + try: + # Import local : smbus2 est indisponible hors Linux (dépend de fcntl) + from smbus2 import SMBus + with SMBus(busId) as bus: + # La plage standard I2C va de 0x03 à 0x77 + for currentAddress in range(0x03, 0x78): + try: + bus.write_byte(currentAddress, 0) + detectedAddresses.append(currentAddress) + logger.info(" -> Périphérique détecté à l'adresse : %s", hex(currentAddress)) + except OSError: + pass + except Exception as exc: + logger.error("Erreur critique lors du scan du bus : %s", exc) + + return detectedAddresses +#endregion diff --git a/src_raspberry/hardware/sensors.py b/src_raspberry/hardware/sensors.py new file mode 100644 index 0000000..11bd6dd --- /dev/null +++ b/src_raspberry/hardware/sensors.py @@ -0,0 +1,333 @@ +#region Imports +import math +import random +import time +import logging +from abc import ABC, abstractmethod +#endregion + +#region Logger +## @brief Logger pour le module sensors +logger = logging.getLogger(__name__) +#endregion + +#region Interfaces +class CSensorDriver(ABC): + """! + @brief Classe de base abstraite pour tous les pilotes de capteurs. + + Chaque pilote est une classe interchangeable, enregistrée dans + SENSOR_DRIVER_REGISTRY et sélectionnée par la configuration du banc. + """ + + def __init__(self, bus, addr: int): + """! + @brief Constructeur d'initialisation. + @param bus Objet bus I2C. + @param addr Adresse du composant. + """ + ## @brief Référence vers le bus de communication + self.bus = bus + ## @brief Adresse I2C du capteur + self.addr = addr + + @abstractmethod + def ReadValue(self, **kwargs): + """! + @brief Lit et retourne la valeur du capteur. + @return Valeur lue ou None en cas d'erreur. + """ + pass +#endregion + +#region Implémentations Drivers +class CDriverAht10(CSensorDriver): + """! + @brief Pilote pour le capteur de température et d'humidité AHT10. + """ + + def __init__(self, bus, addr: int): + super().__init__(bus, addr) + try: + self.bus.write_byte(self.addr, 0xBA) + time.sleep(0.02) + self.bus.write_i2c_block_data(self.addr, 0xE1, [0x08, 0x00]) + time.sleep(0.05) + logger.debug("AHT10 @%s initialisé.", hex(addr)) + except Exception as exc: + logger.error("Erreur init AHT10 @%s : %s", hex(addr), exc) + + def _ReadRawData(self) -> list: + """! + @brief Déclenche une mesure et retourne les octets bruts. + @return Liste des octets lus. + """ + self.bus.write_i2c_block_data(self.addr, 0xAC, [0x33, 0x00]) + time.sleep(0.08) + return self.bus.read_i2c_block_data(self.addr, 0x00, 6) + + def ReadValue(self, channel: str = 'hum') -> float: + """! + @brief Lit une valeur du capteur AHT10. + @param channel 'hum' pour l'humidité ou 'temp' pour la température. + @return Valeur flottante ou None. + """ + try: + rawData = self._ReadRawData() + if channel == 'hum': + humRaw = (rawData[1] << 12) | (rawData[2] << 4) | (rawData[3] >> 4) + return round((humRaw / 1_048_576.0) * 100, 2) + + tempRaw = ((rawData[3] & 0x0F) << 16) | (rawData[4] << 8) | rawData[5] + return round((tempRaw / 1_048_576.0) * 200 - 50, 2) + except Exception as exc: + logger.warning("Erreur lecture AHT10 @%s : %s", hex(self.addr), exc) + return None + +class CDriverTmp102(CSensorDriver): + """! + @brief Pilote pour le capteur de température de précision TMP102. + """ + + def ReadValue(self, **_) -> float: + """! + @brief Lit le registre de température sur 12 bits. + @return Température en degrés Celsius ou None. + """ + try: + rawWord = self.bus.read_word_data(self.addr, 0x00) + rawWord = ((rawWord << 8) & 0xFF00) | (rawWord >> 8) + tempRaw = rawWord >> 4 + if tempRaw & 0x800: + tempRaw -= 4096 + return round(tempRaw * 0.0625, 2) + except Exception as exc: + logger.warning("Erreur lecture TMP102 @%s : %s", hex(self.addr), exc) + return None + +class CDriverEmc2101(CSensorDriver): + """! + @brief Pilote pour la température interne du contrôleur EMC2101. + """ + + def ReadValue(self, **_) -> float: + """! + @brief Retourne la température interne (registre 0x00). + @return Température en degrés Celsius ou None. + """ + try: + tempRaw = self.bus.read_byte_data(self.addr, 0x00) + if tempRaw > 127: + tempRaw -= 256 + return float(tempRaw) + except Exception as exc: + logger.warning("Erreur lecture EMC2101 @%s : %s", hex(self.addr), exc) + return None + +class CThermalModel: + """! + @brief Modèle thermique grossier du banc, utilisé uniquement en simulation. + + Modèle du premier ordre : la résistance chauffe le banc, les pertes vers + l'ambiant augmentent avec le ventilateur. L'échauffement est intégré de façon + exacte entre deux lectures ; chaque capteur simulé le lit avec son propre + couplage, sur une ambiance qui dérive lentement, avec un léger bruit. + """ + + ## @brief Température ambiante moyenne (°C) + AMBIENT = 22.0 + ## @brief Amplitude de la dérive lente de l'ambiance (°C) + DRIFT = 0.5 + ## @brief Période de la dérive de l'ambiance (s) + DRIFT_PERIOD = 600.0 + ## @brief Échauffement à pleine chauffe sans ventilation (°C) + HEATER_RISE = 40.0 + ## @brief Multiplicateur des pertes ajouté par le ventilateur à plein régime + FAN_GAIN = 2.3 + ## @brief Constante de temps sans ventilation (s) + TIME_CONSTANT = 60.0 + ## @brief Écart-type du bruit de mesure (°C) + NOISE = 0.05 + + def __init__(self, actuatorManager, actuatorsConfig: list): + """! + @brief Constructeur d'initialisation. + @param actuatorManager Gestionnaire d'actionneurs, lu pour connaître les consignes. + @param actuatorsConfig Configuration des actionneurs (repérés par leur nom). + """ + self._manager = actuatorManager + ## @brief Actionneur chauffant et ventilateur (None si absents de la configuration) + self._heater = self._FindActuator(actuatorsConfig, ('resist', 'chauff', 'heat')) + self._fan = self._FindActuator(actuatorsConfig, ('ventil', 'fan')) + self._start = self._last = time.monotonic() + ## @brief Échauffement courant du banc au-dessus de l'ambiance (°C) + self._rise = 0.0 + + @staticmethod + def _FindActuator(actuatorsConfig: list, keywords: tuple): + """! @brief Premier actionneur dont le nom contient un des mots-clés. """ + for actuator in actuatorsConfig: + if any(k in str(actuator.get('name', '')).lower() for k in keywords): + return actuator + return None + + def _Level(self, actuator) -> float: + """! @brief Consigne courante de l'actionneur, ramenée entre 0 et 1. """ + if actuator is None: + return 0.0 + try: + low, high = float(actuator.get('min', 0)), float(actuator.get('max', 1)) + value = float(self._manager.GetPinValue(actuator['pin'])) + return min(1.0, max(0.0, (value - low) / (high - low))) if high > low else 0.0 + except Exception: + return 0.0 + + def _Update(self) -> float: + """! @brief Fait évoluer l'échauffement jusqu'à maintenant. @return L'instant courant. """ + now = time.monotonic() + elapsed, self._last = now - self._last, now + losses = 1.0 + self.FAN_GAIN * self._Level(self._fan) + target = self.HEATER_RISE * self._Level(self._heater) / losses + self._rise = target + (self._rise - target) * math.exp(-elapsed * losses / self.TIME_CONSTANT) + return now + + def _Ambient(self, now: float) -> float: + """! @brief Ambiance qui dérive lentement autour de AMBIENT. """ + return self.AMBIENT + self.DRIFT * math.sin(2 * math.pi * (now - self._start) / self.DRIFT_PERIOD) + + def Temperature(self, coupling: float) -> float: + """! @brief Température vue par un capteur plus ou moins proche de la résistance (couplage 0 à 1). """ + now = self._Update() + return round(self._Ambient(now) + coupling * self._rise + random.gauss(0.0, self.NOISE), 2) + + def Humidity(self, coupling: float) -> float: + """! @brief Humidité relative : elle baisse quand l'air se réchauffe (environ -1,5 %RH/°C). """ + self._Update() + humidity = 50.0 - 1.5 * coupling * self._rise + random.gauss(0.0, 0.2) + return round(min(100.0, max(0.0, humidity)), 2) + + +## @brief Durée d'une lecture réelle sur Raspberry, par pilote (s), reproduite en simulation. +# Sans elle, le serveur simulé répond instantanément et une acquisition continue sans +# temps d'attente enchaîne des milliers de requêtes par seconde, au point de figer +# l'interface PyMoDAQ ; le vrai banc est naturellement limité par ces durées. +SIMULATED_READ_TIME: dict = { + 'AHT10': 0.080, # attente de fin de mesure imposée par le capteur (voir CDriverAht10) + 'TMP102': 0.002, # transaction I2C + surcoût Python sur Raspberry + 'EMC2101': 0.002, + 'PT-100': 0.008, # conversion de l'ADS1115 à 128 échantillons/s +} + + +class CDriverSimule(CSensorDriver): + """! + @brief Pilote de test pour retourner des valeurs simulées. + + Relié à un CThermalModel, il renvoie des valeurs qui évoluent de façon + plausible et réagissent aux actionneurs ; sinon, des valeurs aléatoires. + """ + + def __init__(self, bus, addr: int): + super().__init__(bus, addr) + ## @brief Modèle thermique partagé (None : valeurs aléatoires indépendantes) + self.model = None + ## @brief Couplage du capteur à la résistance (0 : ambiance, 1 : au contact) + self.coupling = 0.5 + ## @brief Canal lu quand la requête n'en précise pas ('temp' ou 'hum') + self.defaultChannel = 'temp' + ## @brief Durée simulée d'une lecture (s) + self.readTime = 0.002 + + def AttachModel(self, model: CThermalModel, coupling: float, defaultChannel: str = 'temp', + readTime: float = 0.002) -> None: + """! + @brief Relie le capteur simulé au modèle thermique du banc. + @param model Modèle thermique partagé par les capteurs simulés. + @param coupling Couplage du capteur à la résistance (0 à 1). + @param defaultChannel Canal lu par défaut ('hum' pour un capteur d'humidité). + @param readTime Durée simulée d'une lecture, celle du vrai capteur (s). + """ + self.model = model + self.coupling = coupling + self.defaultChannel = defaultChannel + self.readTime = readTime + + def ReadValue(self, channel: str = None) -> float: + """! + @brief Retourne des valeurs simulées. + @param channel Canal de mesure souhaité. + @return Valeur simulée. + """ + time.sleep(self.readTime) # durée d'une vraie lecture : le serveur n'est pas plus rapide que le banc + channel = channel or self.defaultChannel + if self.model is not None: + if channel == 'hum': + return self.model.Humidity(self.coupling) + return self.model.Temperature(self.coupling) + if channel == 'hum': + return round(random.uniform(40.0, 60.0), 2) + return round(random.uniform(20.0, 25.0), 2) + +class CDriverPt100(CSensorDriver): + """! + @brief Pilote pour une sonde de température PT100 lue via un CAN ADS1115. + + La PT100 ne communique pas directement sur le bus I2C du serveur : elle est + lue au travers d'un convertisseur analogique-numérique ADS1115 (librairie + Adafruit_ADS1x15). L'argument `bus` n'est donc pas utilisé, mais conservé + pour respecter l'interface commune des pilotes de capteurs. + """ + + def __init__(self, bus, addr: int): + super().__init__(bus, addr) + # Import local pour isoler la dépendance optionnelle Adafruit_ADS1x15 : + # le module sensors reste importable même si la librairie est absente. + try: + import Adafruit_ADS1x15 + self._adc = Adafruit_ADS1x15.ADS1115() + ## @brief Gain de l'amplificateur du CAN + self.GAIN = 1 + ## @brief Tension d'alimentation du pont (V) + self.TENSION_VA = 3.29 + ## @brief Valeur brute maximale du CAN + self.MAXI = 26300.0 + ## @brief Résistance de pont (Ohm) + self.RP = 97.7 + ## @brief Résistance nominale de la PT100 à 0 °C (Ohm) + self.R0 = 100.0 + ## @brief Coefficient de température de la platine + self.ALPHA = 0.00385 + except Exception as exc: + logger.error("Erreur d'initialisation de l'ADS1115 (PT100) : %s", exc) + + def ReadValue(self, **_) -> float: + """! + @brief Lit la température via le CAN et la loi de la platine. + @return Température en degrés Celsius (0.0 en cas d'échec). + """ + try: + rawValue = self._adc.read_adc(0, gain=self.GAIN) # canal A0 + tensionPT100 = (rawValue / self.MAXI) * self.TENSION_VA + denominateur = self.TENSION_VA - tensionPT100 + if abs(denominateur) < 0.001: + return 0.0 + Rpt100 = (tensionPT100 * self.RP) / denominateur # résistance du pont + temperature = (Rpt100 - self.R0) / (self.R0 * self.ALPHA) # loi de la platine + return round(temperature, 2) + except Exception as exc: + logger.warning("Échec de lecture PT100 : %s", exc) + return 0.0 +#endregion + +#region Registre +## @brief Dictionnaire regroupant l'ensemble des drivers de capteurs. +# Pour ajouter un capteur : créer une classe héritant de CSensorDriver puis +# l'enregistrer ici sous le nom utilisé dans le champ 'driver' de la config. +SENSOR_DRIVER_REGISTRY: dict = { + 'AHT10': CDriverAht10, + 'TMP102': CDriverTmp102, + 'EMC2101': CDriverEmc2101, + 'PT-100': CDriverPt100, + 'SIMULE': CDriverSimule, +} +#endregion diff --git a/src_raspberry/main.py b/src_raspberry/main.py new file mode 100644 index 0000000..e5dc789 --- /dev/null +++ b/src_raspberry/main.py @@ -0,0 +1,75 @@ +#region Imports +import argparse +import logging +import os +import sys + +# Permet de lancer « python main.py » depuis n'importe quel répertoire en +# rendant les sous-paquets (transport, handlers, hardware) importables. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import config +from hardware.backend import HardwareBackend +from handlers.json_handler import JsonRequestHandler +from transport.zmq_server import ZmqServer +#endregion + +#region Configuration Log +## @brief Configuration globale du journal des évènements. +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)-7s] %(name)s — %(message)s', + datefmt='%H:%M:%S', +) +logger = logging.getLogger(__name__) +#endregion + +#region Main Application +def main() -> None: + """! + @brief Point d'entrée principal : assemble les trois couches et lance le serveur. + + Topologie : + ZmqServer (transport) → JsonRequestHandler (requêtes) → HardwareBackend (composants) + + Le main est le seul maillon non interchangeable : il instancie chaque couche + et les câble entre elles. + """ + parser = argparse.ArgumentParser(description="Serveur d'acquisition et de pilotage Raspberry pour PyMoDAQ") + parser.add_argument("--port", type=int, default=5555, help="port d'écoute TCP (5555 par défaut)") + parser.add_argument("--verbose", action="store_true", help="journalise chaque requête et sa réponse") + args = parser.parse_args() + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + logger.info("Initialisation de la communication composants...") + hardwareBackend = HardwareBackend(config) + + logger.info("Initialisation de la gestion des requêtes...") + requestHandler = JsonRequestHandler(hardwareBackend) + + logger.info("Initialisation du serveur réseau...") + transport = ZmqServer(requestHandler, listenPort=args.port) + + try: + # Le serveur bloque le thread principal en écoutant + transport.start() + + except KeyboardInterrupt: + logger.info("Arrêt demandé par l'utilisateur.") + finally: + logger.info("Fermeture du serveur...") + try: + transport.stop() + except Exception as exc: + logger.error(f"Erreur fermeture serveur : {exc}") + + logger.info("Fermeture de la communication composants...") + try: + hardwareBackend.cleanup() + except Exception as exc: + logger.error(f"Erreur fermeture matériel : {exc}") + +if __name__ == "__main__": + main() +#endregion diff --git a/src_raspberry/requirements.txt b/src_raspberry/requirements.txt new file mode 100644 index 0000000..538927a --- /dev/null +++ b/src_raspberry/requirements.txt @@ -0,0 +1,3 @@ +pyzmq +smbus2 +pigpio diff --git a/src_raspberry/transport/__init__.py b/src_raspberry/transport/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src_raspberry/transport/base.py b/src_raspberry/transport/base.py new file mode 100644 index 0000000..403b59e --- /dev/null +++ b/src_raspberry/transport/base.py @@ -0,0 +1,30 @@ +#region Imports +from abc import ABC, abstractmethod +#endregion + + +#region Interface Transport +class ITransport(ABC): + """! + @brief Couche de communication avec le client PyMoDAQ. + + Un transport reçoit des requêtes brutes (chaînes de caractères) depuis le + réseau, délègue leur traitement à un IRequestHandler, puis renvoie la réponse + au client. Cette interface permet de remplacer ZeroMQ par un autre moyen de + communication (série, HTTP, ...) sans toucher au reste de l'application. + """ + + @abstractmethod + def start(self) -> None: + """! + @brief Démarre le transport et bloque sur la boucle d'écoute. + """ + raise NotImplementedError + + @abstractmethod + def stop(self) -> None: + """! + @brief Ferme proprement le transport et libère les ressources réseau. + """ + raise NotImplementedError +#endregion diff --git a/src_raspberry/transport/zmq_server.py b/src_raspberry/transport/zmq_server.py new file mode 100644 index 0000000..0228639 --- /dev/null +++ b/src_raspberry/transport/zmq_server.py @@ -0,0 +1,153 @@ +#region Imports +import logging +import socket as sys_socket +import threading + +import zmq +from zmq.utils.monitor import recv_monitor_message + +from .base import ITransport +#endregion + +#region Logger +## @brief Logger du module de transport ZeroMQ +logger = logging.getLogger(__name__) +#endregion + + +#region Surveillance des connexions +def monitor_connections(monitor_socket) -> None: + """! + @brief Surveille les connexions/déconnexions TCP dans un thread séparé. + @param monitor_socket Socket de monitoring ZMQ. + """ + logger.info("Surveillance des connexions activée.") + try: + while True: + if not monitor_socket.poll(timeout=500): + continue + try: + evt = recv_monitor_message(monitor_socket) + if evt['event'] == zmq.EVENT_ACCEPTED: + try: + s = sys_socket.fromfd(evt['value'], sys_socket.AF_INET, sys_socket.SOCK_STREAM) + ip = s.getpeername()[0] + s.close() + except Exception: + ip = evt['endpoint'] + logger.info("NOUVEAU CLIENT : %s", ip) + elif evt['event'] == zmq.EVENT_DISCONNECTED: + logger.info("DÉCONNEXION : %s", evt['endpoint']) + except zmq.error.ContextTerminated: + break + except Exception as exc: + logger.error("Erreur moniteur : %s", exc) + except zmq.error.ZMQError: + # Contexte terminé ou socket fermé par stop() : fin normale de la surveillance + pass + logger.info("Fin de la surveillance des connexions.") +#endregion + + +#region Serveur ZMQ +class ZmqServer(ITransport): + """! + @brief Transport gérant la communication avec le client PyMoDAQ via ZeroMQ. + + Ce transport ne s'occupe que du réseau et du framing des messages. Le contenu + de chaque requête est délégué à un IRequestHandler, ce qui le rend totalement + indépendant du protocole applicatif et du matériel. + """ + + def __init__(self, requestHandler, listenPort: int = 5555): + """! + @brief Constructeur d'initialisation. + @param requestHandler Instance de IRequestHandler à qui déléguer les requêtes. + @param listenPort Port d'écoute TCP. + """ + ## @brief Gestionnaire de requêtes délégué + self.handler = requestHandler + ## @brief Port d'écoute + self.port = listenPort + self.context = None + self.zmq_socket = None + self.monitor_socket = None + + def start(self) -> None: + """! + @brief Initialise les sockets et lance la boucle d'écoute (bloquant). + """ + self.context = zmq.Context() + self.zmq_socket = self.context.socket(zmq.ROUTER) + self.zmq_socket.bind(f"tcp://*:{self.port}") + logger.info("Serveur ZMQ ROUTER démarré sur le port %d.", self.port) + + # Thread de surveillance des connexions + self.monitor_socket = self.zmq_socket.get_monitor_socket() + threading.Thread( + target=monitor_connections, + args=(self.monitor_socket,), + daemon=True, + ).start() + + logger.info("SERVEUR PRÊT (Ctrl+C pour arrêter)") + self._run_loop() + + def _run_loop(self) -> None: + """! + @brief Boucle principale de réception et de traitement des messages. + """ + while True: + # Attente bornée : un recv bloquant ne rend jamais la main à Python, + # et Ctrl+C resterait sans effet (notamment sous Windows). + if not self.zmq_socket.poll(timeout=500): + continue + frames = self.zmq_socket.recv_multipart() + + # Détection du format (REQ = 3 frames avec délimiteur vide, DEALER = 2 frames) + client_id = frames[0] + if len(frames) == 3 and frames[1] == b'': + use_delimiter = True + message_bytes = frames[2] + elif len(frames) == 2: + use_delimiter = False + message_bytes = frames[1] + else: + logger.warning("Paquet malformé reçu (%d frames) — ignoré.", len(frames)) + continue + + try: + command_str = message_bytes.decode('utf-8') + except UnicodeDecodeError: + logger.error("Paquet non UTF-8 reçu — ignoré.") + continue + + # DEBUG : une ligne par requête inonderait la console (python main.py --verbose pour les voir) + logger.debug("← (%s) %s", client_id.hex()[:8], command_str) + + # Délégation à la couche de gestion des requêtes + response_str = self.handler.handle(command_str) + + logger.debug("→ %s", response_str) + + reply_frames = [client_id] + if use_delimiter: + reply_frames.append(b'') + reply_frames.append(response_str.encode('utf-8')) + self.zmq_socket.send_multipart(reply_frames) + + def stop(self) -> None: + """! + @brief Ferme proprement les connexions ZMQ. + """ + logger.info("Arrêt du serveur ZMQ...") + for sock in (self.zmq_socket, self.monitor_socket): + try: + if sock: + sock.setsockopt(zmq.LINGER, 0) + sock.close() + except Exception: + pass + if self.context: + self.context.term() +#endregion diff --git a/tests/test_link_zmq.py b/tests/test_link_zmq.py new file mode 100644 index 0000000..d308d3f --- /dev/null +++ b/tests/test_link_zmq.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" +Tests of the PyMoDAQ <-> Raspberry chain without hardware. + +A fake ROUTER server runs in a thread and answers like the raspberry's script; +a real ZMQLink is plugged to it. Written with unittest: runs with +`python -m unittest discover tests` (no extra package) and with pytest. +""" +import json +import math +import threading +import time +import unittest + +import zmq + +from pymodaq_plugins_raspberry.hardware.link_zmq import ZMQLink + + +class FakeRaspberry: + """Fake raspberry's script: a ROUTER socket answering with a user-given function""" + + def __init__(self, answer): + self.answer = answer # function(request: dict) -> response dict, or None to stay silent + self.requests = [] + self.context = zmq.Context() + self.socket = self.context.socket(zmq.ROUTER) + self.port = self.socket.bind_to_random_port("tcp://127.0.0.1") + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def _serve(self): + while True: + try: + if not self.socket.poll(100): + continue + client_id, message = self.socket.recv_multipart() + except zmq.ZMQError: + return # socket closed by stop() + request = json.loads(message) + self.requests.append(request) + response = self.answer(request) + if response is not None: + self.socket.send_multipart([client_id, json.dumps(response).encode('utf-8')]) + + def stop(self): + self.socket.close(linger=0) + self.thread.join(timeout=1) + self.context.term() + + +def bench_answer(request: dict): + """Answers like the simulated bench: one sensor at 0x48, one actuator on pin 18""" + if request["type"] == "scan": + return {"state": "ACK", "value": {"actuator": [], "detector": []}} + if request["type"] == "AQ-MULTI": + values = [] + for component in request["components"]: + if component.get("add") == "0x48": + values.append(21.5) + else: + values.append({"state": "ERROR", "value": "Capteur introuvable"}) + return {"state": "ACK", "value": values} + if request["type"] == "PI": + if request.get("pin") == "18": + return {"state": "ACK", "value": int(request["value"])} + return {"state": "ERROR", "value": f"Pin {request.get('pin')} introuvable."} + return {"state": "ERROR", "value": f"Type de requête inconnu : '{request['type']}'"} + + +def within(seconds, function, *args, **kwargs): + """Call function in a thread: a blocked call fails the test instead of freezing the whole run""" + result = {} + thread = threading.Thread(target=lambda: result.update(value=function(*args, **kwargs)), daemon=True) + thread.start() + thread.join(seconds) + if thread.is_alive(): + raise AssertionError(f"{function.__name__} blocked for more than {seconds} s") + return result["value"] + + +class TestZMQLink(unittest.TestCase): + + link = None + + def start(self, answer, timeout_ms=1000): + self.server = FakeRaspberry(answer) + self.link = within(5, ZMQLink, "127.0.0.1", str(self.server.port), timeout_ms) + + def tearDown(self): + if self.link is not None: # a blocked link cannot be closed from this thread + self.link.close() + self.server.stop() + + def test_acquisition(self): + self.start(bench_answer) + self.assertTrue(self.link.get_link_status()) + self.assertEqual(self.link.multi_acquisition(addresses=["0x48"]), [21.5]) + self.assertEqual(self.server.requests[-1], + {"type": "AQ-MULTI", "components": [{"register": "add", "add": "0x48"}]}) + + def test_pilotage(self): + self.start(bench_answer) + self.assertEqual(self.link.pilotage(200, pin="18"), 200) + self.assertEqual(self.server.requests[-1], + {"type": "PI", "register": "pin", "pin": "18", "value": 200}) + + def test_error_response(self): + self.start(bench_answer) + # a failed reading is nan in place, the other values are kept, nothing is raised + values = self.link.multi_acquisition(addresses=["0x48", "0x99"]) + self.assertEqual(values[0], 21.5) + self.assertTrue(math.isnan(values[1])) + # a refused control is returned as an error message + self.assertTrue(self.link.pilotage(10, pin="7").startswith("ERROR")) + + def test_timeout(self): + answered = {"scan": True} + # answers the connection check, then stays silent like a frozen raspberry + self.start(lambda request: bench_answer(request) if answered.pop(request["type"], False) else None, + timeout_ms=300) + self.assertTrue(self.link.get_link_status()) + start = time.monotonic() + result = within(5, self.link.multi_acquisition, addresses=["0x48"]) + self.assertLess(time.monotonic() - start, 2) + self.assertIsInstance(result, str) + self.assertIn("TIMEOUT", result) + + +if __name__ == '__main__': + unittest.main() diff --git a/version.json b/version.json new file mode 100644 index 0000000..7a89da0 --- /dev/null +++ b/version.json @@ -0,0 +1,3 @@ +{ + "version": "5.5.15" +}