From 8b7ef111c7cf7acf4811b4c193952fd6712f4356 Mon Sep 17 00:00:00 2001 From: VIL-CIEL Date: Wed, 10 Jun 2026 14:44:55 +0200 Subject: [PATCH 01/12] 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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] 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/12] 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/12] 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/12] 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"