From 8b7ef111c7cf7acf4811b4c193952fd6712f4356 Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Wed, 10 Jun 2026 14:44:55 +0200 Subject: [PATCH 01/44] Sprint 1 : refonte du serveur Raspberry en couches interchangeables + infra versioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 1 de la fusion des plugins raspberrypi3 / raspberrypizero dans pymodaq_plugins_raspberry. Infrastructure - Ajout de version.json (5.1.0), CHANGELOG.md et CONTRIBUTING.md (numérotation SemVer, stratégie de branches/tags, plan des sprints). Serveur Raspberry (src_raspberry/, non packagé) - Topologie en couches, chaque maillon étant une classe interchangeable derrière une interface, le main restant le seul assembleur : - ITransport / ZmqServer : communication PyMoDAQ (ZeroMQ), réseau et framing uniquement ; - 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 instancie et câble les trois couches. - Sépare la gestion des requêtes de la communication matérielle, auparavant mélangées dans CProtocolHandler. - Isole le transport ZeroMQ de la logique de framing/décodage. - Comportement fonctionnel identique au serveur Raspberry Pi 3 d'origine (la fusion des bancs Pi 3 / Pi Zero arrive au Sprint 2). Le plugin de base (src/, PiCamera, pyproject) n'est pas modifié. --- CHANGELOG.md | 37 ++++++ CONTRIBUTING.md | 37 ++++++ src_raspberry/README.md | 98 ++++++++++++++ src_raspberry/config.py | 106 ++++++++++++++++ src_raspberry/handlers/__init__.py | 0 src_raspberry/handlers/base.py | 26 ++++ src_raspberry/handlers/json_handler.py | 166 ++++++++++++++++++++++++ src_raspberry/hardware/__init__.py | 0 src_raspberry/hardware/actuators.py | 169 +++++++++++++++++++++++++ src_raspberry/hardware/backend.py | 151 ++++++++++++++++++++++ src_raspberry/hardware/base.py | 61 +++++++++ src_raspberry/hardware/scanner.py | 41 ++++++ src_raspberry/hardware/sensors.py | 152 ++++++++++++++++++++++ src_raspberry/main.py | 67 ++++++++++ src_raspberry/requirements.txt | 3 + src_raspberry/transport/__init__.py | 0 src_raspberry/transport/base.py | 30 +++++ src_raspberry/transport/zmq_server.py | 147 +++++++++++++++++++++ version.json | 3 + 19 files changed, 1294 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 src_raspberry/README.md create mode 100644 src_raspberry/config.py create mode 100644 src_raspberry/handlers/__init__.py create mode 100644 src_raspberry/handlers/base.py create mode 100644 src_raspberry/handlers/json_handler.py create mode 100644 src_raspberry/hardware/__init__.py create mode 100644 src_raspberry/hardware/actuators.py create mode 100644 src_raspberry/hardware/backend.py create mode 100644 src_raspberry/hardware/base.py create mode 100644 src_raspberry/hardware/scanner.py create mode 100644 src_raspberry/hardware/sensors.py create mode 100644 src_raspberry/main.py create mode 100644 src_raspberry/requirements.txt create mode 100644 src_raspberry/transport/__init__.py create mode 100644 src_raspberry/transport/base.py create mode 100644 src_raspberry/transport/zmq_server.py create mode 100644 version.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ac54c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# 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.1.0] - 2026-06-10 + +Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` +dans `pymodaq_plugins_raspberry` — **Sprint 1 : infrastructure et refonte du serveur**. + +### Ajouté +- `version.json` à la racine : version du produit fusionné (SemVer). +- `CHANGELOG.md` et `CONTRIBUTING.md` (stratégie de branches/tags, processus de version). +- Serveur Raspberry refondu 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, auparavant mélangées dans + `CProtocolHandler`, sont désormais 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. +- Comportement fonctionnel identique au serveur Raspberry Pi 3 d'origine + (la fusion des bancs Pi 3 / Pi Zero arrive au Sprint 2). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0496fb8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# 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, préfixé par `v` + (ex. `v5.1.0`), aligné sur `version.json`. + +## Découpage du travail de fusion (sprints) + +| Version | Sprint | Contenu | +|---------|--------|---------| +| 5.1.0 | Sprint 1 | Infrastructure (versioning) + refonte du serveur en couches interchangeables | +| 5.2.0 | Sprint 2 | Fusion des bancs Pi 3 / Pi Zero (registres capteurs + actionneurs, pilotage par config) | +| 5.3.0 | Sprint 3 | Intégration du client PyMoDAQ (Link_PMQ, daq_move, daq_viewer) | +| 5.4.0 | Sprint 4 | Config template fusionné, tests, documentation | diff --git a/src_raspberry/README.md b/src_raspberry/README.md new file mode 100644 index 0000000..9fa89e4 --- /dev/null +++ b/src_raspberry/README.md @@ -0,0 +1,98 @@ +# 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`. + - `actuators.py` : gestionnaire d'actionneurs. + - `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. +- **`main.py`** — point d'entrée : instancie et câble les trois couches. + +## 🛠️ Prérequis et installation + +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 +``` + +> **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 +> (objets `MagicMock`) pour tester la communication réseau sans la Raspberry Pi. + +## 📡 Protocole de communication (JSON) + +Le serveur écoute des trames JSON sur un socket ZeroMQ (ROUTER) port `5555`. + +### Scan du matériel +```json +{"type": "scan"} +``` + +### 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} +``` + +### Modes multiples (`AQ-MULTI`, `PI-MULTI`) +```json +{ + "type": "AQ-MULTI", + "components": [ + {"register": "add", "add": "0x38", "channel": "hum"}, + {"register": "pin", "pin": 18} + ] +} +``` diff --git a/src_raspberry/config.py b/src_raspberry/config.py new file mode 100644 index 0000000..9b7527e --- /dev/null +++ b/src_raspberry/config.py @@ -0,0 +1,106 @@ +"""! +@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. +""" +#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', + 'units': '%', + 'min': 0, + 'max': 255, + 'address': None, + 'pwm_frequency': 25000, + }, + { + 'pin': RESISTANCE_PIN, + 'title': 'Resistance', + 'name': 'resistance', + 'units': '%', + 'min': 0, + 'max': 255, + 'address': None, + 'pwm_frequency': 100, + }, +] +#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_TMP102: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_resistance', + '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..bdced95 --- /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, + "PI-MULTI": self._HandleMultiPiloting, + } + + 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 Dictionnaire listant les périphériques. + """ + return 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. """ + 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)) + + def _HandleMultiPiloting(self, requestData: dict) -> dict: + """! @brief Gère la requête PI-MULTI. """ + componentsList = requestData.get("components", []) + return self._Ack([self._HandlePiloting(comp) for comp in componentsList]) + + @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..80dd677 --- /dev/null +++ b/src_raspberry/hardware/actuators.py @@ -0,0 +1,169 @@ +#region Imports +import logging +from dataclasses import dataclass +from typing import Optional +from unittest.mock import MagicMock + +import pigpio +#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 + pwmFrequency: int + 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"], + pwmFrequency = data["pwm_frequency"], + 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 Gestionnaire Actionneurs +class CActuatorManager: + """! + @brief Gestionnaire des actionneurs (moteurs, chauffages, LEDs) via GPIO en PWM. + """ + + 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 not self.piClient.connected: + logger.info("pigpio non connecté : passage en mode simulation") + self.piClient = MagicMock() + self.piClient.connected = True + self.piClient.read_value = 1 + + ## @brief Dictionnaire des actionneurs indexé par PIN + self._actuators: dict = {} + + self._CheckConnected() + 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.") + logger.info("Connexion à pigpio établie.") + + def _LoadConfig(self, actuatorsConfig: list) -> None: + """! + @brief Charge les configurations d'actionneurs fournies. + @param actuatorsConfig Liste des dictionnaires de configuration. + """ + for rawConfig in actuatorsConfig: + actuatorDef = CActuatorConfig.FromDict(rawConfig) + self._actuators[actuatorDef.pin] = actuatorDef + self.piClient.set_PWM_frequency(actuatorDef.pin, actuatorDef.pwmFrequency) + self.piClient.set_PWM_dutycycle(actuatorDef.pin, 0) + logger.debug(f"Configuré : {actuatorDef.title} (pin {actuatorDef.pin})") + logger.info(f"{len(self._actuators)} actionneur(s) configuré(s).") + + def GetActuatorsInfo(self) -> list: + """! + @brief Retourne la liste des actionneurs configurés. + @return list de CActuatorConfig + """ + return list(self._actuators.values()) + + def SetPin(self, pinTarget: int, powerValue: int) -> None: + """! + @brief Applique un rapport cyclique PWM sur un pin. + @param pinTarget Numéro GPIO BCM. + @param powerValue Puissance souhaitée. + """ + self._CheckConnected() + actuatorObj = self._GetActuator(pinTarget) + + clampedValue = max(actuatorObj.minVal, min(actuatorObj.maxVal, powerValue)) + if clampedValue != powerValue: + logger.warning("Valeur %d hors plage pour %s → bornée à %d.", powerValue, actuatorObj.title, clampedValue) + + self.piClient.set_PWM_dutycycle(pinTarget, clampedValue) + + def GetPinValue(self, pinTarget: int) -> int: + """! + @brief Retourne la valeur PWM actuelle (0-255) du pin. + @param pinTarget Numéro GPIO. + @return int Valeur PWM. + """ + self._CheckConnected() + self._GetActuator(pinTarget) + + try: + return int(self.piClient.get_PWM_dutycycle(pinTarget)) + 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 actuatorObj in self._actuators.values(): + self.piClient.set_PWM_dutycycle(actuatorObj.pin, 0) + 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) -> CActuatorConfig: + """! + @brief Récupère l'objet configuration d'un actionneur selon son PIN. + @param pinTarget Numéro de broche. + @return CActuatorConfig + """ + 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..7fa32e7 --- /dev/null +++ b/src_raspberry/hardware/backend.py @@ -0,0 +1,151 @@ +#region Imports +import dataclasses +import logging +from typing import Optional + +from smbus2 import SMBus + +from .base import IHardwareBackend +from .scanner import CScanner +from .sensors import SENSOR_DRIVER_REGISTRY +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: + i2cBus = SMBus(configModule.I2C_BUS_ID) + except (PermissionError, FileNotFoundError, OSError): + from unittest.mock import MagicMock + i2cBus = MagicMock() + isSimulationEnv = True + logger.info("Mode simulation activé pour l'I2C.") + + ## @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)) + + 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..16b532c --- /dev/null +++ b/src_raspberry/hardware/scanner.py @@ -0,0 +1,41 @@ +#region Imports +import logging +from smbus2 import SMBus +#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: + 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..495f57b --- /dev/null +++ b/src_raspberry/hardware/sensors.py @@ -0,0 +1,152 @@ +#region Imports +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 CDriverSimule(CSensorDriver): + """! + @brief Pilote de test pour retourner des valeurs simulées. + """ + + def ReadValue(self, channel: str = None) -> float: + """! + @brief Retourne des valeurs factices. + @param channel Canal de mesure souhaité. + @return Valeur simulée. + """ + if channel == 'hum': + return round(random.uniform(40.0, 60.0), 2) + return round(random.uniform(20.0, 25.0), 2) +#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, + 'SIMULE': CDriverSimule, +} +#endregion diff --git a/src_raspberry/main.py b/src_raspberry/main.py new file mode 100644 index 0000000..d4dfb92 --- /dev/null +++ b/src_raspberry/main.py @@ -0,0 +1,67 @@ +#region Imports +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. + """ + 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=5555) + + 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..6c8f7eb --- /dev/null +++ b/src_raspberry/transport/zmq_server.py @@ -0,0 +1,147 @@ +#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.ContextTerminated: + 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: + 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 + + logger.info("← (%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.info("→ %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/version.json b/version.json new file mode 100644 index 0000000..4755d7b --- /dev/null +++ b/version.json @@ -0,0 +1,3 @@ +{ + "version": "5.1.0" +} From e82bed8054a02c8477a58cc9897bf207bdc1b740 Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Wed, 10 Jun 2026 14:56:54 +0200 Subject: [PATCH 02/44] =?UTF-8?q?Sprint=202=20:=20fusion=20des=20bancs=20P?= =?UTF-8?q?i=203=20/=20Pi=20Zero=20(capteurs=20et=20actionneurs=20pilot?= =?UTF-8?q?=C3=A9s=20par=20config)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion des plugins raspberrypi3 / raspberrypizero — la différence entre bancs est désormais entièrement portée par la configuration. Ajouté - Pilote de capteur PT-100 (sonde via CAN ADS1115), enregistré dans SENSOR_DRIVER_REGISTRY (dépendance Adafruit importée localement). - Pilotes d'actionneurs interchangeables derrière l'interface CActuatorDriver, enregistrés dans ACTUATOR_DRIVER_REGISTRY et choisis par le champ 'driver' : - PWM : rapport cyclique (ex-banc Raspberry Pi 3) ; - DIGITAL : tout-ou-rien (ex-banc Raspberry Pi Zero). - config_examples/config_pizero.py : banc tout-ou-rien avec sonde PT100. Modifié - CActuatorManager instancie le pilote adapté à chaque actionneur au lieu d'un pilotage PWM codé en dur (modes mixables sur un même banc). - CActuatorConfig accepte un champ 'driver' (défaut PWM) ; pwm_frequency optionnel. - config.py documente le choix du pilote par actionneur. Supprimé - safety_monitor.py des plugins d'origine non repris (constantes de config inexistantes, jamais démarré par le main). Corrigé - Fonction morte build_sensor_map non reprise ; cartographie des capteurs unifiée dans HardwareBackend. - Indentation cassée et perte de pwm_frequency de l'ancien actuators.py Pi Zero résolues par le nouveau modèle de pilotes d'actionneurs. --- CHANGELOG.md | 36 ++++ src_raspberry/README.md | 21 ++- src_raspberry/config.py | 7 + .../config_examples/config_pizero.py | 106 ++++++++++++ src_raspberry/hardware/actuators.py | 154 ++++++++++++++---- src_raspberry/hardware/sensors.py | 51 ++++++ version.json | 2 +- 7 files changed, 344 insertions(+), 33 deletions(-) create mode 100644 src_raspberry/config_examples/config_pizero.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac54c9..42735f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,42 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [5.2.0] - 2026-06-10 + +Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — +**Sprint 2 : fusion des bancs de test (capteurs et actionneurs pilotés par config)**. + +### 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 (ex-banc Raspberry Pi 3) ; + - `DIGITAL` — pilotage tout-ou-rien (ex-banc Raspberry Pi Zero). +- `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é +- Le moniteur de sécurité (`safety_monitor.py`) des plugins d'origine n'est pas + repris : il lisait des constantes de configuration inexistantes et n'était + jamais démarré par le `main`. + +### Corrigé +- La fonction morte `build_sensor_map` (références non importées dans l'ancien + `connexion.py`) n'est pas reprise ; la construction de la cartographie des + capteurs est unifiée dans `HardwareBackend`. +- L'indentation cassée et la perte de `pwm_frequency` de l'ancien `actuators.py` + côté Pi Zero sont résolues par le nouveau modèle de pilotes d'actionneurs. + ## [5.1.0] - 2026-06-10 Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` diff --git a/src_raspberry/README.md b/src_raspberry/README.md index 9fa89e4..10757b5 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -32,13 +32,30 @@ interchangeable** derrière une interface, sauf le `main` qui les assemble : - **`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`. - - `actuators.py` : gestionnaire d'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 (Raspberry Pi 3, Pi Zero, …) 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 1. **Activer l'I2C** : `sudo raspi-config` → Interfacing Options → I2C. diff --git a/src_raspberry/config.py b/src_raspberry/config.py index 9b7527e..16576d2 100644 --- a/src_raspberry/config.py +++ b/src_raspberry/config.py @@ -6,6 +6,11 @@ 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 @@ -38,6 +43,7 @@ 'pin': VENTILATEUR_PIN, 'title': 'Ventilateur', 'name': 'ventilateur', + 'driver': 'PWM', # mode de pilotage : 'PWM' ou 'DIGITAL' 'units': '%', 'min': 0, 'max': 255, @@ -48,6 +54,7 @@ 'pin': RESISTANCE_PIN, 'title': 'Resistance', 'name': 'resistance', + 'driver': 'PWM', 'units': '%', 'min': 0, 'max': 255, diff --git a/src_raspberry/config_examples/config_pizero.py b/src_raspberry/config_examples/config_pizero.py new file mode 100644 index 0000000..db65fb9 --- /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 (ex-banc Raspberry Pi Zero). + +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/hardware/actuators.py b/src_raspberry/hardware/actuators.py index 80dd677..790c071 100644 --- a/src_raspberry/hardware/actuators.py +++ b/src_raspberry/hardware/actuators.py @@ -1,5 +1,6 @@ #region Imports import logging +from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional from unittest.mock import MagicMock @@ -21,7 +22,8 @@ class CActuatorConfig: title: str name: str pin: int - pwmFrequency: int + driver: str = 'PWM' + pwmFrequency: int = 0 units: str = '' minVal: int = 0 maxVal: int = 255 @@ -38,7 +40,8 @@ def FromDict(cls, data: dict) -> "CActuatorConfig": title = data["title"], name = data["name"], pin = data["pin"], - pwmFrequency = data["pwm_frequency"], + 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), @@ -54,10 +57,104 @@ class CPinNotFoundError(KeyError): 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 distingue un banc d'un autre + (ex. Raspberry Pi 3 en PWM vs Raspberry Pi Zero en tout-ou-rien). + """ + + 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, pigpio.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 Gestionnaire Actionneurs class CActuatorManager: """! - @brief Gestionnaire des actionneurs (moteurs, chauffages, LEDs) via GPIO en PWM. + @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): @@ -74,7 +171,7 @@ def __init__(self, actuatorsConfig: list): self.piClient.connected = True self.piClient.read_value = 1 - ## @brief Dictionnaire des actionneurs indexé par PIN + ## @brief Dictionnaire des pilotes d'actionneurs indexé par PIN self._actuators: dict = {} self._CheckConnected() @@ -91,50 +188,47 @@ def _CheckConnected(self) -> None: def _LoadConfig(self, actuatorsConfig: list) -> None: """! - @brief Charge les configurations d'actionneurs fournies. + @brief Instancie le pilote adapté à chaque actionneur configuré. @param actuatorsConfig Liste des dictionnaires de configuration. """ for rawConfig in actuatorsConfig: actuatorDef = CActuatorConfig.FromDict(rawConfig) - self._actuators[actuatorDef.pin] = actuatorDef - self.piClient.set_PWM_frequency(actuatorDef.pin, actuatorDef.pwmFrequency) - self.piClient.set_PWM_dutycycle(actuatorDef.pin, 0) - logger.debug(f"Configuré : {actuatorDef.title} (pin {actuatorDef.pin})") + 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 actionneurs configurés. + @brief Retourne la liste des configurations d'actionneurs. @return list de CActuatorConfig """ - return list(self._actuators.values()) + return [actuator.cfg for actuator in self._actuators.values()] def SetPin(self, pinTarget: int, powerValue: int) -> None: """! - @brief Applique un rapport cyclique PWM sur un pin. + @brief Applique une consigne à un actionneur. @param pinTarget Numéro GPIO BCM. - @param powerValue Puissance souhaitée. + @param powerValue Consigne souhaitée. """ self._CheckConnected() - actuatorObj = self._GetActuator(pinTarget) - - clampedValue = max(actuatorObj.minVal, min(actuatorObj.maxVal, powerValue)) - if clampedValue != powerValue: - logger.warning("Valeur %d hors plage pour %s → bornée à %d.", powerValue, actuatorObj.title, clampedValue) - - self.piClient.set_PWM_dutycycle(pinTarget, clampedValue) + self._GetActuator(pinTarget).set_value(powerValue) def GetPinValue(self, pinTarget: int) -> int: """! - @brief Retourne la valeur PWM actuelle (0-255) du pin. + @brief Retourne la valeur/état actuel du pin. @param pinTarget Numéro GPIO. - @return int Valeur PWM. + @return int Valeur courante. """ self._CheckConnected() - self._GetActuator(pinTarget) - + actuator = self._GetActuator(pinTarget) try: - return int(self.piClient.get_PWM_dutycycle(pinTarget)) + return actuator.get_value() except Exception as exc: raise RuntimeError(f"Impossible de lire la valeur du pin {pinTarget}.") from exc @@ -145,8 +239,8 @@ def Cleanup(self) -> None: if not self.piClient.connected: return logger.info("Arrêt des actionneurs...") - for actuatorObj in self._actuators.values(): - self.piClient.set_PWM_dutycycle(actuatorObj.pin, 0) + for actuator in self._actuators.values(): + actuator.reset() self.piClient.stop() logger.info("Connexion pigpio fermée.") @@ -156,11 +250,11 @@ def __enter__(self) -> "CActuatorManager": def __exit__(self, *_) -> None: self.Cleanup() - def _GetActuator(self, pinTarget: int) -> CActuatorConfig: + def _GetActuator(self, pinTarget: int) -> CActuatorDriver: """! - @brief Récupère l'objet configuration d'un actionneur selon son PIN. + @brief Récupère le pilote d'un actionneur selon son PIN. @param pinTarget Numéro de broche. - @return CActuatorConfig + @return CActuatorDriver """ try: return self._actuators[pinTarget] diff --git a/src_raspberry/hardware/sensors.py b/src_raspberry/hardware/sensors.py index 495f57b..c1df59b 100644 --- a/src_raspberry/hardware/sensors.py +++ b/src_raspberry/hardware/sensors.py @@ -137,6 +137,56 @@ def ReadValue(self, channel: str = None) -> float: 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 @@ -147,6 +197,7 @@ def ReadValue(self, channel: str = None) -> float: 'AHT10': CDriverAht10, 'TMP102': CDriverTmp102, 'EMC2101': CDriverEmc2101, + 'PT-100': CDriverPt100, 'SIMULE': CDriverSimule, } #endregion diff --git a/version.json b/version.json index 4755d7b..13e2f39 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.1.0" + "version": "5.2.0" } From e5eebcc1fa1f10320e3fa2c4a67c0ffb7afc3eef Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Wed, 10 Jun 2026 15:02:10 +0200 Subject: [PATCH 03/44] =?UTF-8?q?Sprint=203=20:=20int=C3=A9gration=20du=20?= =?UTF-8?q?client=20PyMoDAQ=20(Link=5FPMQ,=20daq=5Fmove,=20daq=5Fviewer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intègre côté package PyMoDAQ la partie client commune aux plugins raspberrypi3 / raspberrypizero, dans l'arborescence standard d'un plugin. Ajouté - hardware/Link_PMQ.py : client ZMQLink (lien ZeroMQ DEALER vers le serveur). - hardware/Config_Components.py : lecture des composants depuis la config 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 unifiée [Raspberry] (remplace [RaspPi3] / [RaspPiZero] des plugins d'origine). - pyproject.toml : ajout de la dépendance pyzmq (requise par ZMQLink). - Le plugin DAQ_2DViewer_PiCamera existant n'est pas modifié. Corrigé - f-strings à guillemets imbriqués identiques (Python 3.12+ uniquement) remplacés par une forme compatible Python 3.8+. --- CHANGELOG.md | 23 ++ pyproject.toml | 1 + .../daq_move_plugins/daq_move_MoveRasp.py | 208 +++++++++++++++++ .../plugins_0D/daq_0Dviewer_ViewRasp.py | 210 ++++++++++++++++++ .../hardware/Config_Components.py | 67 ++++++ .../hardware/Link_PMQ.py | 193 ++++++++++++++++ .../resources/config_template.toml | 44 +++- version.json | 2 +- 8 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py create mode 100644 src/pymodaq_plugins_raspberry/daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py create mode 100644 src/pymodaq_plugins_raspberry/hardware/Config_Components.py create mode 100644 src/pymodaq_plugins_raspberry/hardware/Link_PMQ.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 42735f9..6ec4ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,29 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [5.3.0] - 2026-06-10 + +Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — +**Sprint 3 : intégration du client PyMoDAQ**. + +### 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 unifiée `[Raspberry]` + (remplace les sections spécifiques `[RaspPi3]` / `[RaspPiZero]` des plugins d'origine). +- `pyproject.toml` : ajout de la dépendance `pyzmq` (requise par `ZMQLink`). +- Le plugin `DAQ_2DViewer_PiCamera` existant 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 Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — diff --git a/pyproject.toml b/pyproject.toml index 2144b51..3b52d53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ description = 'Set of instrument plugins to use with a raspberry' dependencies = [ "pymodaq>=5.0.0", 'picamera2', + 'pyzmq', ] 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..13465e2 --- /dev/null +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -0,0 +1,208 @@ +from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, main, + DataActuator, DataActuatorType) + +from pymodaq_gui.parameter import Parameter + +from ..hardware.Link_PMQ import ZMQLink +from ..hardware.Config_Components import get_actuators_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_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 = False + _axis_names = [actuator['title'] for actuator in actuators] + _controller_units = [actuator['units'] for actuator in actuators] + _epsilon = 0.1 + data_actuator_type = DataActuatorType.DataActuator + current_component = None + name_access_variables = get_access_variables(config) + #################################################################################################################### + + # 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 + + for elem in self.actuators: + if elem['title'] == self.settings['multiaxes', 'axis']: + self.current_component = elem + self.settings['bounds', 'max_bound'] = str(int(elem['max'])*100) + self.settings['bounds', 'min_bound'] = str(int(elem['min'])*100) + self.settings['units'] = elem['units'] + break + + 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: + 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': + for elem in self.actuators: + if elem['name'] == param.value(): + 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: + self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port")) + initialized = self.controller.get_link_status() + else: + self.controller = controller + initialized = True + + return "Initialized ? : ", initialized + + def move_abs(self, value: DataActuator): + """ Move the actuator to the absolute target defined by value + + Parameters + ---------- + value: (float) 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: (float) 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(0) + + 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() + + value /= 100 + if self.current_component is not None and (isinstance(value, float) or isinstance(value, int)): + + for elem in self.name_access_variables: + try: + if self.current_component[elem] != 'None': + access_variables = self.current_component[elem] + + # SPECIFIC LINES # +######################################################################################################################## + if elem == "address": + output = self.controller.pilotage(address=access_variables, value=value) + else: + output = self.controller.pilotage(pin=access_variables, value=value) +######################################################################################################################## + except Exception as e: + logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") + + if not isinstance(output, str): + self.output_value = output + logger.info(f"Move : {access_variables} -> {value} | Output : {output}") + else: + logger.warning(output) + + 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) + +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..e8015ad --- /dev/null +++ b/src/pymodaq_plugins_raspberry/daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py @@ -0,0 +1,210 @@ +import numpy as np + +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_PMQ import ZMQLink +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').child('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').child('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: + self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port")) + initialized = self.controller.get_link_status() + else: + self.controller = controller + initialized = True + + return "Initialized ? : ", initialized + + def close(self): + """Terminate the communication protocol""" + if self.is_master: + 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 + """ + access_variables = [] + labels_tab = [] + + for i, type_access_variables in enumerate(self.selected_components): + access_variables.append([]) + for elem in self.selected_components[type_access_variables]: + try: + access_variables[i].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)}") + # SPECIFIC LINE # +######################################################################################################################## + data_tot = self.controller.multi_acquisition(addresses=access_variables[0], pins=access_variables[1]) +######################################################################################################################## + + if isinstance(data_tot, str) and "ERROR" in data_tot: + mess = f"Viewer ERROR : {data_tot}" + data_tot = [0] + logger.warning(mess) + else: + logger.info(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..c87185f --- /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_PMQ.py b/src/pymodaq_plugins_raspberry/hardware/Link_PMQ.py new file mode 100644 index 0000000..22f430e --- /dev/null +++ b/src/pymodaq_plugins_raspberry/hardware/Link_PMQ.py @@ -0,0 +1,193 @@ +import zmq +import json +import uuid + +from pymodaq.utils.logger import set_logger, get_module_name +logger = set_logger(get_module_name(__file__)) + +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 + + def __init__(self, ip_address : str, port : str): + """ + 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) + :return: void - start the ZMQ connection + """ + self.__isLinked = False + self.__id_socket = "" + 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 + """ + assert ip_address is not None, "ERROR - ip address not set" + + self.__context = zmq.Context() + self.__socket = self.__context.socket(zmq.DEALER) + + self.__id_socket = str(uuid.uuid4()) + self.__socket.setsockopt_string(zmq.IDENTITY, self.__id_socket) + + self.__socket.connect(f"tcp://{ip_address}:{port}") + self.__isLinked = True + + print(f"ZMQ LINK -> CONNECTED |" + f" IP ROUTER : {ip_address} |" + f" PORT ROUTER : {port} |" + f" ID DEALER : {self.__id_socket}") + return + + def close(self): + """ + Stop the connection + -------------------- + :return: Close the ZMQ connection + """ + self.__socket.close() + self.__isLinked = False + return + + 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 + """ + self.__socket.send(json.dumps(request).encode('utf-8')) + 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 + """ + inp_mq = self.__socket.recv() + if isinstance(inp_mq, bytes): + inp_mq = inp_mq.decode('utf-8') + return json.loads(inp_mq) + + def get_link_status(self) -> bool: + """ + Get the status of the socket (True -> open, False -> 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 + """ + assert addresses is not None or pins is not None, "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" + + for i, elem in enumerate(inp_mq["value"]): + if type(elem) != int and type(elem) != float: + inp_mq["value"][i] = -1 + logger.warning(f"READ ERROR - {inp_mq['value'][i]}") + + return inp_mq["value"] + + def pilotage(self, value : str | int, address : str | int = None, pin : str | int = None) -> float | str: + """ + Send a JSON control request to the raspberry's script + -------------------- + :param value: The value wanted for the component + :param address: The address of the component + :param pin: The pin of the component + :return: The value read by the component after control + """ + assert address is not None or pin is not None, "ERROR: hardware should have an address or a pin" + assert not (address is not None and pin is not None), \ + "ERROR: only one of address or pin should be given, not both" + + inp_mq = None + + if address is not None: + inp_mq = self.__write( + { + "type": "PI", + "register": "add", + "add": address, + "value" : value + } + ) + elif pin is not None: + 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__': + """Main section used during development tests""" + + Capteur1 = ZMQLink("172.17.50.41", '5555') + + print(Capteur1.multi_acquisition(addresses=["0x49"])) + print(Capteur1.pilotage("0", pin="18")) + + Capteur1.close() diff --git a/src/pymodaq_plugins_raspberry/resources/config_template.toml b/src/pymodaq_plugins_raspberry/resources/config_template.toml index 3736213..024d07f 100644 --- a/src/pymodaq_plugins_raspberry/resources/config_template.toml +++ b/src/pymodaq_plugins_raspberry/resources/config_template.toml @@ -1,2 +1,44 @@ -#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.158.235.2' +port = '5555' +# +[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 +# Here, for a I2C network, we use an address and a pin variable to identify and use the component "MyActuator" +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 = "%" +# +# 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/version.json b/version.json index 13e2f39..447c754 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.2.0" + "version": "5.3.0" } From c67afd77a84ed230e1ef5da72d5f474acb67cbdf Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Wed, 10 Jun 2026 15:04:45 +0200 Subject: [PATCH 04/44] =?UTF-8?q?Sprint=204=20:=20finitions=20et=20documen?= =?UTF-8?q?tation=20du=20plugin=20fusionn=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première version « production » du plugin raspberry fusionné. Ajouté - README.rst : liste des instruments (MoveRasp, ViewRasp, picamera) et description du serveur Raspberry (src_raspberry/). Modifié - README.rst : auteurs, version PyMoDAQ requise (>= 5), cartes testées (Raspberry Pi 3 et Pi Zero). Vérifié - Conformité à tests/test_plugin_package_structure.py (conventions de nommage et méthodes obligatoires) ; aucune modification du test nécessaire. - Compilation de l'ensemble (serveur + package) et test d'intégration des deux bancs (PWM / DIGITAL) + lecture du template [Raspberry]. --- CHANGELOG.md | 19 +++++++++++++++++++ README.rst | 37 +++++++++++++++++++++++++++++++++---- version.json | 2 +- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ec4ee1..ff8a039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,25 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [5.4.0] - 2026-06-10 + +Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — +**Sprint 4 : finitions et documentation** (première version « production » du plugin fusionné). + +### Ajouté +- Documentation du plugin fusionné dans `README.rst` : liste des instruments + (`MoveRasp`, `ViewRasp`, `picamera`) et description du serveur Raspberry + (`src_raspberry/`). + +### Modifié +- `README.rst` : mise à jour des auteurs, de la version PyMoDAQ requise (>= 5) et + des cartes testées (Raspberry Pi 3 et Pi Zero). + +### 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`) ; + aucune modification du test n'a été nécessaire. + ## [5.3.0] - 2026-06-10 Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — diff --git a/README.rst b/README.rst index 9734bda..629b34c 100644 --- a/README.rst +++ b/README.rst @@ -19,12 +19,19 @@ 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 +Set of instrument plugins to be used from or on your Raspberry Pi. + +This package merges the former ``pymodaq_plugins_raspberrypi3`` and +``pymodaq_plugins_raspberrypizero`` plugins into a single one. The differences +between boards (Raspberry Pi 3, Pi Zero, ...) are entirely described by +configuration: a sensor selects its driver, an actuator its control mode +(``PWM`` or ``DIGITAL``), so the same code base works with any test bench. Authors ======= * Sebastien J. Weber (sebastien.weber@cnrs.fr) +* Fabien Villedieu (merge of the raspberrypi3 / raspberrypizero plugins) Instruments @@ -32,6 +39,17 @@ Instruments Below is the list of instruments included in this plugin +Actuators ++++++++++ + +* **MoveRasp**: control of actuators (PWM or all-or-nothing) wired to a Raspberry, + through a ZeroMQ link to the on-board server (see ``src_raspberry/``) + +Viewer0D +++++++++ + +* **ViewRasp**: acquisition of I2C sensors (AHT10, TMP102, EMC2101, PT100, ...) + wired to a Raspberry, through the same ZeroMQ link Viewer2D ++++++++ @@ -48,9 +66,20 @@ Viewer2D ========== +Raspberry-side server +===================== + +The ``MoveRasp`` / ``ViewRasp`` plugins talk to a server that must run **on the +Raspberry Pi**. Its source code lives in the ``src_raspberry/`` folder at the root +of this repository (it is a companion, non-packaged addition). It is organised in +interchangeable layers — ZeroMQ transport, JSON request handler, hardware backend — +each behind an interface, so the transport or the hardware communication can be +swapped without touching the rest. See ``src_raspberry/README.md`` for installation +and the JSON protocol. + + Installation instructions ========================= -* PyMoDAQ’s version >= 4 -* Tested on/with a raspberry pi 4 - +* PyMoDAQ’s version >= 5 +* Tested on/with a Raspberry Pi 3 and a Raspberry Pi Zero diff --git a/version.json b/version.json index 447c754..7438a92 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.3.0" + "version": "5.4.0" } From 5dae804c04e4531ed3dc1ca27cafab37be893d50 Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Wed, 10 Jun 2026 15:32:49 +0200 Subject: [PATCH 05/44] =?UTF-8?q?Sprint=204=20(fix)=20:=20convention=20de?= =?UTF-8?q?=20tags=20sans=20pr=C3=A9fixe=20v=20=E2=80=94=20version=205.4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les versions antérieures du dépôt (5.0.0, 5.0.1) sont taguées sans préfixe « v ». On aligne la documentation sur cette convention : les tags de production sont au format MAJEUR.MINEUR.CORRECTIF (ex. 5.4.1), sans préfixe « v ». - CONTRIBUTING.md : correction de la section Tags. - version.json : 5.4.1. - CHANGELOG.md : entrée [5.4.1]. --- CHANGELOG.md | 7 +++++++ CONTRIBUTING.md | 4 ++-- version.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8a039..fc094af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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 Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0496fb8..9f2e9a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,8 +24,8 @@ La version courante est portée par [`version.json`](version.json) à la racine - `hotfix/nom-court` : correctifs urgents ### Tags -- Tag Git obligatoire à chaque déploiement en production, préfixé par `v` - (ex. `v5.1.0`), aligné sur `version.json`. +- 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`). ## Découpage du travail de fusion (sprints) diff --git a/version.json b/version.json index 7438a92..8073976 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.0" + "version": "5.4.1" } From bb201f6aa14a50938b00985f4b99796447ff6a2e Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Wed, 10 Jun 2026 15:52:51 +0200 Subject: [PATCH 06/44] =?UTF-8?q?Doc=20:=20README=20orient=C3=A9=20utilisa?= =?UTF-8?q?teur=20=E2=80=94=20version=205.4.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réécrit le README du point de vue d'un utilisateur du plugin (contrôle d'un dispositif expérimental / banc de test via un Raspberry), sans détailler l'historique interne du projet. Met en avant les trois axes d'adaptabilité : - communication PyMoDAQ <-> Raspberry (ZeroMQ par défaut, remplaçable) ; - communication Raspberry <-> composants (drivers capteurs/actionneurs par config) ; - ajout de nouvelles requêtes JSON côté PyMoDAQ et côté Raspberry. - version.json : 5.4.2. - CHANGELOG.md : entrée [5.4.2]. --- CHANGELOG.md | 8 ++++++ README.rst | 72 ++++++++++++++++++++++++++++++++++------------------ version.json | 2 +- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc094af..89cd3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/README.rst b/README.rst index 629b34c..4d50b60 100644 --- a/README.rst +++ b/README.rst @@ -19,19 +19,20 @@ 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. -This package merges the former ``pymodaq_plugins_raspberrypi3`` and -``pymodaq_plugins_raspberrypizero`` plugins into a single one. The differences -between boards (Raspberry Pi 3, Pi Zero, ...) are entirely described by -configuration: a sensor selects its driver, an actuator its control mode -(``PWM`` or ``DIGITAL``), so the same code base works with any test bench. Authors ======= * Sebastien J. Weber (sebastien.weber@cnrs.fr) -* Fabien Villedieu (merge of the raspberrypi3 / raspberrypizero plugins) +* Fabien Villedieu Instruments @@ -42,44 +43,67 @@ Below is the list of instruments included in this plugin Actuators +++++++++ -* **MoveRasp**: control of actuators (PWM or all-or-nothing) wired to a Raspberry, - through a ZeroMQ link to the on-board server (see ``src_raspberry/``) +* **MoveRasp**: drive the outputs of the device (e.g. PWM or all-or-nothing + actuators) wired to the Raspberry Viewer0D ++++++++ -* **ViewRasp**: acquisition of I2C sensors (AHT10, TMP102, EMC2101, PT100, ...) - wired to a Raspberry, through the same ZeroMQ link +* **ViewRasp**: read the sensors of the device (e.g. I2C sensors) wired to the + Raspberry Viewer2D ++++++++ * **picamera**: control of the integrated pi camera using the Picamera2 library -.. if needed use this field - PID Models - ========== +Adapting the plugin to your setup +================================= + +The plugin is built to be adapted to a wide range of benches. Three things can be +changed independently: + +* **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_PMQ.py``). + +* **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). +* **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: - Extensions - ========== + - **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_PMQ.py``) that + builds and sends the new request, then call it from the move/viewer plugins. Raspberry-side server ===================== -The ``MoveRasp`` / ``ViewRasp`` plugins talk to a server that must run **on the -Raspberry Pi**. Its source code lives in the ``src_raspberry/`` folder at the root -of this repository (it is a companion, non-packaged addition). It is organised in -interchangeable layers — ZeroMQ transport, JSON request handler, hardware backend — -each behind an interface, so the transport or the hardware communication can be -swapped without touching the rest. See ``src_raspberry/README.md`` for installation -and the JSON protocol. +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. Installation instructions ========================= * PyMoDAQ’s version >= 5 -* Tested on/with a Raspberry Pi 3 and a Raspberry Pi Zero +* The Raspberry-side server requires the I2C bus and the ``pigpio`` daemon + (see ``src_raspberry/README.md``). diff --git a/version.json b/version.json index 8073976..5656941 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.1" + "version": "5.4.2" } From ea7ef9f3f591b05883f70dca1eb7213cbe890afe Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Thu, 11 Jun 2026 09:05:44 +0200 Subject: [PATCH 07/44] =?UTF-8?q?Fix=20:=20picamera2=20conditionn=C3=A9e?= =?UTF-8?q?=20=C3=A0=20Linux=20pour=20permettre=20l'install=20hors=20Raspb?= =?UTF-8?q?erry=20=E2=80=94=20version=205.4.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit picamera2 dépend de python-prctl (Linux-only), ce qui rendait `pip install` du plugin impossible sur la machine de contrôle Windows/macOS — pourtant nécessaire pour utiliser les plugins distants MoveRasp / ViewRasp via ZeroMQ. - pyproject.toml : picamera2 déclarée avec le marqueur `platform_system == "Linux"`. Le viewer PiCamera n'est alors disponible que sur le Raspberry ; l'auto-import PyMoDAQ ignore proprement son absence sur les autres systèmes. - version.json : 5.4.3. - CHANGELOG.md : entrée [5.4.3]. --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 5 ++++- version.json | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cd3d0..58fb9b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/pyproject.toml b/pyproject.toml index 3b52d53..b63c382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +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/version.json b/version.json index 5656941..27a0269 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.2" + "version": "5.4.3" } From 565079a449fe02db0a393aac1e6d1cb42a1b7324 Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Thu, 11 Jun 2026 09:14:02 +0200 Subject: [PATCH 08/44] =?UTF-8?q?Doc=20:=20note=20PiCamera=20Linux-only=20?= =?UTF-8?q?et=20nettoyage=20des=20r=C3=A9f=C3=A9rences=20internes=20?= =?UTF-8?q?=E2=80=94=20version=205.4.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.rst : ajout d'une note indiquant que le viewer PiCamera n'est disponible que sur Linux/Raspberry (dépendance picamera2), et que la machine de contrôle Windows/macOS installe le plugin sans ce viewer. - Nettoyage de la documentation et des commentaires : suppression des références internes peu compréhensibles hors contexte de développement (CHANGELOG, CONTRIBUTING, README serveur, docstrings). - version.json : 5.4.4 ; CHANGELOG.md : entrée [5.4.4]. --- CHANGELOG.md | 62 +++++++------------ CONTRIBUTING.md | 9 --- README.rst | 9 ++- src_raspberry/README.md | 2 +- .../config_examples/config_pizero.py | 2 +- src_raspberry/hardware/actuators.py | 4 +- version.json | 2 +- 7 files changed, 37 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fb9b7..db1b69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é @@ -38,28 +46,20 @@ La version courante est également disponible dans [`version.json`](version.json ## [5.4.0] - 2026-06-10 -Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — -**Sprint 4 : finitions et documentation** (première version « production » du plugin fusionné). - ### Ajouté -- Documentation du plugin fusionné dans `README.rst` : liste des instruments +- 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, de la version PyMoDAQ requise (>= 5) et - des cartes testées (Raspberry Pi 3 et Pi Zero). +- `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`) ; - aucune modification du test n'a été nécessaire. + méthodes obligatoires des plugins `DAQ_Move_MoveRasp` et `DAQ_0DViewer_ViewRasp`). ## [5.3.0] - 2026-06-10 -Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — -**Sprint 3 : intégration du client PyMoDAQ**. - ### Ajouté - `hardware/Link_PMQ.py` : client `ZMQLink` (lien ZeroMQ DEALER vers le serveur Raspberry). - `hardware/Config_Components.py` : lecture des composants (actionneurs/détecteurs) @@ -69,10 +69,9 @@ Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberryp `DAQ_0DViewer_ViewRasp`. ### Modifié -- `resources/config_template.toml` : section de configuration unifiée `[Raspberry]` - (remplace les sections spécifiques `[RaspPi3]` / `[RaspPiZero]` des plugins d'origine). +- `resources/config_template.toml` : section de configuration `[Raspberry]`. - `pyproject.toml` : ajout de la dépendance `pyzmq` (requise par `ZMQLink`). -- Le plugin `DAQ_2DViewer_PiCamera` existant n'est pas modifié. +- Le plugin `DAQ_2DViewer_PiCamera` n'est pas modifié. ### Corrigé - Remplacement des f-strings à guillemets imbriqués identiques (`f"{d["k"]}"`), @@ -80,17 +79,14 @@ Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberryp ## [5.2.0] - 2026-06-10 -Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` — -**Sprint 2 : fusion des bancs de test (capteurs et actionneurs pilotés par config)**. - ### 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 (ex-banc Raspberry Pi 3) ; - - `DIGITAL` — pilotage tout-ou-rien (ex-banc Raspberry Pi Zero). + - `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. @@ -103,27 +99,20 @@ Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberryp - `config.py` documente le choix du pilote par actionneur. ### Supprimé -- Le moniteur de sécurité (`safety_monitor.py`) des plugins d'origine n'est pas - repris : il lisait des constantes de configuration inexistantes et n'était - jamais démarré par le `main`. +- 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é -- La fonction morte `build_sensor_map` (références non importées dans l'ancien - `connexion.py`) n'est pas reprise ; la construction de la cartographie des - capteurs est unifiée dans `HardwareBackend`. -- L'indentation cassée et la perte de `pwm_frequency` de l'ancien `actuators.py` - côté Pi Zero sont résolues par le nouveau modèle de pilotes d'actionneurs. +- 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 -Fusion des plugins `pymodaq_plugins_raspberrypi3` et `pymodaq_plugins_raspberrypizero` -dans `pymodaq_plugins_raspberry` — **Sprint 1 : infrastructure et refonte du serveur**. - ### Ajouté -- `version.json` à la racine : version du produit fusionné (SemVer). +- `version.json` à la racine (version du projet, SemVer). - `CHANGELOG.md` et `CONTRIBUTING.md` (stratégie de branches/tags, processus de version). -- Serveur Raspberry refondu dans `src_raspberry/` selon une topologie en couches, - chaque maillon étant une **classe interchangeable** derrière une interface : +- 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 ; @@ -131,9 +120,6 @@ dans `pymodaq_plugins_raspberry` — **Sprint 1 : infrastructure et refonte du s - `main.py` — boucle principale qui assemble les trois couches. ### Modifié -- La gestion des requêtes et la communication matérielle, auparavant mélangées dans - `CProtocolHandler`, sont désormais deux entités distinctes (`JsonRequestHandler` - d'un côté, `HardwareBackend` de l'autre). +- 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. -- Comportement fonctionnel identique au serveur Raspberry Pi 3 d'origine - (la fusion des bancs Pi 3 / Pi Zero arrive au Sprint 2). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f2e9a4..9e43b52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,12 +26,3 @@ La version courante est portée par [`version.json`](version.json) à la racine ### 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`). - -## Découpage du travail de fusion (sprints) - -| Version | Sprint | Contenu | -|---------|--------|---------| -| 5.1.0 | Sprint 1 | Infrastructure (versioning) + refonte du serveur en couches interchangeables | -| 5.2.0 | Sprint 2 | Fusion des bancs Pi 3 / Pi Zero (registres capteurs + actionneurs, pilotage par config) | -| 5.3.0 | Sprint 3 | Intégration du client PyMoDAQ (Link_PMQ, daq_move, daq_viewer) | -| 5.4.0 | Sprint 4 | Config template fusionné, tests, documentation | diff --git a/README.rst b/README.rst index 4d50b60..c2e843b 100644 --- a/README.rst +++ b/README.rst @@ -55,7 +55,11 @@ Viewer0D 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 @@ -105,5 +109,8 @@ 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``). diff --git a/src_raspberry/README.md b/src_raspberry/README.md index 10757b5..410c10d 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -45,7 +45,7 @@ interchangeable** derrière une interface, sauf le `main` qui les assemble : ### Adapter le serveur à un banc -Toute la différence entre deux bancs (Raspberry Pi 3, Pi Zero, …) tient dans +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 diff --git a/src_raspberry/config_examples/config_pizero.py b/src_raspberry/config_examples/config_pizero.py index db65fb9..fcb9d58 100644 --- a/src_raspberry/config_examples/config_pizero.py +++ b/src_raspberry/config_examples/config_pizero.py @@ -1,7 +1,7 @@ """! @file config_pizero.py @brief Exemple de configuration de banc avec actionneurs tout-ou-rien (TOR) et - sonde PT100 (ex-banc Raspberry Pi Zero). + 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 diff --git a/src_raspberry/hardware/actuators.py b/src_raspberry/hardware/actuators.py index 790c071..a9b8c54 100644 --- a/src_raspberry/hardware/actuators.py +++ b/src_raspberry/hardware/actuators.py @@ -64,8 +64,8 @@ class CActuatorDriver(ABC): 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 distingue un banc d'un autre - (ex. Raspberry Pi 3 en PWM vs Raspberry Pi Zero en tout-ou-rien). + 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): diff --git a/version.json b/version.json index 27a0269..44f4ae9 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.3" + "version": "5.4.4" } From 40cfcbe93da7efdcabdc5b006e5dd1ec6dd1efb1 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Mon, 29 Jun 2026 14:49:53 +0200 Subject: [PATCH 09/44] Fix : Resolved Requested Changes from seb5g - version 5.4.5 --- .../daq_move_plugins/daq_move_MoveRasp.py | 45 ++++++++++--------- .../plugins_0D/daq_0Dviewer_ViewRasp.py | 8 ++-- ...fig_Components.py => config_components.py} | 0 .../hardware/{Link_PMQ.py => link_zmq.py} | 0 4 files changed, 29 insertions(+), 24 deletions(-) rename src/pymodaq_plugins_raspberry/hardware/{Config_Components.py => config_components.py} (100%) rename src/pymodaq_plugins_raspberry/hardware/{Link_PMQ.py => link_zmq.py} (100%) 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 index 13465e2..21cdb1a 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -3,8 +3,8 @@ from pymodaq_gui.parameter import Parameter -from ..hardware.Link_PMQ import ZMQLink -from ..hardware.Config_Components import get_actuators_hardware, get_access_variables +from ..hardware.link_zmq import ZMQLink +from ..hardware.config_components import get_actuators_hardware, get_access_variables from pymodaq_plugins_raspberry import config @@ -39,10 +39,10 @@ class DAQ_Move_MoveRasp(DAQ_Move_base): # SETUP AXES VARIABLES #################################################################################################################### - is_multiaxes = False + is_multiaxes = True _axis_names = [actuator['title'] for actuator in actuators] _controller_units = [actuator['units'] for actuator in actuators] - _epsilon = 0.1 + _epsilon = [0.1 for actuator in actuators] data_actuator_type = DataActuatorType.DataActuator current_component = None name_access_variables = get_access_variables(config) @@ -57,12 +57,7 @@ def ini_attributes(self): self.controller: ZMQLink = None for elem in self.actuators: - if elem['title'] == self.settings['multiaxes', 'axis']: - self.current_component = elem - self.settings['bounds', 'max_bound'] = str(int(elem['max'])*100) - self.settings['bounds', 'min_bound'] = str(int(elem['min'])*100) - self.settings['units'] = elem['units'] - break + self.update_move_settings(self.axis_name[0]) self.settings['bounds', 'is_bounds'] = True @@ -93,13 +88,23 @@ def commit_settings(self, param: Parameter): A given parameter (within detector_settings) whose value has been changed by the user """ if param.name() == 'axis': - for elem in self.actuators: - if elem['name'] == param.value(): - self.current_component = elem - self.settings['bounds', 'max_bound'] = elem['max'] - self.settings['bounds', 'min_bound'] = elem['min'] - self.settings['units'] = elem['units'] - break + 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['name'] == 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 @@ -129,7 +134,7 @@ def move_abs(self, value: DataActuator): Parameters ---------- - value: (float) value of the absolute target positioning + value: (DataActuator) value of the absolute target positioning """ value = self.check_bound(value) @@ -143,7 +148,7 @@ def move_rel(self, value: DataActuator): Parameters ---------- - value: (float) value of the relative target positioning + 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 @@ -166,7 +171,7 @@ def move_value(self, value): access_variables = None if isinstance(value, DataActuator): - value = value.value() + value = value.value(self.axis_unit) value /= 100 if self.current_component is not None and (isinstance(value, float) or isinstance(value, int)): 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 index e8015ad..a93f9aa 100644 --- 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 @@ -6,8 +6,8 @@ from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main -from ...hardware.Link_PMQ import ZMQLink -from ...hardware.Config_Components import get_actuators_hardware, get_detectors_hardware, get_access_variables +from ...hardware.link_zmq import ZMQLink +from ...hardware.config_components import get_actuators_hardware, get_detectors_hardware, get_access_variables from pymodaq_plugins_raspberry import config @@ -73,7 +73,7 @@ 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').child('load_actuator_group') + actuator_settings = self.settings.child('load_compo_group', 'load_actuator_group') for elem in actuator_settings: for type_access_variables in self.selected_components: @@ -85,7 +85,7 @@ def ini_attributes(self): except Exception as e: logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") - detectors_settings = self.settings.child('load_compo_group').child('load_detector_group') + detectors_settings = self.settings.child('load_compo_group', 'load_detector_group') for elem in detectors_settings: for type_access_variables in self.selected_components: diff --git a/src/pymodaq_plugins_raspberry/hardware/Config_Components.py b/src/pymodaq_plugins_raspberry/hardware/config_components.py similarity index 100% rename from src/pymodaq_plugins_raspberry/hardware/Config_Components.py rename to src/pymodaq_plugins_raspberry/hardware/config_components.py diff --git a/src/pymodaq_plugins_raspberry/hardware/Link_PMQ.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py similarity index 100% rename from src/pymodaq_plugins_raspberry/hardware/Link_PMQ.py rename to src/pymodaq_plugins_raspberry/hardware/link_zmq.py From 43de107d658c25eb188e57e482d04722cec0d88d Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Mon, 29 Jun 2026 22:58:46 +0200 Subject: [PATCH 10/44] Fix : Resolved other Requested Changes from seb5g - version 5.4.6 --- .../daq_move_plugins/daq_move_MoveRasp.py | 5 +++-- .../daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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 index 21cdb1a..65e3f54 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -1,3 +1,4 @@ +from pint.facets.numpy import quantity from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, main, DataActuator, DataActuatorType) @@ -127,7 +128,7 @@ def ini_stage(self, controller=None): self.controller = controller initialized = True - return "Initialized ? : ", initialized + return "Initialized", initialized def move_abs(self, value: DataActuator): """ Move the actuator to the absolute target defined by value @@ -158,7 +159,7 @@ def move_rel(self, value: DataActuator): def move_home(self): """Call the reference method of the controller""" - self.move_value(0) + 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 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 index a93f9aa..e00438b 100644 --- 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 @@ -121,7 +121,7 @@ def ini_detector(self, controller=None): self.controller = controller initialized = True - return "Initialized ? : ", initialized + return "Initialized", initialized def close(self): """Terminate the communication protocol""" From ca275886f9a9a63d5608a11c985cc4dfa602d202 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Tue, 30 Jun 2026 15:55:27 +0200 Subject: [PATCH 11/44] Fix : Resolved bug relating to the bounds - version 5.4.7 --- .../daq_move_plugins/daq_move_MoveRasp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 65e3f54..2c2f8dc 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -58,7 +58,7 @@ def ini_attributes(self): self.controller: ZMQLink = None for elem in self.actuators: - self.update_move_settings(self.axis_name[0]) + self.update_move_settings(self.axis_name) self.settings['bounds', 'is_bounds'] = True @@ -100,7 +100,7 @@ def update_move_settings(self, 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['name'] == param: + if elem['title'] == param: self.current_component = elem self.settings['bounds', 'max_bound'] = elem['max'] self.settings['bounds', 'min_bound'] = elem['min'] From 444facb07a96a370ba907784a4cf36be2ad054c9 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Tue, 30 Jun 2026 15:58:57 +0200 Subject: [PATCH 12/44] Fix : Resolved units management - version 5.4.8 --- .../daq_move_plugins/daq_move_MoveRasp.py | 9 +++++---- .../resources/config_template.toml | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) 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 index 2c2f8dc..d09cd8f 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -128,6 +128,7 @@ def ini_stage(self, controller=None): self.controller = controller initialized = True + self.settings.child('scaling').setOpts(visible=False) return "Initialized", initialized def move_abs(self, value: DataActuator): @@ -140,7 +141,7 @@ def move_abs(self, value: DataActuator): value = self.check_bound(value) self.target_value = value - #value = self.set_position_with_scaling(value) + value = self.set_position_with_scaling(value) self.move_value(value) @@ -153,7 +154,7 @@ def move_rel(self, value: DataActuator): """ 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) + value = self.set_position_relative_with_scaling(value) self.move_value(value) @@ -174,7 +175,7 @@ def move_value(self, value): if isinstance(value, DataActuator): value = value.value(self.axis_unit) - value /= 100 + # 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)): for elem in self.name_access_variables: @@ -208,7 +209,7 @@ def get_actuator_value(self): ------- float: The position obtained after scaling conversion. """ - return DataActuator(data=self.output_value) + return DataActuator(data=self.output_value, units=self.axis_unit) if __name__ == '__main__': main(__file__) diff --git a/src/pymodaq_plugins_raspberry/resources/config_template.toml b/src/pymodaq_plugins_raspberry/resources/config_template.toml index 024d07f..0ef4718 100644 --- a/src/pymodaq_plugins_raspberry/resources/config_template.toml +++ b/src/pymodaq_plugins_raspberry/resources/config_template.toml @@ -9,7 +9,7 @@ port = '5555' # 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 = "%" +units = "" min = "0" max = "100" # @@ -30,7 +30,7 @@ pin = "00" # 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 = "%" +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" From fffbe78c915808ea1eb237a1cc49469446bb12a4 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:11:44 +0200 Subject: [PATCH 13/44] Fix : resynchronisation de version.json et du CHANGELOG avec l'historique (S0) - version 5.4.9 --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ version.json | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db1b69d..6808893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,41 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/version.json b/version.json index 44f4ae9..4ff34c3 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.4" + "version": "5.4.9" } From 5a501eda75e5d1e5b803bd472194f0d7cef14d98 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:38:47 +0200 Subject: [PATCH 14/44] =?UTF-8?q?Fix=20:=20import=20paresseux=20de=20smbus?= =?UTF-8?q?2=20pour=20d=C3=A9marrer=20la=20simulation=20hors=20Linux=20(S1?= =?UTF-8?q?)=20-=20version=205.4.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ src_raspberry/hardware/backend.py | 7 ++++--- src_raspberry/hardware/scanner.py | 3 ++- version.json | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6808893..5c294ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/hardware/backend.py b/src_raspberry/hardware/backend.py index 7fa32e7..9077b90 100644 --- a/src_raspberry/hardware/backend.py +++ b/src_raspberry/hardware/backend.py @@ -3,8 +3,6 @@ import logging from typing import Optional -from smbus2 import SMBus - from .base import IHardwareBackend from .scanner import CScanner from .sensors import SENSOR_DRIVER_REGISTRY @@ -35,8 +33,11 @@ def __init__(self, configModule): """ 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 (PermissionError, FileNotFoundError, OSError): + except (ImportError, PermissionError, FileNotFoundError, OSError): from unittest.mock import MagicMock i2cBus = MagicMock() isSimulationEnv = True diff --git a/src_raspberry/hardware/scanner.py b/src_raspberry/hardware/scanner.py index 16b532c..0fadd15 100644 --- a/src_raspberry/hardware/scanner.py +++ b/src_raspberry/hardware/scanner.py @@ -1,6 +1,5 @@ #region Imports import logging -from smbus2 import SMBus #endregion #region Logger @@ -25,6 +24,8 @@ def ScanBus(busId: int) -> list: 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): diff --git a/version.json b/version.json index 4ff34c3..8dc7b87 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.9" + "version": "5.4.10" } From 1efcafa0180213e12b02ee76f66bc84a9aa1774e Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:39:15 +0200 Subject: [PATCH 15/44] =?UTF-8?q?Fix=20:=20pigpio=20optionnel=20pour=20d?= =?UTF-8?q?=C3=A9marrer=20la=20simulation=20sans=20d=C3=A9mon=20GPIO=20(S1?= =?UTF-8?q?)=20-=20version=205.4.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ src_raspberry/hardware/actuators.py | 16 ++++++++++++---- version.json | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c294ea..baaf09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/hardware/actuators.py b/src_raspberry/hardware/actuators.py index a9b8c54..87d7d79 100644 --- a/src_raspberry/hardware/actuators.py +++ b/src_raspberry/hardware/actuators.py @@ -5,7 +5,15 @@ from typing import Optional from unittest.mock import MagicMock -import pigpio +# 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 @@ -125,7 +133,7 @@ class CDigitalActuator(CActuatorDriver): """ def setup(self) -> None: - self.pi.set_mode(self.cfg.pin, pigpio.OUTPUT) + 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) @@ -163,9 +171,9 @@ def __init__(self, actuatorsConfig: list): @param actuatorsConfig Liste des dictionnaires de configuration des actionneurs. """ ## @brief Interface de communication avec la librairie pigpio - self.piClient = pigpio.pi() + self.piClient = pigpio.pi() if pigpio is not None else None - if not self.piClient.connected: + if self.piClient is None or not self.piClient.connected: logger.info("pigpio non connecté : passage en mode simulation") self.piClient = MagicMock() self.piClient.connected = True diff --git a/version.json b/version.json index 8dc7b87..1c73768 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.10" + "version": "5.4.11" } From 1cd689250e0d0b9d490d4b0a50122ae3b74492db Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:39:43 +0200 Subject: [PATCH 16/44] =?UTF-8?q?Fix=20:=20relecture=20de=20la=20consigne?= =?UTF-8?q?=20r=C3=A9elle=20des=20actionneurs=20en=20simulation=20(S1)=20-?= =?UTF-8?q?=20version=205.4.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++ src_raspberry/README.md | 3 +- src_raspberry/hardware/actuators.py | 43 ++++++++++++++++++++++++++--- version.json | 2 +- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baaf09d..0ca9d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/README.md b/src_raspberry/README.md index 410c10d..820fdf7 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -81,7 +81,8 @@ python main.py > **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 -> (objets `MagicMock`) pour tester la communication réseau sans la Raspberry Pi. +> (bus I2C factice, actionneurs simulés qui mémorisent leur consigne) pour tester +> la communication réseau sans la Raspberry Pi. ## 📡 Protocole de communication (JSON) diff --git a/src_raspberry/hardware/actuators.py b/src_raspberry/hardware/actuators.py index 87d7d79..b3ecb5b 100644 --- a/src_raspberry/hardware/actuators.py +++ b/src_raspberry/hardware/actuators.py @@ -3,7 +3,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional -from unittest.mock import MagicMock # 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. @@ -155,6 +154,44 @@ def get_value(self) -> int: } #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: """! @@ -175,9 +212,7 @@ def __init__(self, actuatorsConfig: list): if self.piClient is None or not self.piClient.connected: logger.info("pigpio non connecté : passage en mode simulation") - self.piClient = MagicMock() - self.piClient.connected = True - self.piClient.read_value = 1 + self.piClient = CSimulatedPi() ## @brief Dictionnaire des pilotes d'actionneurs indexé par PIN self._actuators: dict = {} diff --git a/version.json b/version.json index 1c73768..bb0a59d 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.11" + "version": "5.4.12" } From 81ae298267a2bbbc691f68dd543a7fa491ec9785 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:40:52 +0200 Subject: [PATCH 17/44] =?UTF-8?q?Fix=20:=20arr=C3=AAt=20propre=20du=20serv?= =?UTF-8?q?eur=20par=20Ctrl+C=20(S1)=20-=20version=205.4.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ src_raspberry/transport/zmq_server.py | 7 ++++++- version.json | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca9d4d..9d60916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/transport/zmq_server.py b/src_raspberry/transport/zmq_server.py index 6c8f7eb..850e777 100644 --- a/src_raspberry/transport/zmq_server.py +++ b/src_raspberry/transport/zmq_server.py @@ -42,7 +42,8 @@ def monitor_connections(monitor_socket) -> None: break except Exception as exc: logger.error("Erreur moniteur : %s", exc) - except zmq.error.ContextTerminated: + 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 @@ -97,6 +98,10 @@ 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) diff --git a/version.json b/version.json index bb0a59d..f10f551 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.12" + "version": "5.4.13" } From db7b04f996ffc3c31aaca4fd2203c1cbc8eebe46 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:44:00 +0200 Subject: [PATCH 18/44] =?UTF-8?q?Fix=20:=20bascule=20en=20simulation=20sig?= =?UTF-8?q?nal=C3=A9e=20en=20WARNING=20avec=20sa=20cause=20(S1)=20-=20vers?= =?UTF-8?q?ion=205.4.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ src_raspberry/hardware/actuators.py | 3 ++- src_raspberry/hardware/backend.py | 6 ++++-- version.json | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d60916..b765f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/hardware/actuators.py b/src_raspberry/hardware/actuators.py index b3ecb5b..a3da295 100644 --- a/src_raspberry/hardware/actuators.py +++ b/src_raspberry/hardware/actuators.py @@ -211,7 +211,8 @@ def __init__(self, actuatorsConfig: list): self.piClient = pigpio.pi() if pigpio is not None else None if self.piClient is None or not self.piClient.connected: - logger.info("pigpio non connecté : passage en mode simulation") + 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 diff --git a/src_raspberry/hardware/backend.py b/src_raspberry/hardware/backend.py index 9077b90..2a9aa5f 100644 --- a/src_raspberry/hardware/backend.py +++ b/src_raspberry/hardware/backend.py @@ -37,11 +37,13 @@ def __init__(self, configModule): # 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): + except (ImportError, PermissionError, FileNotFoundError, OSError) as exc: from unittest.mock import MagicMock i2cBus = MagicMock() isSimulationEnv = True - logger.info("Mode simulation activé pour l'I2C.") + # 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 diff --git a/version.json b/version.json index f10f551..69a0ec1 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.13" + "version": "5.4.14" } From c7adf3c3d5034ab19032dba16dc6aa3179d4a50f Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:44:44 +0200 Subject: [PATCH 19/44] =?UTF-8?q?Fix=20:=20r=C3=A9ponse=20de=20scan=20enve?= =?UTF-8?q?lopp=C3=A9e=20dans=20{state,=20value}=20(S2,=20P1.2)=20-=20vers?= =?UTF-8?q?ion=205.4.15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ src_raspberry/README.md | 2 ++ src_raspberry/handlers/json_handler.py | 5 +++-- version.json | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b765f48..ce329f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/README.md b/src_raspberry/README.md index 820fdf7..61ae354 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -89,6 +89,8 @@ python main.py Le serveur écoute des trames JSON sur un socket ZeroMQ (ROUTER) port `5555`. ### Scan du matériel + +Réponse : `{"state": "ACK", "value": {"actuator": [...], "detector": [...]}}`. ```json {"type": "scan"} ``` diff --git a/src_raspberry/handlers/json_handler.py b/src_raspberry/handlers/json_handler.py index bdced95..7a490fc 100644 --- a/src_raspberry/handlers/json_handler.py +++ b/src_raspberry/handlers/json_handler.py @@ -77,9 +77,10 @@ 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 Dictionnaire listant les périphériques. + @return Dict de réponse dont la valeur liste les périphériques + ({'actuator': [...], 'detector': [...]}). """ - return self.backend.scan() + return self._Ack(self.backend.scan()) def _HandleAcquisition(self, requestData: dict) -> dict: """! diff --git a/version.json b/version.json index 69a0ec1..58c51f0 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.14" + "version": "5.4.15" } From a59c177ce29ff1c9e6b441ebd013c3f86a768a0d Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:45:13 +0200 Subject: [PATCH 20/44] =?UTF-8?q?Fix=20:=20lib=C3=A9ration=20du=20contexte?= =?UTF-8?q?=20ZeroMQ=20et=20logger=20=C3=A0=20la=20place=20de=20print=20(S?= =?UTF-8?q?2,=20P2)=20-=20version=205.4.16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ .../hardware/link_zmq.py | 18 +++++++++++++----- version.json | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce329f1..130772a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index 22f430e..9addc27 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -27,6 +27,8 @@ def __init__(self, ip_address : str, port : str): """ self.__isLinked = False self.__id_socket = "" + self.__context = None + self.__socket = None self.open(ip_address, port) return @@ -40,6 +42,7 @@ def open(self, ip_address : str, port : str): """ assert ip_address is not None, "ERROR - ip address not set" + self.close() # releases the previous socket and context when reopening self.__context = zmq.Context() self.__socket = self.__context.socket(zmq.DEALER) @@ -49,10 +52,10 @@ def open(self, ip_address : str, port : str): self.__socket.connect(f"tcp://{ip_address}:{port}") self.__isLinked = True - print(f"ZMQ LINK -> CONNECTED |" - f" IP ROUTER : {ip_address} |" - f" PORT ROUTER : {port} |" - f" ID DEALER : {self.__id_socket}") + logger.info(f"ZMQ LINK -> CONNECTED |" + f" IP ROUTER : {ip_address} |" + f" PORT ROUTER : {port} |" + f" ID DEALER : {self.__id_socket}") return def close(self): @@ -61,7 +64,12 @@ def close(self): -------------------- :return: Close the ZMQ connection """ - self.__socket.close() + 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 diff --git a/version.json b/version.json index 58c51f0..974bd13 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.15" + "version": "5.4.16" } From 44539a76e888da46e7c78fb7d09e2afaa474afbf Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:47:09 +0200 Subject: [PATCH 21/44] =?UTF-8?q?Fix=20:=20timeout=20de=20r=C3=A9ception?= =?UTF-8?q?=20sur=20le=20socket=20DEALER=20(S2,=20P0.1)=20-=20version=205.?= =?UTF-8?q?4.17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 ++++ .../daq_move_plugins/daq_move_MoveRasp.py | 5 +- .../plugins_0D/daq_0Dviewer_ViewRasp.py | 5 +- .../hardware/link_zmq.py | 75 +++++++++++++++---- .../resources/config_template.toml | 1 + version.json | 2 +- 6 files changed, 85 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 130772a..e9942dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index d09cd8f..e1e140b 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -4,7 +4,7 @@ from pymodaq_gui.parameter import Parameter -from ..hardware.link_zmq import ZMQLink +from ..hardware.link_zmq import ZMQLink, DEFAULT_TIMEOUT_MS from ..hardware.config_components import get_actuators_hardware, get_access_variables from pymodaq_plugins_raspberry import config @@ -122,7 +122,8 @@ def ini_stage(self, controller=None): False if initialization failed otherwise True """ if self.is_master: - self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port")) + self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port"), + config.get(("Raspberry", "timeout_ms"), DEFAULT_TIMEOUT_MS)) initialized = self.controller.get_link_status() else: self.controller = controller 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 index e00438b..d2f7a2e 100644 --- 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 @@ -6,7 +6,7 @@ from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main -from ...hardware.link_zmq import ZMQLink +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 @@ -115,7 +115,8 @@ def ini_detector(self, controller=None): False if initialization failed otherwise True """ if self.is_master: - self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port")) + self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port"), + config.get(("Raspberry", "timeout_ms"), DEFAULT_TIMEOUT_MS)) initialized = self.controller.get_link_status() else: self.controller = controller diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index 9addc27..9cbd906 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -5,6 +5,8 @@ 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 @@ -16,19 +18,24 @@ class ZMQLink: __context : zmq.Context __socket : zmq.Socket __id_socket : str + __address : str + __timeout_ms : int - def __init__(self, ip_address : str, port : str): + 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 @@ -44,12 +51,8 @@ def open(self, ip_address : str, port : str): self.close() # releases the previous socket and context when reopening self.__context = zmq.Context() - self.__socket = self.__context.socket(zmq.DEALER) - - self.__id_socket = str(uuid.uuid4()) - self.__socket.setsockopt_string(zmq.IDENTITY, self.__id_socket) - - self.__socket.connect(f"tcp://{ip_address}:{port}") + self.__address = f"tcp://{ip_address}:{port}" + self.__new_socket() self.__isLinked = True logger.info(f"ZMQ LINK -> CONNECTED |" @@ -73,26 +76,70 @@ def close(self): 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 - """ - self.__socket.send(json.dumps(request).encode('utf-8')) + :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 + :return: The response from a request, sent by the raspberry's script, + or {"state": "ERROR", "value": } on timeout or invalid response """ - inp_mq = self.__socket.recv() + 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') - return json.loads(inp_mq) + 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: """ @@ -140,6 +187,8 @@ def multi_acquisition(self, addresses : list[str] | list[int] = None, pins : lis 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: diff --git a/src/pymodaq_plugins_raspberry/resources/config_template.toml b/src/pymodaq_plugins_raspberry/resources/config_template.toml index 0ef4718..c484cb4 100644 --- a/src/pymodaq_plugins_raspberry/resources/config_template.toml +++ b/src/pymodaq_plugins_raspberry/resources/config_template.toml @@ -3,6 +3,7 @@ # Here, for a ZMQ link, we use an IPv4 address to connect to the Raspberry via Ethernet address_Rasp = '192.158.235.2' port = '5555' +timeout_ms = 2000 # maximum wait for a Raspberry's response, in milliseconds # [Raspberry.ACTUATOR.COMPONENT1] diff --git a/version.json b/version.json index 974bd13..a1c127b 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.16" + "version": "5.4.17" } From 636f63f999683ff4443904a9b24c12d930fb0944 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:48:42 +0200 Subject: [PATCH 22/44] =?UTF-8?q?Fix=20:=20lien=20=C3=A9tabli=20seulement?= =?UTF-8?q?=20si=20la=20carte=20r=C3=A9pond=20au=20scan,=20=C3=A9chec=20d'?= =?UTF-8?q?initialisation=20explicite=20(S2,=20P0.2)=20-=20version=205.4.1?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ .../daq_move_plugins/daq_move_MoveRasp.py | 13 +++++++++---- .../plugins_0D/daq_0Dviewer_ViewRasp.py | 13 +++++++++---- .../hardware/link_zmq.py | 19 +++++++++++++------ version.json | 2 +- 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9942dc..4055b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index e1e140b..a7db64a 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -76,7 +76,7 @@ def user_condition_to_reach_target(self) -> bool: def close(self): """Terminate the communication protocol""" - if self.is_master: + if self.is_master and self.controller is not None: self.controller.close() pass @@ -122,9 +122,14 @@ def ini_stage(self, controller=None): False if initialization failed otherwise True """ if self.is_master: - self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port"), - config.get(("Raspberry", "timeout_ms"), DEFAULT_TIMEOUT_MS)) - initialized = self.controller.get_link_status() + 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 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 index d2f7a2e..5c0745f 100644 --- 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 @@ -115,9 +115,14 @@ def ini_detector(self, controller=None): False if initialization failed otherwise True """ if self.is_master: - self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port"), - config.get(("Raspberry", "timeout_ms"), DEFAULT_TIMEOUT_MS)) - initialized = self.controller.get_link_status() + 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 @@ -126,7 +131,7 @@ def ini_detector(self, controller=None): def close(self): """Terminate the communication protocol""" - if self.is_master: + if self.is_master and self.controller is not None: self.controller.close() pass diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index 9cbd906..6ee4281 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -53,12 +53,19 @@ def open(self, ip_address : str, port : str): self.__context = zmq.Context() self.__address = f"tcp://{ip_address}:{port}" self.__new_socket() - self.__isLinked = True - logger.info(f"ZMQ LINK -> CONNECTED |" - f" IP ROUTER : {ip_address} |" - f" PORT ROUTER : {port} |" - f" ID DEALER : {self.__id_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): @@ -143,7 +150,7 @@ def __timeout_error(self) -> dict: def get_link_status(self) -> bool: """ - Get the status of the socket (True -> open, False -> closed) + Get the status of the link (True -> the raspberry answered, False -> no answer or closed) -------------------- :return: The status of the connection """ diff --git a/version.json b/version.json index a1c127b..e2c9de0 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.17" + "version": "5.4.18" } From f1e4220533ac74ba96ea868fb764b92a479ed2a7 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:51:58 +0200 Subject: [PATCH 23/44] =?UTF-8?q?Fix=20:=20erreur=20de=20lecture=20journal?= =?UTF-8?q?is=C3=A9e=20avec=20son=20composant=20et=20remplac=C3=A9e=20par?= =?UTF-8?q?=20nan=20(S3,=20P0.3,=20P1.3)=20-=20version=205.4.19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ src/pymodaq_plugins_raspberry/hardware/link_zmq.py | 11 ++++++++--- version.json | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4055b62..88c2a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index 6ee4281..d1e719b 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -163,7 +163,8 @@ def multi_acquisition(self, addresses : list[str] | list[int] = None, pins : lis :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 + 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) """ assert addresses is not None or pins is not None, "ERROR: hardware should have an address or a pin" @@ -199,8 +200,12 @@ def multi_acquisition(self, addresses : list[str] | list[int] = None, pins : lis for i, elem in enumerate(inp_mq["value"]): if type(elem) != int and type(elem) != float: - inp_mq["value"][i] = -1 - logger.warning(f"READ ERROR - {inp_mq['value'][i]}") + # 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"] diff --git a/version.json b/version.json index e2c9de0..3638b90 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.18" + "version": "5.4.19" } From 733014cfbcba9fbb64608af88fc5db3d4eec3242 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:53:02 +0200 Subject: [PATCH 24/44] =?UTF-8?q?Fix=20:=20acquisition=20en=20=C3=A9chec?= =?UTF-8?q?=20affich=C3=A9e=20en=20nan=20sur=20chaque=20voie=20avec=20stat?= =?UTF-8?q?ut=20visible=20(S3,=20P0.1,=20P0.3)=20-=20version=205.4.20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ .../plugins_0D/daq_0Dviewer_ViewRasp.py | 6 ++++-- version.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c2a88..7f3739a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index 5c0745f..ae9d56c 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -185,9 +186,10 @@ def grab_data(self, Naverage=1, **kwargs): ######################################################################################################################## if isinstance(data_tot, str) and "ERROR" in data_tot: - mess = f"Viewer ERROR : {data_tot}" - data_tot = [0] + 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.info(f" Viewer Data : {data_tot}") diff --git a/version.json b/version.json index 3638b90..f18f5cc 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.19" + "version": "5.4.20" } From c117b6e8c4c415369320673fa32071504e85d888 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:54:31 +0200 Subject: [PATCH 25/44] =?UTF-8?q?Fix=20:=20acquisition=20sans=20composant?= =?UTF-8?q?=20s=C3=A9lectionn=C3=A9=20signal=C3=A9e=20au=20lieu=20de=20pla?= =?UTF-8?q?nter=20(S3)=20-=20version=205.4.21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ .../plugins_0D/daq_0Dviewer_ViewRasp.py | 9 +++++++++ version.json | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3739a..d860a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index ae9d56c..f693d3b 100644 --- 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 @@ -180,6 +180,15 @@ def grab_data(self, Naverage=1, **kwargs): 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=access_variables[0], pins=access_variables[1]) diff --git a/version.json b/version.json index f18f5cc..c3a9e2f 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.20" + "version": "5.4.21" } From db63215bb039f352bbdc3686f469f7c6b24a9ae0 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:55:07 +0200 Subject: [PATCH 26/44] =?UTF-8?q?Fix=20:=20=C3=A9chec=20de=20pilotage=20af?= =?UTF-8?q?fich=C3=A9=20en=20statut=20PyMoDAQ=20(S3)=20-=20version=205.4.2?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ .../daq_move_plugins/daq_move_MoveRasp.py | 2 ++ version.json | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d860a6a..824c579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index a7db64a..8ab2bb6 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -2,6 +2,7 @@ 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 @@ -204,6 +205,7 @@ def move_value(self, value): 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") diff --git a/version.json b/version.json index c3a9e2f..742e344 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.21" + "version": "5.4.22" } From 866893265aeb9c2393e284043bc5e3f41af026ef Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:59:02 +0200 Subject: [PATCH 27/44] Fix : pilotage par broche uniquement, erreur explicite pour l'adresse I2C (S4, P1.1) - version 5.4.23 --- CHANGELOG.md | 18 ++++++++++ .../daq_move_plugins/daq_move_MoveRasp.py | 20 +++++------ .../hardware/link_zmq.py | 36 ++++++------------- .../resources/config_template.toml | 2 +- src_raspberry/handlers/json_handler.py | 7 +++- version.json | 2 +- 6 files changed, 44 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 824c579..e7d52fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,24 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index 8ab2bb6..8326028 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -6,7 +6,7 @@ from pymodaq_gui.parameter import Parameter from ..hardware.link_zmq import ZMQLink, DEFAULT_TIMEOUT_MS -from ..hardware.config_components import get_actuators_hardware, get_access_variables +from ..hardware.config_components import get_actuators_hardware from pymodaq_plugins_raspberry import config @@ -47,7 +47,6 @@ class DAQ_Move_MoveRasp(DAQ_Move_base): _epsilon = [0.1 for actuator in actuators] data_actuator_type = DataActuatorType.DataActuator current_component = None - name_access_variables = get_access_variables(config) #################################################################################################################### # SETUP SPECIFIC CONFIG FOR PRESET @@ -185,20 +184,17 @@ def move_value(self, value): # 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)): - for elem in self.name_access_variables: - try: - if self.current_component[elem] != 'None': - access_variables = self.current_component[elem] + # 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 elem == "address": - output = self.controller.pilotage(address=access_variables, value=value) - else: - output = self.controller.pilotage(pin=access_variables, value=value) + 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") ######################################################################################################################## - except Exception as e: - logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") if not isinstance(output, str): self.output_value = output diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index d1e719b..2c61029 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -209,39 +209,23 @@ def multi_acquisition(self, addresses : list[str] | list[int] = None, pins : lis return inp_mq["value"] - def pilotage(self, value : str | int, address : str | int = None, pin : str | int = None) -> float | str: + 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 address: The address of the component :param pin: The pin of the component :return: The value read by the component after control """ - assert address is not None or pin is not None, "ERROR: hardware should have an address or a pin" - assert not (address is not None and pin is not None), \ - "ERROR: only one of address or pin should be given, not both" - - inp_mq = None - - if address is not None: - inp_mq = self.__write( - { - "type": "PI", - "register": "add", - "add": address, - "value" : value - } - ) - elif pin is not None: - inp_mq = self.__write( - { - "type": "PI", - "register": "pin", - "pin": pin, - "value" : value - } - ) + inp_mq = self.__write( + { + "type": "PI", + "register": "pin", + "pin": pin, + "value" : value + } + ) if isinstance(inp_mq, dict): if inp_mq["state"] == "ACK": diff --git a/src/pymodaq_plugins_raspberry/resources/config_template.toml b/src/pymodaq_plugins_raspberry/resources/config_template.toml index c484cb4..848777f 100644 --- a/src/pymodaq_plugins_raspberry/resources/config_template.toml +++ b/src/pymodaq_plugins_raspberry/resources/config_template.toml @@ -15,7 +15,7 @@ min = "0" max = "100" # # 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" +# An actuator is driven by its GPIO pin: keep address = "None" (the Raspberry has no I2C actuator driver) address = "None" pin = "00" # diff --git a/src_raspberry/handlers/json_handler.py b/src_raspberry/handlers/json_handler.py index 7a490fc..13fbffa 100644 --- a/src_raspberry/handlers/json_handler.py +++ b/src_raspberry/handlers/json_handler.py @@ -126,7 +126,12 @@ def _HandleMultiAcquisition(self, requestData: dict) -> dict: return self._Ack(valuesList) def _HandlePiloting(self, requestData: dict) -> dict: - """! @brief Gère la requête de pilotage. """ + """! @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") diff --git a/version.json b/version.json index 742e344..96baf7e 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.22" + "version": "5.4.23" } From 8f22427c764171f5ea54fff91819b77312304c33 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 15:59:31 +0200 Subject: [PATCH 28/44] =?UTF-8?q?Fix=20:=20suppression=20du=20handler=20PI?= =?UTF-8?q?-MULTI=20inutilis=C3=A9=20et=20mise=20=C3=A0=20jour=20du=20prot?= =?UTF-8?q?ocole=20document=C3=A9=20(S4,=20P1.4)=20-=20version=205.4.24?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ src_raspberry/README.md | 13 ++++++++++--- src_raspberry/handlers/json_handler.py | 6 ------ version.json | 2 +- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d52fd..4a6bb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/README.md b/src_raspberry/README.md index 61ae354..20c9fc8 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -87,13 +87,14 @@ python main.py ## 📡 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 - -Réponse : `{"state": "ACK", "value": {"actuator": [...], "detector": [...]}}`. ```json {"type": "scan"} ``` +Réponse : `{"state": "ACK", "value": {"actuator": [...], "detector": [...]}}`. ### Acquisition (`AQ`) ```json @@ -105,8 +106,10 @@ Réponse : `{"state": "ACK", "value": {"actuator": [...], "detector": [...]}}`. ```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. -### Modes multiples (`AQ-MULTI`, `PI-MULTI`) +### Acquisition multiple (`AQ-MULTI`) ```json { "type": "AQ-MULTI", @@ -116,3 +119,7 @@ Réponse : `{"state": "ACK", "value": {"actuator": [...], "detector": [...]}}`. ] } ``` +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/handlers/json_handler.py b/src_raspberry/handlers/json_handler.py index 13fbffa..78fd250 100644 --- a/src_raspberry/handlers/json_handler.py +++ b/src_raspberry/handlers/json_handler.py @@ -35,7 +35,6 @@ def __init__(self, hardwareBackend): "AQ": self._HandleAcquisition, "AQ-MULTI": self._HandleMultiAcquisition, "PI": self._HandlePiloting, - "PI-MULTI": self._HandleMultiPiloting, } def handle(self, request: str) -> str: @@ -146,11 +145,6 @@ def _HandlePiloting(self, requestData: dict) -> dict: except Exception as exc: return self._Error(str(exc)) - def _HandleMultiPiloting(self, requestData: dict) -> dict: - """! @brief Gère la requête PI-MULTI. """ - componentsList = requestData.get("components", []) - return self._Ack([self._HandlePiloting(comp) for comp in componentsList]) - @staticmethod def _Ack(valueData) -> dict: """! @brief Formate un succès. """ diff --git a/version.json b/version.json index 96baf7e..e794c47 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.23" + "version": "5.4.24" } From 438e2dbb9e61fba00742c9c8761e8f2b0e865bfa Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:03:06 +0200 Subject: [PATCH 29/44] =?UTF-8?q?Feat=20:=20simulation=20thermique=20r?= =?UTF-8?q?=C3=A9aliste,=20la=20r=C3=A9sistance=20chauffe=20et=20le=20vent?= =?UTF-8?q?ilateur=20refroidit=20(S5)=20-=20version=205.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 18 +++++ src_raspberry/config.py | 8 +++ src_raspberry/hardware/backend.py | 12 +++- src_raspberry/hardware/sensors.py | 114 +++++++++++++++++++++++++++++- version.json | 2 +- 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6bb8c..9756fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,24 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/config.py b/src_raspberry/config.py index 16576d2..46179c4 100644 --- a/src_raspberry/config.py +++ b/src_raspberry/config.py @@ -66,42 +66,50 @@ #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', diff --git a/src_raspberry/hardware/backend.py b/src_raspberry/hardware/backend.py index 2a9aa5f..c00051f 100644 --- a/src_raspberry/hardware/backend.py +++ b/src_raspberry/hardware/backend.py @@ -5,7 +5,7 @@ from .base import IHardwareBackend from .scanner import CScanner -from .sensors import SENSOR_DRIVER_REGISTRY +from .sensors import SENSOR_DRIVER_REGISTRY, CThermalModel from .actuators import CActuatorManager #endregion @@ -87,6 +87,16 @@ def _BuildSensorMap(self, targetBus, isSimulation: bool = False) -> dict: 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') + return instantiatedMap def scan(self) -> dict: diff --git a/src_raspberry/hardware/sensors.py b/src_raspberry/hardware/sensors.py index c1df59b..987b6bc 100644 --- a/src_raspberry/hardware/sensors.py +++ b/src_raspberry/hardware/sensors.py @@ -1,4 +1,5 @@ #region Imports +import math import random import time import logging @@ -123,17 +124,128 @@ def ReadValue(self, **_) -> float: 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) + + 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' + + def AttachModel(self, model: CThermalModel, coupling: float, defaultChannel: str = 'temp') -> 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é). + """ + self.model = model + self.coupling = coupling + self.defaultChannel = defaultChannel + def ReadValue(self, channel: str = None) -> float: """! - @brief Retourne des valeurs factices. + @brief Retourne des valeurs simulées. @param channel Canal de mesure souhaité. @return Valeur simulée. """ + 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) diff --git a/version.json b/version.json index e794c47..0618dc3 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.4.24" + "version": "5.5.0" } From b203983c8909ea851b214701a904c8342a5f4dfe Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:04:21 +0200 Subject: [PATCH 30/44] =?UTF-8?q?Feat=20:=20configuration=20TOML=20de=20d?= =?UTF-8?q?=C3=A9monstration=20align=C3=A9e=20sur=20la=20carte=20simul?= =?UTF-8?q?=C3=A9e=20(S5)=20-=20version=205.5.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 +++ .../resources/config_demo.toml | 65 +++++++++++++++++++ version.json | 2 +- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/pymodaq_plugins_raspberry/resources/config_demo.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9756fea..041f7e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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/version.json b/version.json index 0618dc3..6848868 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.0" + "version": "5.5.1" } From 5c3e725b301c1947e126a23178b77e6df1074537 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:04:45 +0200 Subject: [PATCH 31/44] =?UTF-8?q?Doc=20:=20lancement=20de=20la=20d=C3=A9mo?= =?UTF-8?q?nstration=20en=20mode=20simulation=20(S5)=20-=20version=205.5.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ src_raspberry/README.md | 8 ++++++++ version.json | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 041f7e3..38c1ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/README.md b/src_raspberry/README.md index 20c9fc8..1d25ba8 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -84,6 +84,14 @@ python main.py > (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`. diff --git a/version.json b/version.json index 6848868..858ad61 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.1" + "version": "5.5.2" } From 1f476f0c0527925d831e2145079e87f6c1d69d91 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:14:42 +0200 Subject: [PATCH 32/44] =?UTF-8?q?Test=20:=20cha=C3=AEne=20ZMQLink=20test?= =?UTF-8?q?=C3=A9e=20sans=20mat=C3=A9riel=20avec=20un=20faux=20serveur=20R?= =?UTF-8?q?OUTER=20(S6)=20-=20version=205.5.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 ++++ tests/test_link_zmq.py | 131 +++++++++++++++++++++++++++++++++++++++++ version.json | 2 +- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/test_link_zmq.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c1ae5..3101e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index 858ad61..6cc5651 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.2" + "version": "5.5.3" } From 08130d63e445a2e33b5002ddbbb3196e7ecede9c Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:19:55 +0200 Subject: [PATCH 33/44] =?UTF-8?q?Fix=20:=20coquille=20dans=20l'adresse=20I?= =?UTF-8?q?P=20du=20mod=C3=A8le=20de=20configuration=20(S7,=20P2)=20-=20ve?= =?UTF-8?q?rsion=205.5.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ .../resources/config_template.toml | 2 +- version.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3101e23..ed44844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src/pymodaq_plugins_raspberry/resources/config_template.toml b/src/pymodaq_plugins_raspberry/resources/config_template.toml index 848777f..ae3eb41 100644 --- a/src/pymodaq_plugins_raspberry/resources/config_template.toml +++ b/src/pymodaq_plugins_raspberry/resources/config_template.toml @@ -1,7 +1,7 @@ [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.158.235.2' +address_Rasp = '192.168.235.2' port = '5555' timeout_ms = 2000 # maximum wait for a Raspberry's response, in milliseconds # diff --git a/version.json b/version.json index 6cc5651..63ec14c 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.3" + "version": "5.5.4" } From 697db5901db6a6a1a92e0caaaf22179846144c50 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:20:41 +0200 Subject: [PATCH 34/44] =?UTF-8?q?Fix=20:=20validations=20d'entr=C3=A9e=20p?= =?UTF-8?q?ar=20exceptions=20au=20lieu=20d'assert=20(S7,=20P2)=20-=20versi?= =?UTF-8?q?on=205.5.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ src/pymodaq_plugins_raspberry/hardware/link_zmq.py | 6 ++++-- version.json | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed44844..29ebc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index 2c61029..4668dfe 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -47,7 +47,8 @@ def open(self, ip_address : str, port : str): :param port: The raspberry's communication port (5555 by default) :return: void - Start the ZMQ connection """ - assert ip_address is not None, "ERROR - ip address not set" + 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() @@ -166,7 +167,8 @@ def multi_acquisition(self, addresses : list[str] | list[int] = None, pins : lis 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) """ - assert addresses is not None or pins is not None, "ERROR: hardware should have an address or a pin" + if addresses is None and pins is None: + raise ValueError("ERROR: hardware should have an address or a pin") output = { "type": "AQ-MULTI", diff --git a/version.json b/version.json index 63ec14c..381dee9 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.4" + "version": "5.5.5" } From 99655fd39d3c22159fa42234ac5cb8f28714a526 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:21:26 +0200 Subject: [PATCH 35/44] =?UTF-8?q?Fix=20:=20adresse=20de=20d=C3=A9veloppeme?= =?UTF-8?q?nt=20retir=C3=A9e,=20v=C3=A9rification=20de=20connexion=20param?= =?UTF-8?q?=C3=A9trable=20(S7,=20P2)=20-=20version=205.5.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ src/pymodaq_plugins_raspberry/hardware/link_zmq.py | 14 ++++++++------ version.json | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ebc01..00df3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index 4668dfe..184068e 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -238,11 +238,13 @@ def pilotage(self, value : str | int, pin : str | int) -> float | str: return "ERROR : input type incorrect, dict required" if __name__ == '__main__': - """Main section used during development tests""" + """Quick link check from a terminal (read only, nothing is driven): + python -m pymodaq_plugins_raspberry.hardware.link_zmq [port]""" + import sys - Capteur1 = ZMQLink("172.17.50.41", '5555') + if len(sys.argv) < 2: + sys.exit("usage: python -m pymodaq_plugins_raspberry.hardware.link_zmq [port]") - print(Capteur1.multi_acquisition(addresses=["0x49"])) - print(Capteur1.pilotage("0", pin="18")) - - Capteur1.close() + 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/version.json b/version.json index 381dee9..79c1573 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.5" + "version": "5.5.6" } From b19d652437d47eda9eb15177a08378a22cdda32a Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:22:29 +0200 Subject: [PATCH 36/44] =?UTF-8?q?Fix=20:=20compatibilit=C3=A9=20Python=203?= =?UTF-8?q?.8=20et=203.9=20des=20annotations=20et=20de=20la=20fusion=20de?= =?UTF-8?q?=20dictionnaires=20(S7)=20-=20version=205.5.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ .../hardware/config_components.py | 4 ++-- src/pymodaq_plugins_raspberry/hardware/link_zmq.py | 3 +++ version.json | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00df3fb..77a00fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src/pymodaq_plugins_raspberry/hardware/config_components.py b/src/pymodaq_plugins_raspberry/hardware/config_components.py index c87185f..c4bd60d 100644 --- a/src/pymodaq_plugins_raspberry/hardware/config_components.py +++ b/src/pymodaq_plugins_raspberry/hardware/config_components.py @@ -17,7 +17,7 @@ def get_actuators_hardware(config : Config) -> list: for component in config("Raspberry", "ACTUATOR"): if 'COMPONENT' in component: try: - temp_tab.append(config("Raspberry", "ACTUATOR", component) | {'type': 'bool', 'default': False, 'value': False}) + temp_tab.append({**config("Raspberry", "ACTUATOR", component), 'type': 'bool', 'default': False, 'value': False}) except Exception as e: logger.info(str(e)) return temp_tab @@ -37,7 +37,7 @@ def get_detectors_hardware(config: Config) -> list: for component in config("Raspberry", "DETECTOR"): if 'COMPONENT' in component: try: - temp_tab.append(config("Raspberry", "DETECTOR", component) | {'type': 'bool', 'default': False, 'value': False}) + temp_tab.append({**config("Raspberry", "DETECTOR", component), 'type': 'bool', 'default': False, 'value': False}) except Exception as e: logger.info(str(e)) diff --git a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py index 184068e..d439ce2 100644 --- a/src/pymodaq_plugins_raspberry/hardware/link_zmq.py +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py @@ -1,3 +1,6 @@ +# 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 diff --git a/version.json b/version.json index 79c1573..9bd633b 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.6" + "version": "5.5.7" } From f903ba86048a32ec554915df4d5caf4e71012dde Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:23:12 +0200 Subject: [PATCH 37/44] =?UTF-8?q?Fix=20:=20import=20pint=20inutile=20et=20?= =?UTF-8?q?boucle=20redondante=20retir=C3=A9s=20du=20plugin=20move=20(S7)?= =?UTF-8?q?=20-=20version=205.5.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ .../daq_move_plugins/daq_move_MoveRasp.py | 4 +--- version.json | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a00fa..7be4401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index 8326028..d2862e8 100644 --- a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -1,4 +1,3 @@ -from pint.facets.numpy import quantity from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, main, DataActuator, DataActuatorType) @@ -57,8 +56,7 @@ class DAQ_Move_MoveRasp(DAQ_Move_base): def ini_attributes(self): self.controller: ZMQLink = None - for elem in self.actuators: - self.update_move_settings(self.axis_name) + self.update_move_settings(self.axis_name) self.settings['bounds', 'is_bounds'] = True diff --git a/version.json b/version.json index 9bd633b..ef03518 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.7" + "version": "5.5.8" } From 820e16244baa5f3899f3b2e9bee4453a2f78cb92 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:25:30 +0200 Subject: [PATCH 38/44] =?UTF-8?q?Fix=20:=20acquisition=20ind=C3=A9pendante?= =?UTF-8?q?=20de=20l'ordre=20des=20cl=C3=A9s=20du=20TOML=20(S7)=20-=20vers?= =?UTF-8?q?ion=205.5.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ .../plugins_0D/daq_0Dviewer_ViewRasp.py | 14 ++++++++------ version.json | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be4401..b445e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index f693d3b..5999c2d 100644 --- 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 @@ -169,14 +169,16 @@ def grab_data(self, Naverage=1, **kwargs): kwargs: dict others optionals arguments """ - access_variables = [] + # 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 i, type_access_variables in enumerate(self.selected_components): - access_variables.append([]) - for elem in self.selected_components[type_access_variables]: + for type_access_variables, access_variables in (('address', addresses), ('pin', pins)): + for elem in self.selected_components.get(type_access_variables, []): try: - access_variables[i].append(elem.opts[type_access_variables]) + 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)}") @@ -191,7 +193,7 @@ def grab_data(self, Naverage=1, **kwargs): return # SPECIFIC LINE # ######################################################################################################################## - data_tot = self.controller.multi_acquisition(addresses=access_variables[0], pins=access_variables[1]) + data_tot = self.controller.multi_acquisition(addresses=addresses, pins=pins) ######################################################################################################################## if isinstance(data_tot, str) and "ERROR" in data_tot: diff --git a/version.json b/version.json index ef03518..3439ee5 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.8" + "version": "5.5.9" } From b4616c1e94bd7935fec7da5c88978fc00da051e2 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:25:54 +0200 Subject: [PATCH 39/44] =?UTF-8?q?Fix=20:=20port=20d'=C3=A9coute=20du=20ser?= =?UTF-8?q?veur=20carte=20param=C3=A9trable=20(S7)=20-=20version=205.5.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ src_raspberry/README.md | 3 ++- src_raspberry/main.py | 7 ++++++- version.json | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b445e6a..fb1956c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/README.md b/src_raspberry/README.md index 1d25ba8..d67869e 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -76,7 +76,8 @@ vers `config.py`. ## 🚀 Lancement ```bash -python main.py +python main.py # port 5555 +python main.py --port 5556 # autre port (à reporter dans le TOML côté PC) ``` > **Mode simulation** : si le bus I2C ou le démon pigpio sont inaccessibles diff --git a/src_raspberry/main.py b/src_raspberry/main.py index d4dfb92..1169db8 100644 --- a/src_raspberry/main.py +++ b/src_raspberry/main.py @@ -1,4 +1,5 @@ #region Imports +import argparse import logging import os import sys @@ -34,6 +35,10 @@ def main() -> None: 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)") + args = parser.parse_args() + logger.info("Initialisation de la communication composants...") hardwareBackend = HardwareBackend(config) @@ -41,7 +46,7 @@ def main() -> None: requestHandler = JsonRequestHandler(hardwareBackend) logger.info("Initialisation du serveur réseau...") - transport = ZmqServer(requestHandler, listenPort=5555) + transport = ZmqServer(requestHandler, listenPort=args.port) try: # Le serveur bloque le thread principal en écoutant diff --git a/version.json b/version.json index 3439ee5..43f59f6 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.9" + "version": "5.5.10" } From 7878a7ac06185ca038fdf9843d20358d1315e400 Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:43:21 +0200 Subject: [PATCH 40/44] =?UTF-8?q?Fix=20:=20connexion=20pigpio=20journalis?= =?UTF-8?q?=C3=A9e=20une=20seule=20fois=20au=20d=C3=A9marrage=20(logs)=20-?= =?UTF-8?q?=20version=205.5.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ src_raspberry/hardware/actuators.py | 2 +- version.json | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1956c..0b01963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/hardware/actuators.py b/src_raspberry/hardware/actuators.py index a3da295..d7b95ce 100644 --- a/src_raspberry/hardware/actuators.py +++ b/src_raspberry/hardware/actuators.py @@ -219,6 +219,7 @@ def __init__(self, actuatorsConfig: list): self._actuators: dict = {} self._CheckConnected() + logger.info("Connexion à pigpio établie.") self._LoadConfig(actuatorsConfig) def _CheckConnected(self) -> None: @@ -228,7 +229,6 @@ def _CheckConnected(self) -> None: """ if not self.piClient.connected: raise CPigpioNotConnectedError("Impossible de se connecter à pigpiod.") - logger.info("Connexion à pigpio établie.") def _LoadConfig(self, actuatorsConfig: list) -> None: """! diff --git a/version.json b/version.json index 43f59f6..63e8d53 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.10" + "version": "5.5.11" } From b46175424f17629772eaaa62fe57cbe1a1e199ba Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:44:30 +0200 Subject: [PATCH 41/44] =?UTF-8?q?Fix=20:=20requ=C3=AAtes=20du=20serveur=20?= =?UTF-8?q?carte=20journalis=C3=A9es=20en=20DEBUG,=20option=20--verbose=20?= =?UTF-8?q?(logs)=20-=20version=205.5.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ src_raspberry/README.md | 1 + src_raspberry/main.py | 3 +++ src_raspberry/transport/zmq_server.py | 5 +++-- version.json | 2 +- 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b01963..98e8b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/README.md b/src_raspberry/README.md index d67869e..7074329 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -78,6 +78,7 @@ vers `config.py`. ```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 diff --git a/src_raspberry/main.py b/src_raspberry/main.py index 1169db8..e5dc789 100644 --- a/src_raspberry/main.py +++ b/src_raspberry/main.py @@ -37,7 +37,10 @@ def main() -> None: """ 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) diff --git a/src_raspberry/transport/zmq_server.py b/src_raspberry/transport/zmq_server.py index 850e777..0228639 100644 --- a/src_raspberry/transport/zmq_server.py +++ b/src_raspberry/transport/zmq_server.py @@ -122,12 +122,13 @@ def _run_loop(self) -> None: logger.error("Paquet non UTF-8 reçu — ignoré.") continue - logger.info("← (%s) %s", client_id.hex()[:8], command_str) + # 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.info("→ %s", response_str) + logger.debug("→ %s", response_str) reply_frames = [client_id] if use_delimiter: diff --git a/version.json b/version.json index 63e8d53..74c912b 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.11" + "version": "5.5.12" } From ab3d7a7fb491bf9920514cd261c4245dba18fc1b Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 16:45:02 +0200 Subject: [PATCH 42/44] =?UTF-8?q?Fix=20:=20donn=C3=A9es=20d'acquisition=20?= =?UTF-8?q?journalis=C3=A9es=20en=20DEBUG=20dans=20le=20viewer=20(logs)=20?= =?UTF-8?q?-=20version=205.5.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ .../plugins_0D/daq_0Dviewer_ViewRasp.py | 2 +- version.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e8b21..f94d083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é 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 index 5999c2d..ca0e77c 100644 --- 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 @@ -202,7 +202,7 @@ def grab_data(self, Naverage=1, **kwargs): logger.warning(mess) self.emit_status(ThreadCommand('Update_Status', [mess, 'log'])) else: - logger.info(f" Viewer Data : {data_tot}") + logger.debug(f"Viewer Data : {data_tot}") if isinstance(data_tot, list): if len(labels_tab) == 1: diff --git a/version.json b/version.json index 74c912b..8ce0832 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.12" + "version": "5.5.13" } From 39c2719fcc9d80c89b88de7c7e147bf318380d1f Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 17:41:00 +0200 Subject: [PATCH 43/44] =?UTF-8?q?Fix=20:=20dur=C3=A9es=20de=20lecture=20r?= =?UTF-8?q?=C3=A9alistes=20en=20simulation=20pour=20ne=20plus=20figer=20Py?= =?UTF-8?q?MoDAQ=20(simulation)=20-=20version=205.5.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ src_raspberry/hardware/backend.py | 5 +++-- src_raspberry/hardware/sensors.py | 20 +++++++++++++++++++- version.json | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f94d083..ff2363d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : La version courante est également disponible dans [`version.json`](version.json). +## [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é diff --git a/src_raspberry/hardware/backend.py b/src_raspberry/hardware/backend.py index c00051f..c98a799 100644 --- a/src_raspberry/hardware/backend.py +++ b/src_raspberry/hardware/backend.py @@ -5,7 +5,7 @@ from .base import IHardwareBackend from .scanner import CScanner -from .sensors import SENSOR_DRIVER_REGISTRY, CThermalModel +from .sensors import SENSOR_DRIVER_REGISTRY, SIMULATED_READ_TIME, CThermalModel from .actuators import CActuatorManager #endregion @@ -95,7 +95,8 @@ def _BuildSensorMap(self, targetBus, isSimulation: bool = False) -> dict: sensorConfig = self.config.SENSORS_CONFIG[currentAddr] sensorObj.AttachModel(thermalModel, sensorConfig.get('sim_coupling', 0.5), - 'hum' if sensorConfig.get('units') == 'RH' else 'temp') + 'hum' if sensorConfig.get('units') == 'RH' else 'temp', + SIMULATED_READ_TIME.get(sensorConfig.get('driver'), 0.002)) return instantiatedMap diff --git a/src_raspberry/hardware/sensors.py b/src_raspberry/hardware/sensors.py index 987b6bc..11bd6dd 100644 --- a/src_raspberry/hardware/sensors.py +++ b/src_raspberry/hardware/sensors.py @@ -207,6 +207,18 @@ def Humidity(self, coupling: float) -> float: 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. @@ -223,17 +235,22 @@ def __init__(self, bus, addr: int): 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') -> None: + 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: """! @@ -241,6 +258,7 @@ def ReadValue(self, channel: str = None) -> float: @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': diff --git a/version.json b/version.json index 8ce0832..c949c8e 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.13" + "version": "5.5.14" } From 21b1f14163314831839929d2e9ac8cb7b5cd869e Mon Sep 17 00:00:00 2001 From: Fabien Villedieu Date: Sun, 27 Sep 2026 21:39:24 +0200 Subject: [PATCH 44/44] =?UTF-8?q?Doc=20:=20README=20r=C3=A9par=C3=A9=20sur?= =?UTF-8?q?=20GitHub,=20sections=20configuration,=20simulation,=20tests=20?= =?UTF-8?q?et=20lien=20vers=20le=20wiki=20-=20version=205.5.15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++ README.rst | 79 ++++++++++++++++++++++++++++++++++------- src_raspberry/README.md | 4 +++ version.json | 2 +- 4 files changed, 84 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2363d..09a3620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ et ce projet adhère au [Versioning Sémantique](https://semver.org/lang/fr/) : 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é diff --git a/README.rst b/README.rst index 1f9356e..2b81ed2 100644 --- a/README.rst +++ b/README.rst @@ -35,6 +35,15 @@ Authors * 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 =========== @@ -51,7 +60,7 @@ Viewer0D * **ViewRasp**: read the sensors of the device (e.g. I2C sensors) wired to the Raspberry - + Viewer1D ++++++++ @@ -73,10 +82,10 @@ 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. +* **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** @@ -97,7 +106,7 @@ changed independently: 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_PMQ.py``). + ``ZMQLink`` (``hardware/link_zmq.py``). * **The Raspberry ⇄ components communication.** Each sensor and each actuator is driven by an interchangeable driver selected from the bench configuration @@ -115,7 +124,7 @@ changed independently: - **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_PMQ.py``) that + - **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. @@ -128,6 +137,48 @@ 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 +++++++++++++++++++++++++ @@ -137,10 +188,12 @@ Installation instructions 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``). - - - OnRaspberry Plugins - =================== -* PyMoDAQ’s version >= 5 + (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/src_raspberry/README.md b/src_raspberry/README.md index 7074329..7211124 100644 --- a/src_raspberry/README.md +++ b/src_raspberry/README.md @@ -58,6 +58,10 @@ 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 diff --git a/version.json b/version.json index c949c8e..7a89da0 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "5.5.14" + "version": "5.5.15" }