diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db1b69d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,125 @@ +# 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.4.4] - 2026-06-10 + +### Modifié +- `README.rst` : ajout d'une note précisant que le viewer PiCamera n'est disponible + que sur Linux/Raspberry (dépendance `picamera2`). +- Documentation et commentaires nettoyés de références internes peu compréhensibles + hors du contexte de développement. + +## [5.4.3] - 2026-06-10 + +### Corrigé +- Installation impossible sur Windows/macOS : la dépendance `picamera2` (qui tire + `python-prctl`, Linux-only) est désormais conditionnée à Linux via un marqueur + d'environnement (`picamera2; platform_system == "Linux"`). La machine de contrôle + peut installer le plugin (actionneur/détecteur distants) ; le viewer PiCamera + reste disponible sur le Raspberry. + +## [5.4.2] - 2026-06-10 + +### Modifié +- `README.rst` réécrit du point de vue de l'utilisateur du plugin (contrôle d'un + dispositif expérimental via un Raspberry). Mise en avant des trois axes + d'adaptabilité : communication PyMoDAQ ⇄ Raspberry, communication + Raspberry ⇄ composants, et ajout de nouvelles requêtes JSON des deux côtés. + +## [5.4.1] - 2026-06-10 + +### Corrigé +- `CONTRIBUTING.md` : convention de tags alignée sur l'historique du dépôt + (`5.0.0`, `5.0.1`) — les tags de production sont au format `MAJEUR.MINEUR.CORRECTIF` + sans préfixe `v`. + +## [5.4.0] - 2026-06-10 + +### Ajouté +- Documentation du plugin dans `README.rst` : liste des instruments + (`MoveRasp`, `ViewRasp`, `picamera`) et description du serveur Raspberry + (`src_raspberry/`). + +### Modifié +- `README.rst` : mise à jour des auteurs et de la version PyMoDAQ requise (>= 5). + +### Vérifié +- Conformité à `tests/test_plugin_package_structure.py` (conventions de nommage et + méthodes obligatoires des plugins `DAQ_Move_MoveRasp` et `DAQ_0DViewer_ViewRasp`). + +## [5.3.0] - 2026-06-10 + +### Ajouté +- `hardware/Link_PMQ.py` : client `ZMQLink` (lien ZeroMQ DEALER vers le serveur Raspberry). +- `hardware/Config_Components.py` : lecture des composants (actionneurs/détecteurs) + depuis le fichier de configuration TOML. +- `daq_move_plugins/daq_move_MoveRasp.py` : plugin actionneur `DAQ_Move_MoveRasp`. +- `daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py` : plugin détecteur + `DAQ_0DViewer_ViewRasp`. + +### Modifié +- `resources/config_template.toml` : section de configuration `[Raspberry]`. +- `pyproject.toml` : ajout de la dépendance `pyzmq` (requise par `ZMQLink`). +- Le plugin `DAQ_2DViewer_PiCamera` n'est pas modifié. + +### Corrigé +- Remplacement des f-strings à guillemets imbriqués identiques (`f"{d["k"]}"`), + valides seulement en Python 3.12+, par une forme compatible Python 3.8+. + +## [5.2.0] - 2026-06-10 + +### Ajouté +- Pilote de capteur `PT-100` (sonde de température via CAN ADS1115), enregistré + dans `SENSOR_DRIVER_REGISTRY` (import de la dépendance Adafruit isolé localement). +- Pilotes d'actionneurs **interchangeables** derrière l'interface `CActuatorDriver`, + sélectionnés par le champ `driver` de la configuration, et enregistrés dans + `ACTUATOR_DRIVER_REGISTRY` : + - `PWM` — pilotage en rapport cyclique ; + - `DIGITAL` — pilotage tout-ou-rien. +- `config_examples/config_pizero.py` : configuration d'exemple d'un banc tout-ou-rien + avec sonde PT100. + +### Modifié +- `CActuatorManager` instancie désormais le pilote adapté à chaque actionneur + (au lieu d'un pilotage PWM codé en dur), permettant de mélanger des modes de + pilotage différents sur un même banc. +- `CActuatorConfig` accepte un champ `driver` (défaut `PWM`) ; `pwm_frequency` + devient optionnel (inutile pour les actionneurs tout-ou-rien). +- `config.py` documente le choix du pilote par actionneur. + +### Supprimé +- Moniteur de sécurité (`safety_monitor`) : retiré car il lisait des constantes de + configuration inexistantes et n'était jamais démarré par le `main`. + +### Corrigé +- Construction de la cartographie des capteurs unifiée dans `HardwareBackend` + (suppression d'une fonction morte aux références non importées). + +## [5.1.0] - 2026-06-10 + +### Ajouté +- `version.json` à la racine (version du projet, SemVer). +- `CHANGELOG.md` et `CONTRIBUTING.md` (stratégie de branches/tags, processus de version). +- Serveur Raspberry dans `src_raspberry/` selon une topologie en couches, chaque + maillon étant une **classe interchangeable** derrière une interface : + - `ITransport` / `ZmqServer` — communication avec le client PyMoDAQ (ZeroMQ) ; + - `IRequestHandler` / `JsonRequestHandler` — décodage et routage des requêtes JSON, + sans aucun accès matériel ; + - `IHardwareBackend` / `HardwareBackend` — communication avec les capteurs et actionneurs ; + - `main.py` — boucle principale qui assemble les trois couches. + +### Modifié +- La gestion des requêtes et la communication matérielle sont deux entités distinctes + (`JsonRequestHandler` d'un côté, `HardwareBackend` de l'autre). +- Le transport ZeroMQ est isolé de la logique de framing/décodage des messages. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9e43b52 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contribuer + +## Versioning et notes de version + +### Numérotation +Format obligatoire : `MAJEUR.MINEUR.CORRECTIF` (ex. `5.1.0`). + +- **MAJEUR** : refonte ou rupture de compatibilité +- **MINEUR** : nouvelle fonctionnalité +- **CORRECTIF** : correction de bug ou ajustement mineur + +La version courante est portée par [`version.json`](version.json) à la racine du projet. + +### Fichier `CHANGELOG.md` +- Présent à la racine, mis à jour à **chaque** modification livrée. +- Une entrée par version, avec les sections : `Ajouté`, `Modifié`, `Corrigé`, + `Supprimé`, `Sécurité`. +- Aucun merge en production sans mise à jour du `CHANGELOG.md`. + +### Branches Git +- `main` : version en production +- `develop` : version en cours +- `feature/nom-court` : nouvelles fonctionnalités +- `hotfix/nom-court` : correctifs urgents + +### Tags +- Tag Git obligatoire à chaque déploiement en production (ex. `5.1.0`), + aligné sur `version.json` et sur la convention de l'historique (sans préfixe `v`). diff --git a/README.rst b/README.rst index d99012e..1f9356e 100644 --- a/README.rst +++ b/README.rst @@ -19,12 +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. + Authors ======= * Sebastien J. Weber (sebastien.weber@cnrs.fr) +* Fabien Villedieu Instruments @@ -32,6 +40,18 @@ Instruments Below is the list of instruments included in this plugin +Actuators ++++++++++ + +* **MoveRasp**: drive the outputs of the device (e.g. PWM or all-or-nothing + actuators) wired to the Raspberry + +Viewer0D +++++++++ + +* **ViewRasp**: read the sensors of the device (e.g. I2C sensors) wired to the + Raspberry + Viewer1D ++++++++ @@ -41,21 +61,86 @@ Viewer1D Viewer2D ++++++++ -* **picamera**: control of the integrated pi camera using the Picamera2 library +* **picamera**: control of the integrated pi camera using the Picamera2 library. + This viewer relies on ``picamera2``, which is **only available on Linux/Raspberry** + (it depends on a Linux-only package). On a Windows/macOS control machine the + plugin still installs and works for the remote actuator/detector, but the + PiCamera viewer is simply not loaded. -.. if needed use this field - PID Models - ========== +Adapting the plugin to your setup +================================= +Beware: some plugins are meant to be used with PyMoDAQ installed on the Raspberry directly: + + * **daqhats** + * **picamera** + +Some other are using the raspberry and the components plugged on it as an external DAQ connected to a computer. +The computer and the Raspberry communicate over ZMQ: + +* **MoveRasp** +* **ViewRasp** + + +ZMQ plugins +=========== - Extensions - ========== +Communication ++++++++++++++ + +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: + + - **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 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 4 +* 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``). + + + OnRaspberry Plugins + =================== +* PyMoDAQ’s version >= 5 diff --git a/pyproject.toml b/pyproject.toml index 2144b51..b63c382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,11 @@ name = "pymodaq_plugins_raspberry" description = 'Set of instrument plugins to use with a raspberry' dependencies = [ "pymodaq>=5.0.0", - 'picamera2', + 'pyzmq', + # picamera2 ne s'installe que sur Linux/Raspberry (il dépend de python-prctl, + # Linux-only). Sur la machine de contrôle (Windows/macOS), le plugin reste + # installable ; seul le viewer PiCamera est alors indisponible. + 'picamera2; platform_system == "Linux"', ] authors = [ diff --git a/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py new file mode 100644 index 0000000..d09cd8f --- /dev/null +++ b/src/pymodaq_plugins_raspberry/daq_move_plugins/daq_move_MoveRasp.py @@ -0,0 +1,215 @@ +from pint.facets.numpy import quantity +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_zmq 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 = True + _axis_names = [actuator['title'] for actuator in actuators] + _controller_units = [actuator['units'] for actuator in actuators] + _epsilon = [0.1 for actuator in actuators] + data_actuator_type = DataActuatorType.DataActuator + current_component = None + 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: + self.update_move_settings(self.axis_name) + + self.settings['bounds', 'is_bounds'] = True + + pass + + def user_condition_to_reach_target(self) -> bool: + """ Implement a condition for exiting the polling mechanism and specifying that the + target value has been reached + + Returns + ------- + bool: if True, PyMoDAQ considers the target value has been reached + """ + return True + + def close(self): + """Terminate the communication protocol""" + if self.is_master: + self.controller.close() + pass + + def commit_settings(self, param: Parameter): + """Apply the consequences of a change of value in the detector settings + + Parameters + ---------- + param: Parameter + A given parameter (within detector_settings) whose value has been changed by the user + """ + if param.name() == 'axis': + self.update_move_settings(param.value()) + + def update_move_settings(self, param : str): + """Update the settings of the actuators + + Parameters + ---------- + param: str + the name of the parameter (within detector_settings) whose value has been changed by the user + """ + for elem in self.actuators: + if elem['title'] == param: + self.current_component = elem + self.settings['bounds', 'max_bound'] = elem['max'] + self.settings['bounds', 'min_bound'] = elem['min'] + self.settings['units'] = elem['units'] + break + + def ini_stage(self, controller=None): + """Actuator communication initialization + + Parameters + ---------- + controller: (ZMQLink) + custom object of a PyMoDAQ plugin (Slave case). None if only one actuator by controller (Master case) + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True + """ + if self.is_master: + self.controller = ZMQLink(config("Raspberry", "address_Rasp"), config("Raspberry", "port")) + initialized = self.controller.get_link_status() + else: + self.controller = controller + initialized = True + + self.settings.child('scaling').setOpts(visible=False) + return "Initialized", initialized + + def move_abs(self, value: DataActuator): + """ Move the actuator to the absolute target defined by value + + Parameters + ---------- + value: (DataActuator) value of the absolute target positioning + """ + + value = self.check_bound(value) + self.target_value = value + value = self.set_position_with_scaling(value) + + self.move_value(value) + + def move_rel(self, value: DataActuator): + """ Move the actuator to the relative target actuator value defined by value + + Parameters + ---------- + value: (DataActuator) value of the relative target positioning + """ + value = self.check_bound(self.current_position + value) - self.current_position + self.target_value = value + self.current_position + value = self.set_position_relative_with_scaling(value) + + self.move_value(value) + + def move_home(self): + """Call the reference method of the controller""" + self.move_value(DataActuator(name='set_value_home', data=0, units=self.axis_unit)) + + def move_value(self, value): + """ Move the actuator to the target actuator value defined by value + Used by move_rel() and move_abs() + Parameters + ---------- + value: value of the target positioning + """ + output = 0 + access_variables = None + + if isinstance(value, DataActuator): + value = value.value(self.axis_unit) + + # value = (value * 255) / 10000 # Formula for converting the PWM duty cycle percentage to a raw value in PigPio + if self.current_component is not None and (isinstance(value, float) or isinstance(value, int)): + + 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, units=self.axis_unit) + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_raspberry/daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py b/src/pymodaq_plugins_raspberry/daq_viewer_plugins/plugins_0D/daq_0Dviewer_ViewRasp.py new file mode 100644 index 0000000..e00438b --- /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_zmq 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', 'load_actuator_group') + for elem in actuator_settings: + for type_access_variables in self.selected_components: + + try: + if (elem.opts[type_access_variables] != "None" and elem.value() + and elem not in self.selected_components[type_access_variables]): + + self.selected_components[type_access_variables].append(elem) + except Exception as e: + logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") + + detectors_settings = self.settings.child('load_compo_group', 'load_detector_group') + for elem in detectors_settings: + for type_access_variables in self.selected_components: + + try: + if (elem.opts[type_access_variables] != "None" and elem.value() + and elem not in self.selected_components[type_access_variables]): + + self.selected_components[type_access_variables].append(elem) + except Exception as e: + logger.info(f"ERROR - NO ELEMENTS WITH NAME : {str(e)}") + + logger.info(f"Selected Components : {self.selected_components}") + + def ini_detector(self, controller=None): + """Detector communication initialization + + Parameters + ---------- + controller: (ZMQLink) + custom object of a PyMoDAQ plugin (Slave case). None if only one actuator/detector by controller + (Master case) + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True + """ + if self.is_master: + 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_zmq.py b/src/pymodaq_plugins_raspberry/hardware/link_zmq.py new file mode 100644 index 0000000..22f430e --- /dev/null +++ b/src/pymodaq_plugins_raspberry/hardware/link_zmq.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..0ef4718 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 = "V" +# +# Extra variables, they are used to identify the component +# Here, for a I2C network, we use an address and a pin variable to identify and use the component "MyActuator" +address = "0x00" +pin = "None" +# + +#[Raspberry.DETECTOR.COMPONENT2] +#title = "" +#name = "" +#units = "" diff --git a/src_raspberry/README.md b/src_raspberry/README.md new file mode 100644 index 0000000..410c10d --- /dev/null +++ b/src_raspberry/README.md @@ -0,0 +1,115 @@ +# Serveur d'acquisition et de pilotage Raspberry Pi (PyMoDAQ) + +Ce dossier contient le code serveur qui tourne sur la Raspberry Pi. Il fait +l'interface entre le matériel (capteurs I2C, actionneurs) et le réseau via un +serveur ZeroMQ, permettant à un client PyMoDAQ distant de piloter l'ensemble +via des trames JSON. + +> Ce dossier est un ajout au plugin et n'est **pas packagé** : il ne fait pas +> partie de la distribution Python du plugin PyMoDAQ et ne le modifie pas. + +## 🏗️ Architecture en couches + +Le serveur est découpé selon une topologie où **chaque maillon est une classe +interchangeable** derrière une interface, sauf le `main` qui les assemble : + +``` + ZmqServer ──► JsonRequestHandler ──► HardwareBackend + (transport) (gestion requêtes) (comm. composants) + ▲ ▲ ▲ + ITransport IRequestHandler IHardwareBackend + assemblés par main.py (boucle) +``` + +- **`transport/`** — communication avec le client PyMoDAQ. + - `base.py` : interface `ITransport`. + - `zmq_server.py` : implémentation `ZmqServer` (ZeroMQ ROUTER). Ne gère que le + réseau et le framing ; remplaçable par un autre transport. +- **`handlers/`** — gestion des requêtes. + - `base.py` : interface `IRequestHandler`. + - `json_handler.py` : implémentation `JsonRequestHandler` (décodage + routage + JSON). **Aucun accès matériel** : tout est délégué au backend. +- **`hardware/`** — communication avec les composants. + - `base.py` : interface `IHardwareBackend`. + - `backend.py` : implémentation `HardwareBackend` (capteurs + actionneurs). + - `sensors.py` : pilotes de capteurs et `SENSOR_DRIVER_REGISTRY` + (`AHT10`, `TMP102`, `EMC2101`, `PT-100`, `SIMULE`). + - `actuators.py` : pilotes d'actionneurs et `ACTUATOR_DRIVER_REGISTRY` + (`PWM`, `DIGITAL`), plus le gestionnaire `CActuatorManager`. + - `scanner.py` : détection des adresses I2C. +- **`config.py`** — description du banc de test (broches, capteurs, actionneurs). + C'est le **seul fichier à adapter** d'un banc à l'autre. +- **`config_examples/`** — configurations prêtes à l'emploi pour d'autres bancs + (ex. `config_pizero.py` : actionneurs tout-ou-rien + sonde PT100). +- **`main.py`** — point d'entrée : instancie et câble les trois couches. + +### Adapter le serveur à un banc + +Toute la différence entre deux bancs de test tient dans +`config.py` : un capteur choisit son pilote via le champ `driver` (clé de +`SENSOR_DRIVER_REGISTRY`), un actionneur via son propre champ `driver` +(`PWM` ou `DIGITAL`, clé de `ACTUATOR_DRIVER_REGISTRY`). Ajouter un nouveau +matériel = créer une classe de pilote et l'enregistrer dans le registre +correspondant ; ni le transport, ni la gestion des requêtes, ni le `main` +ne changent. + +Pour repartir d'un banc existant, copiez le fichier voulu de `config_examples/` +vers `config.py`. + +## 🛠️ Prérequis et installation + +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..16576d2 --- /dev/null +++ b/src_raspberry/config.py @@ -0,0 +1,113 @@ +"""! +@file config.py +@brief Configuration du banc de test : adresses I2C, broches matérielles et + description des actionneurs / capteurs. + +Chaque Raspberry possède son propre banc de test : ce fichier est l'unique endroit +à adapter pour décrire le matériel présent. Les couches transport / gestion des +requêtes / communication composants ne changent pas d'un banc à l'autre. + +Ce fichier décrit le banc par défaut (actionneurs pilotés en PWM). Des exemples de +configurations pour d'autres bancs sont fournis dans le dossier `config_examples/` +(ex. `config_pizero.py` pour un banc tout-ou-rien avec sonde PT100) : il suffit de +copier le contenu voulu ici. +""" +#region Constantes I2C +## @brief ID du bus I2C matériel +I2C_BUS_ID = 1 +#endregion + +#region Broches matérielles +## @brief Broche matérielle contrôlant le ventilateur +VENTILATEUR_PIN = 18 + +## @brief Broche matérielle contrôlant la résistance chauffante +RESISTANCE_PIN = 23 +#endregion + +#region Adresses I2C des capteurs +## @brief Adresse I2C du capteur de température et d'humidité AHT10 +CAPTEUR_AHT10 = 0x38 + +## @brief Adresse I2C du capteur de température principal TMP102 +CAPTEUR_TMP102 = 0x48 + +## @brief Adresse I2C du contrôleur de ventilateur / capteur EMC2101 +CAPTEUR_EMC2101 = 0x4C +#endregion + +#region Configuration Actionneurs +## @brief Configuration des actionneurs (ventilateurs, résistances, etc.) +ACTUATORS_CONFIG = [ + { + 'pin': VENTILATEUR_PIN, + 'title': 'Ventilateur', + 'name': 'ventilateur', + 'driver': 'PWM', # mode de pilotage : 'PWM' ou 'DIGITAL' + 'units': '%', + 'min': 0, + 'max': 255, + 'address': None, + 'pwm_frequency': 25000, + }, + { + 'pin': RESISTANCE_PIN, + 'title': 'Resistance', + 'name': 'resistance', + 'driver': 'PWM', + 'units': '%', + 'min': 0, + 'max': 255, + 'address': None, + 'pwm_frequency': 100, + }, +] +#endregion + +#region Configuration Capteurs +## @brief Configuration des capteurs détectables sur le bus I2C +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/config_examples/config_pizero.py b/src_raspberry/config_examples/config_pizero.py new file mode 100644 index 0000000..fcb9d58 --- /dev/null +++ b/src_raspberry/config_examples/config_pizero.py @@ -0,0 +1,106 @@ +"""! +@file config_pizero.py +@brief Exemple de configuration de banc avec actionneurs tout-ou-rien (TOR) et + sonde PT100. + +Pour l'utiliser : copier ce contenu dans `src_raspberry/config.py`. +Aucun autre fichier n'a besoin d'être modifié — seuls les pilotes sélectionnés +par la configuration changent (DIGITAL au lieu de PWM, ajout du capteur PT-100). +""" +#region Constantes I2C +## @brief ID du bus I2C matériel +I2C_BUS_ID = 1 +#endregion + +#region Broches matérielles +## @brief Broche matérielle contrôlant le ventilateur (tout-ou-rien) +VENTILATEUR_PIN = 13 + +## @brief Broche matérielle contrôlant la résistance chauffante (tout-ou-rien) +RESISTANCE_PIN = 19 +#endregion + +#region Adresses I2C des capteurs +## @brief Adresse I2C du capteur de température et d'humidité AHT10 +CAPTEUR_AHT10 = 0x38 + +## @brief Adresse I2C de la sonde PT100 (via CAN ADS1115) +CAPTEUR_PT100 = 0x48 + +## @brief Adresse I2C du contrôleur de ventilateur / capteur EMC2101 +CAPTEUR_EMC2101 = 0x4C +#endregion + +#region Configuration Actionneurs +## @brief Actionneurs pilotés en tout-ou-rien (driver 'DIGITAL') +ACTUATORS_CONFIG = [ + { + 'pin': VENTILATEUR_PIN, + 'title': 'Ventilateur', + 'name': 'ventilateur', + 'driver': 'DIGITAL', + 'units': 'Etat', + 'min': 0, + 'max': 1, + 'address': None, + }, + { + 'pin': RESISTANCE_PIN, + 'title': 'Resistance', + 'name': 'resistance', + 'driver': 'DIGITAL', + 'units': 'Etat', + 'min': 0, + 'max': 1, + 'address': None, + }, +] +#endregion + +#region Configuration Capteurs +## @brief Configuration des capteurs détectables sur le bus I2C +SENSORS_CONFIG = { + CAPTEUR_AHT10: { + 'driver': 'AHT10', + 'title': 'aht10', + 'name': 'rh_sortie', + 'units': 'RH', + }, + CAPTEUR_PT100: { + 'driver': 'PT-100', + 'title': 'pt-100', + 'name': 'pt100', + 'units': '°C', + }, + 0x49: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_dissipateur', + 'units': '°C', + }, + 0x4A: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_entree', + 'units': '°C', + }, + 0x4B: { + 'driver': 'TMP102', + 'title': 'tmp102', + 'name': 't_sortie', + 'units': '°C', + }, + CAPTEUR_EMC2101: { + 'driver': 'EMC2101', + 'title': 'emc2101', + 'name': 'T_emc', + 'units': '°C', + }, + 'default': { + 'driver': 'Unknown', + 'title': 'Unknown Sensor', + 'name': 'unknow_sensor', + 'units': '', + }, +} +#endregion diff --git a/src_raspberry/handlers/__init__.py b/src_raspberry/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src_raspberry/handlers/base.py b/src_raspberry/handlers/base.py new file mode 100644 index 0000000..1659559 --- /dev/null +++ b/src_raspberry/handlers/base.py @@ -0,0 +1,26 @@ +#region Imports +from abc import ABC, abstractmethod +#endregion + + +#region Interface Gestion des requêtes +class IRequestHandler(ABC): + """! + @brief Couche de gestion des requêtes. + + Décode une requête brute, route vers l'action correspondante et renvoie une + réponse sérialisée. Cette couche ne contient AUCUN accès matériel : toute + opération sur les composants est déléguée à un IHardwareBackend. Elle peut + donc être remplacée (autre format de protocole) indépendamment du transport + et du matériel. + """ + + @abstractmethod + def handle(self, request: str) -> str: + """! + @brief Traite une requête brute et renvoie la réponse sérialisée. + @param request La requête brute reçue du transport. + @return La réponse sérialisée (chaîne) à renvoyer au client. + """ + raise NotImplementedError +#endregion diff --git a/src_raspberry/handlers/json_handler.py b/src_raspberry/handlers/json_handler.py new file mode 100644 index 0000000..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..a9b8c54 --- /dev/null +++ b/src_raspberry/hardware/actuators.py @@ -0,0 +1,263 @@ +#region Imports +import logging +from abc import ABC, abstractmethod +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 + driver: str = 'PWM' + pwmFrequency: int = 0 + units: str = '' + minVal: int = 0 + maxVal: int = 255 + address: Optional[int] = None + + @classmethod + def FromDict(cls, data: dict) -> "CActuatorConfig": + """! + @brief Construit un objet de configuration à partir d'un dictionnaire. + @param data Dictionnaire contenant les paramètres. + @return CActuatorConfig + """ + return cls( + title = data["title"], + name = data["name"], + pin = data["pin"], + driver = data.get("driver", "PWM"), + pwmFrequency = data.get("pwm_frequency", 0), + units = data.get("units", ''), + minVal = data.get("min", 0), + maxVal = data.get("max", 255), + address = data.get("address"), + ) + +class CPigpioNotConnectedError(RuntimeError): + """! @brief Exception levée quand pigpiod n'est pas joignable. """ + pass + +class CPinNotFoundError(KeyError): + """! @brief Exception levée quand le pin demandé n'existe pas. """ + pass +#endregion + +#region Pilotes d'actionneurs (classes interchangeables) +class CActuatorDriver(ABC): + """! + @brief Classe de base abstraite pour tous les pilotes d'actionneurs. + + Chaque mode de pilotage (PWM, tout-ou-rien, ...) est une classe interchangeable + enregistrée dans ACTUATOR_DRIVER_REGISTRY et sélectionnée par le champ 'driver' + de la configuration de l'actionneur. C'est ce qui permet d'adapter le serveur + au type de pilotage utilisé par chaque banc de test. + """ + + def __init__(self, piClient, actuatorConfig: CActuatorConfig): + """! + @brief Constructeur d'initialisation. + @param piClient Interface pigpio (ou mock en simulation). + @param actuatorConfig Configuration de l'actionneur piloté. + """ + ## @brief Interface pigpio + self.pi = piClient + ## @brief Configuration de l'actionneur + self.cfg = actuatorConfig + + @abstractmethod + def setup(self) -> None: + """! @brief Initialise la broche matérielle. """ + pass + + @abstractmethod + def set_value(self, value: int) -> None: + """! @brief Applique une consigne à l'actionneur. """ + pass + + @abstractmethod + def get_value(self) -> int: + """! @brief Retourne la valeur/état courant de l'actionneur. """ + pass + + def reset(self) -> None: + """! @brief Remet l'actionneur dans son état de repos (éteint). """ + self.set_value(0) + + +class CPwmActuator(CActuatorDriver): + """! + @brief Actionneur piloté en PWM (rapport cyclique 0-255), via pigpio. + """ + + def setup(self) -> None: + self.pi.set_PWM_frequency(self.cfg.pin, self.cfg.pwmFrequency) + self.pi.set_PWM_dutycycle(self.cfg.pin, 0) + logger.debug("PWM configuré : %s (pin %s, %d Hz)", self.cfg.title, self.cfg.pin, self.cfg.pwmFrequency) + + def set_value(self, value: int) -> None: + clampedValue = max(self.cfg.minVal, min(self.cfg.maxVal, value)) + if clampedValue != value: + logger.warning("Valeur %s hors plage pour %s → bornée à %d.", value, self.cfg.title, clampedValue) + self.pi.set_PWM_dutycycle(self.cfg.pin, clampedValue) + + def get_value(self) -> int: + return int(self.pi.get_PWM_dutycycle(self.cfg.pin)) + + +class CDigitalActuator(CActuatorDriver): + """! + @brief Actionneur tout-ou-rien (TOR), piloté en sortie logique 0/1 via pigpio. + """ + + def setup(self) -> None: + self.pi.set_mode(self.cfg.pin, 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 via GPIO. + + Instancie pour chaque actionneur le pilote indiqué par sa configuration + (champ 'driver'), ce qui permet de mélanger des modes de pilotage différents + sur un même banc sans changer cette classe. + """ + + def __init__(self, actuatorsConfig: list): + """! + @brief Initialise la connexion au démon pigpio et charge la configuration. + @param actuatorsConfig Liste des dictionnaires de configuration des actionneurs. + """ + ## @brief Interface de communication avec la librairie pigpio + self.piClient = pigpio.pi() + + if 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 pilotes d'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 Instancie le pilote adapté à chaque actionneur configuré. + @param actuatorsConfig Liste des dictionnaires de configuration. + """ + for rawConfig in actuatorsConfig: + actuatorDef = CActuatorConfig.FromDict(rawConfig) + driverClass = ACTUATOR_DRIVER_REGISTRY.get(actuatorDef.driver) + if driverClass is None: + logger.warning("Driver actionneur '%s' inconnu (pin %s) — ignoré.", + actuatorDef.driver, actuatorDef.pin) + continue + actuator = driverClass(self.piClient, actuatorDef) + actuator.setup() + self._actuators[actuatorDef.pin] = actuator + logger.info(f"{len(self._actuators)} actionneur(s) configuré(s).") + + def GetActuatorsInfo(self) -> list: + """! + @brief Retourne la liste des configurations d'actionneurs. + @return list de CActuatorConfig + """ + return [actuator.cfg for actuator in self._actuators.values()] + + def SetPin(self, pinTarget: int, powerValue: int) -> None: + """! + @brief Applique une consigne à un actionneur. + @param pinTarget Numéro GPIO BCM. + @param powerValue Consigne souhaitée. + """ + self._CheckConnected() + self._GetActuator(pinTarget).set_value(powerValue) + + def GetPinValue(self, pinTarget: int) -> int: + """! + @brief Retourne la valeur/état actuel du pin. + @param pinTarget Numéro GPIO. + @return int Valeur courante. + """ + self._CheckConnected() + actuator = self._GetActuator(pinTarget) + try: + return actuator.get_value() + except Exception as exc: + raise RuntimeError(f"Impossible de lire la valeur du pin {pinTarget}.") from exc + + def Cleanup(self) -> None: + """! + @brief Éteint tous les actionneurs et ferme la connexion. + """ + if not self.piClient.connected: + return + logger.info("Arrêt des actionneurs...") + for actuator in self._actuators.values(): + actuator.reset() + self.piClient.stop() + logger.info("Connexion pigpio fermée.") + + def __enter__(self) -> "CActuatorManager": + return self + + def __exit__(self, *_) -> None: + self.Cleanup() + + def _GetActuator(self, pinTarget: int) -> CActuatorDriver: + """! + @brief Récupère le pilote d'un actionneur selon son PIN. + @param pinTarget Numéro de broche. + @return CActuatorDriver + """ + try: + return self._actuators[pinTarget] + except KeyError: + raise CPinNotFoundError(f"Pin {pinTarget} introuvable.") +#endregion diff --git a/src_raspberry/hardware/backend.py b/src_raspberry/hardware/backend.py new file mode 100644 index 0000000..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..c1df59b --- /dev/null +++ b/src_raspberry/hardware/sensors.py @@ -0,0 +1,203 @@ +#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) + +class CDriverPt100(CSensorDriver): + """! + @brief Pilote pour une sonde de température PT100 lue via un CAN ADS1115. + + La PT100 ne communique pas directement sur le bus I2C du serveur : elle est + lue au travers d'un convertisseur analogique-numérique ADS1115 (librairie + Adafruit_ADS1x15). L'argument `bus` n'est donc pas utilisé, mais conservé + pour respecter l'interface commune des pilotes de capteurs. + """ + + def __init__(self, bus, addr: int): + super().__init__(bus, addr) + # Import local pour isoler la dépendance optionnelle Adafruit_ADS1x15 : + # le module sensors reste importable même si la librairie est absente. + try: + import Adafruit_ADS1x15 + self._adc = Adafruit_ADS1x15.ADS1115() + ## @brief Gain de l'amplificateur du CAN + self.GAIN = 1 + ## @brief Tension d'alimentation du pont (V) + self.TENSION_VA = 3.29 + ## @brief Valeur brute maximale du CAN + self.MAXI = 26300.0 + ## @brief Résistance de pont (Ohm) + self.RP = 97.7 + ## @brief Résistance nominale de la PT100 à 0 °C (Ohm) + self.R0 = 100.0 + ## @brief Coefficient de température de la platine + self.ALPHA = 0.00385 + except Exception as exc: + logger.error("Erreur d'initialisation de l'ADS1115 (PT100) : %s", exc) + + def ReadValue(self, **_) -> float: + """! + @brief Lit la température via le CAN et la loi de la platine. + @return Température en degrés Celsius (0.0 en cas d'échec). + """ + try: + rawValue = self._adc.read_adc(0, gain=self.GAIN) # canal A0 + tensionPT100 = (rawValue / self.MAXI) * self.TENSION_VA + denominateur = self.TENSION_VA - tensionPT100 + if abs(denominateur) < 0.001: + return 0.0 + Rpt100 = (tensionPT100 * self.RP) / denominateur # résistance du pont + temperature = (Rpt100 - self.R0) / (self.R0 * self.ALPHA) # loi de la platine + return round(temperature, 2) + except Exception as exc: + logger.warning("Échec de lecture PT100 : %s", exc) + return 0.0 +#endregion + +#region Registre +## @brief Dictionnaire regroupant l'ensemble des drivers de capteurs. +# Pour ajouter un capteur : créer une classe héritant de CSensorDriver puis +# l'enregistrer ici sous le nom utilisé dans le champ 'driver' de la config. +SENSOR_DRIVER_REGISTRY: dict = { + 'AHT10': CDriverAht10, + 'TMP102': CDriverTmp102, + 'EMC2101': CDriverEmc2101, + 'PT-100': CDriverPt100, + 'SIMULE': CDriverSimule, +} +#endregion diff --git a/src_raspberry/main.py b/src_raspberry/main.py new file mode 100644 index 0000000..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..44f4ae9 --- /dev/null +++ b/version.json @@ -0,0 +1,3 @@ +{ + "version": "5.4.4" +}