diff --git a/README.rst b/README.rst index ecfa47e..d217cb6 100644 --- a/README.rst +++ b/README.rst @@ -19,16 +19,17 @@ pymodaq_plugins_arduino :target: https://github.com/PyMoDAQ/pymodaq_plugins_arduino/actions/workflows/Test.yml -This package regroups a list of instrument created around an arduino board. Some instruments use the -Telemetrix library to use python together with the arduino board. - +This package regroups a list of instruments created around an Arduino or ESP32 board. Some +instruments use the Telemetrix library to use Python together with the Arduino board. Others use the +Telemetrix AIO ESP32 library to communicate with an ESP32 board over WiFi. Authors ======= * Sebastien J. Weber (sebastien.weber@cemes.fr) * Jérémie Margueritat - +* Mohamed El Mokhtari (mohamed.elmokhtari26@gmail.com) +* Fabien Villedieu (fabien.villedieu.pro@gmail.com) Instruments =========== @@ -40,24 +41,41 @@ Actuators * **LED**: control of a multicolor LED using three PWM digital outputs and the Telemetrix library. Allows the control of the three color channel independently + * **LEDwithLCD**: same as **LED** actuator but displaying the red, green, blue values on a standard 16x2 liquid crystal display -* **Analog**: data acquisition from analog inputs +* **Servo**: control of a servo motor position (in degrees) on a digital pin using the Telemetrix + library + +* **Analog**: data acquisition from analog inputs +* **FanHeater**: control of a heater and a fan using two PWM outputs and the Telemetrix AIO ESP32 + library. Allows the control of the heater and fan independently over WiFi. The PWM duty cycle + ranges from 0 to 255 (8-bit resolution) Extensions ========== -* **ColorSynthesizer**: DashBoard extension using RBG LED actuators. Allows to quicly select a RGB value and apply those +* **ColorSynthesizer**: DashBoard extension using RBG LED actuators. Allows to quickly select a RGB value and apply those to the actuators +Viewers +======= + +* **Voltage_ADS1115**: reads up to four single-ended analog voltages (AIN0–AIN3) from an ADS1115 + (16-bit) or ADS1015 (12-bit) I2C ADC. The PGA gain, data rate and number of active channels are + configurable. Communication is performed over I2C by an ESP32 running the Telemetrix AIO WiFi + firmware. + +* **Temperature MAX31865**: reads temperature from a PT100 resistance temperature detector (RTD) wired to a + MAX31865 amplifier/ADC board. Communication with the MAX31865 is performed over bit-banged SPI + by an ESP32 running the Telemetrix AIO WiFi firmware. Installation instructions ========================= -* PyMoDAQ version > 4.1.0 - +* PyMoDAQ version > 5.0.0 LED actuator ++++++++++++ @@ -75,6 +93,13 @@ I2C backpack. The functionalities used to drive the LCD are adapted from a micro (https://github.com/brainelectronics/micropython-i2c-lcd) itself adapted from https://github.com/fdebrabander/Arduino-LiquidCrystal-I2C-library +Servo actuator +++++++++++++++ + +The **Servo** actuator uses the telemetrix library. The corresponding sketch should therefore be +uploaded on the arduino board. This allows to drive a servo motor connected to a digital pin of the +Arduino board, the position being commanded in degrees. See https://mryslab.github.io/telemetrix/ + Analog 0D viewer ++++++++++++++++ @@ -82,5 +107,35 @@ The **Analog** 0D viewer uses the telemetrix library. The corresponding sketch s on the arduino board. This allows to acquire data from the analog inputs on an Arduino board from python objects on the connected computer. See https://mryslab.github.io/telemetrix/ +FanHeater actuator +++++++++++++++++++ + +The **FanHeater** actuator uses the telemetrix-aio-esp32 library. The corresponding firmware should +therefore be uploaded on the ESP32 board. This allows to control a heater and a fan connected to +the ESP32 over WiFi from python objects on the connected computer. +See https://mryslab.github.io/telemetrix-esp32/ + +Voltage_ADS1115 0D viewer ++++++++++++++++++++++++++ + +The **Voltage_ADS1115** 0D viewer uses the telemetrix-aio-esp32 library. The corresponding firmware +should therefore be uploaded on the ESP32 board. This allows to acquire analog voltages from an +ADS1115/ADS1015 I2C ADC connected to the ESP32 over WiFi. +See https://mryslab.github.io/telemetrix-esp32/ + +Temperature MAX31865 0D viewer ++++++++++++++++ + +The **Temperature MAX31865** 0D viewer uses the telemetrix-aio-esp32 library. The corresponding firmware should +therefore be uploaded on the ESP32 board. This allows to acquire temperature data from a PT100 +sensor wired to a MAX31865 board connected to the ESP32 over WiFi. + Here are `detailed installation instructions `_. +Wiki +==== + +Additional documentation for this fork is available on the dedicated Wiki: +https://wiki-plugins-dap-pymodaq.github.io/ + + diff --git a/pyproject.toml b/pyproject.toml index 2e2eb64..18e2b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ description = 'Set of instrument plugins implemented using an Arduino Board' dependencies = [ "pymodaq>=5.0.0", 'telemetrix', + 'telemetrix-esp32>=2.1.1', 'pyvisa', 'pyvisa-py', diff --git a/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_FanHeater.py b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_FanHeater.py new file mode 100644 index 0000000..a31912c --- /dev/null +++ b/src/pymodaq_plugins_arduino/daq_move_plugins/daq_move_FanHeater.py @@ -0,0 +1,149 @@ +from typing import Optional + +from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, main, + DataActuatorType, DataActuator) +from pymodaq_utils.utils import ThreadCommand +from pymodaq_gui.parameter import Parameter + +from pymodaq_plugins_arduino.hardware.esp32_telemetrix import ArduinoWifi +from pymodaq_plugins_arduino.utils import Config + +config = Config() + + +class DAQ_Move_FanHeater(DAQ_Move_base): + """Instrument plugin class for the heater and fan actuators. + + This object inherits all functionalities to communicate with PyMoDAQ's DAQ_Move module through + inheritance via DAQ_Move_base. It makes a bridge between the DAQ_Move module and the Python + wrapper of a particular instrument. + + Both actuators are driven by a XY-MOS PWM board connected to an ESP32 over WiFi using the + Telemetrix AIO protocol. The PWM duty cycle ranges from 0 to 255 (8-bit resolution). + + Heater + Fan + + Attributes: + ----------- + controller: object + The particular object that allows communication with the hardware, in general a Python + wrapper around the hardware library. + """ + + _controller_units = '' # raw PWM level (0-255) unit depends on wired device + is_multiaxes = True + _axis_names = { + 'Heater': config('esp32', 'pins', 'heater_pin'), + 'Fan': config('esp32', 'pins', 'fan_pin'), + } + _epsilon = 0.1 + data_actuator_type = DataActuatorType['DataActuator'] + + params = [ + {'title': 'IP Address:', 'name': 'ip_address', 'type': 'str', + 'value': config('esp32', 'ip_address')}, + ] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + + def ini_attributes(self): + self.controller: Optional[ArduinoWifi] = None + + def get_actuator_value(self): + """Get the current value from the hardware with scaling conversion. + + Returns + ------- + float: The position obtained after scaling conversion. + """ + pos = DataActuator(data=self.controller.get_output_pin_value(self.axis_value), units=self.axis_unit) + pos = self.get_position_with_scaling(pos) + return pos + + def close(self): + """Terminate the communication protocol""" + if self.is_master: + self.controller.set_pins_output_to(0) + self.controller.shutdown() + + 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 + """ + pass + + def ini_stage(self, controller=None): + """Actuator communication initialization + + Parameters + ---------- + controller: (object) + 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 = ArduinoWifi( + ip_address=self.settings['ip_address'] + ) + self.set_pins() + else: + self.controller = controller + + info = "Heater and Fan ready" + initialized = True + return info, initialized + + def set_pins(self): + """Configure every axis pin as a PWM output and reset it to 0.""" + for pin in self._axis_names.values(): + self.controller.set_pin_mode_analog_output(pin) + self.controller.analog_write_and_memorize(pin, 0) + + 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) # apply user-defined bounds + self.target_value = value + value = self.set_position_with_scaling(value) # apply scaling if the user specified one + + self.controller.analog_write_and_memorize(self.axis_value, int(value.value(self.axis_unit))) + + 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) + + # PWM value is set in duty-cycle counts (0–255) + self.controller.analog_write_and_memorize(self.axis_value, int(self.target_value.value(self.axis_unit))) + + def move_home(self): + """Call the reference method of the controller""" + self.controller.analog_write_and_memorize(self.axis_value, 0) + + def stop_motion(self): + """Stop the actuator and emits move_done signal""" + pass + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_arduino/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Temperature_MAX31865.py b/src/pymodaq_plugins_arduino/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Temperature_MAX31865.py new file mode 100644 index 0000000..3c32415 --- /dev/null +++ b/src/pymodaq_plugins_arduino/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Temperature_MAX31865.py @@ -0,0 +1,144 @@ +from typing import Optional + +import numpy as np +from pymodaq.utils.data import DataFromPlugins, DataToExport +from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main +from pymodaq.utils.parameter import Parameter + +from pymodaq_plugins_arduino.hardware.esp32_telemetrix import ArduinoWifi +from pymodaq_plugins_arduino.hardware.sensors.max31865.spi_max31865 import MAX31865 +from pymodaq_plugins_arduino.utils import Config + +config = Config() + + +class DAQ_0DViewer_Temperature_MAX31865(DAQ_Viewer_base): + """Instrument plugin class for a 0D viewer. + + This object inherits all functionalities to communicate with PyMoDAQ's DAQ_Viewer module through + inheritance via DAQ_Viewer_base. It makes a bridge between the DAQ_Viewer module and the Python + wrapper of a particular instrument. + + This plugin reads temperature from a PT100 resistance temperature detector (RTD) wired to a + MAX31865 amplifier/ADC board. Communication with the MAX31865 is performed over bit-banged SPI + by an ESP32 running the Telemetrix AIO WiFi firmware. + + The four SPI pin numbers (SCK, MISO, MOSI, CS) are configurable from the PyMoDAQ parameter tree + or from *config_template.toml*. + + Attributes: + ----------- + controller: object + The particular object that allows communication with the hardware, in general a Python + wrapper around the hardware library. + max31865: MAX31865 + The driver object for the MAX31865 chip. + """ + + _controller_units = '°C' + + params = comon_parameters + [ + {'title': 'Connection', 'name': 'connection', 'type': 'group', 'children': [ + {'title': 'IP Address:', 'name': 'ip_address', 'type': 'str', + 'value': config('esp32', 'ip_address')}, + ]}, + {'title': 'SPI Pins (GPIO)', 'name': 'spi_pins', 'type': 'group', 'children': [ + {'title': 'SCK pin:', 'name': 'sck_pin', 'type': 'int', + 'value': config('max31865', 'sck_pin'), + 'tip': 'Nano ESP32 legacy: D13 = GPIO48'}, + {'title': 'MISO pin:', 'name': 'miso_pin', 'type': 'int', + 'value': config('max31865', 'miso_pin'), + 'tip': 'Nano ESP32 legacy: D12 = GPIO47'}, + {'title': 'MOSI pin:', 'name': 'mosi_pin', 'type': 'int', + 'value': config('max31865', 'mosi_pin'), + 'tip': 'Nano ESP32 legacy: D11 = GPIO38'}, + {'title': 'CS pin:', 'name': 'cs_pin', 'type': 'int', + 'value': config('max31865', 'cs_pin'), + 'tip': 'Nano ESP32 legacy: D10 = GPIO21'}, + ]}, + ] + + def ini_attributes(self): + self.controller: Optional[ArduinoWifi] = None + self.max31865: Optional[MAX31865] = None + + 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 + """ + pass + + def ini_detector(self, controller=None): + """Detector communication initialization + + Parameters + ---------- + controller: (object) + 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 = ArduinoWifi( + ip_address=self.settings['connection', 'ip_address'] + ) + else: + self.controller = controller + + self.max31865 = MAX31865( + controller=self.controller, + sck_pin=self.settings['spi_pins', 'sck_pin'], + miso_pin=self.settings['spi_pins', 'miso_pin'], + mosi_pin=self.settings['spi_pins', 'mosi_pin'], + cs_pin=self.settings['spi_pins', 'cs_pin'], + ) + self.max31865.ini_max31865() + + info = "Temperature MAX31865 ready" + initialized = True + return info, initialized + + def close(self): + """Terminate the communication protocol""" + if self.is_master and self.controller is not None: + self.controller.shutdown() + + 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 optional arguments + """ + temperature = self.max31865.get_temperature() + self.dte_signal.emit(DataToExport( + name='Temperature MAX31865', + data=[DataFromPlugins( + name='Temperature', + data=[np.array([temperature])], + dim='Data0D', + labels=['Temperature (°C)'], + )] + )) + + def stop(self): + """Stop the current grab hardware wise if necessary""" + pass + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_arduino/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Voltage_ADS1115.py b/src/pymodaq_plugins_arduino/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Voltage_ADS1115.py new file mode 100644 index 0000000..26d808f --- /dev/null +++ b/src/pymodaq_plugins_arduino/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Voltage_ADS1115.py @@ -0,0 +1,165 @@ +from typing import Optional + +import numpy as np +from pymodaq.utils.data import DataFromPlugins, DataToExport +from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main +from pymodaq.utils.parameter import Parameter + +from pymodaq_plugins_arduino.hardware.esp32_telemetrix import ArduinoWifi +from pymodaq_plugins_arduino.hardware.sensors.ads1115.i2c_ads1115 import ADS1115, GAIN_CONFIG, DATA_RATE_CONFIG +from pymodaq_plugins_arduino.utils import Config + +config = Config() + + +class DAQ_0DViewer_Voltage_ADS1115(DAQ_Viewer_base): + """Instrument plugin class for a 0D viewer. + + This object inherits all functionalities to communicate with PyMoDAQ's DAQ_Viewer module through + inheritance via DAQ_Viewer_base. It makes a bridge between the DAQ_Viewer module and the Python + wrapper of a particular instrument. + + This plugin reads analog voltages from an ADS1115 (16-bit) or ADS1015 (12-bit) I2C ADC. + Communication is performed over I2C by an ESP32 running the Telemetrix AIO WiFi firmware. + + Up to 4 single-ended channels (AIN0–AIN3) can be acquired simultaneously. The number of + active channels, the PGA gain, and the data rate are configurable from the parameter tree. + + Attributes: + ----------- + controller: ArduinoWifi + Connection to the ESP32 board. + ads: ADS1115 + Low-level driver for the ADS1115/ADS1015 chip. + """ + + _controller_units = 'V' + + params = comon_parameters + [ + {'title': 'Connection', 'name': 'connection', 'type': 'group', 'children': [ + {'title': 'IP Address:', 'name': 'ip_address', 'type': 'str', + 'value': config('esp32', 'ip_address')}, + ]}, + {'title': 'I2C Settings', 'name': 'i2c', 'type': 'group', 'children': [ + {'title': 'I2C Address (hex):', 'name': 'i2c_address', 'type': 'int', + 'value': config('ads1115', 'i2c_address'), + 'tip': '0x48=ADDR→GND 0x49=ADDR→VDD 0x4A=ADDR→SDA 0x4B=ADDR→SCL'}, + {'title': 'SDA pin:', 'name': 'sda_pin', 'type': 'int', + 'value': config('ads1115', 'sda_pin'), + 'tip': 'Nano ESP32 : A4 = GPIO11 (SDA par défaut)'}, + {'title': 'SCL pin:', 'name': 'scl_pin', 'type': 'int', + 'value': config('ads1115', 'scl_pin'), + 'tip': 'Nano ESP32 : A5 = GPIO12 (SCL par défaut)'}, + ]}, + {'title': 'ADC Settings', 'name': 'adc', 'type': 'group', 'children': [ + {'title': 'Chip type:', 'name': 'chip_type', 'type': 'list', + 'limits': ['ADS1115 (16-bit)', 'ADS1015 (12-bit)'], + 'value': 'ADS1115 (16-bit)'}, + {'title': 'Gain (PGA):', 'name': 'gain', 'type': 'list', + 'limits': list(GAIN_CONFIG.keys()), + 'value': config('ads1115', 'gain'), + 'tip': 'Gain × → full-scale range: 2/3×→±6.144V 1×→±4.096V 2×→±2.048V 4×→±1.024V 8×→±0.512V 16×→±0.256V'}, + {'title': 'Data rate (SPS):', 'name': 'data_rate', 'type': 'list', + 'limits': list(DATA_RATE_CONFIG.keys()), + 'value': 128}, + {'title': 'Active channels:', 'name': 'num_channels', 'type': 'int', + 'value': 1, 'min': 1, 'max': 4, + 'tip': 'Number of single-ended channels to read (AIN0 … AIN(n-1))'}, + ]}, + ] + + def ini_attributes(self): + self.controller: Optional[ArduinoWifi] = None + self.ads: Optional[ADS1115] = None + + 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() in ('gain', 'data_rate', 'chip_type'): + if self.ads is not None: + self.ads.gain = self.settings['adc', 'gain'] + self.ads.data_rate = self.settings['adc', 'data_rate'] + self.ads.is_ads1015 = self.settings['adc', 'chip_type'] == 'ADS1015 (12-bit)' + + def ini_detector(self, controller=None): + """Detector communication initialization. + + Parameters + ---------- + controller: object + Custom object of a PyMoDAQ plugin (Slave case). None if this is the Master. + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True. + """ + + if self.is_master: + self.controller = ArduinoWifi( + ip_address=self.settings['connection', 'ip_address'] + ) + else: + self.controller = controller + + self.ads = ADS1115( + controller=self.controller, + i2c_address=self.settings['i2c', 'i2c_address'], + sda_pin=self.settings['i2c', 'sda_pin'], + scl_pin=self.settings['i2c', 'scl_pin'], + gain=self.settings['adc', 'gain'], + data_rate=self.settings['adc', 'data_rate'], + is_ads1015=self.settings['adc', 'chip_type'] == 'ADS1015 (12-bit)', + ) + self.ads.ini_ads1115() + + info = "Voltage ADS1115 ready" + initialized = True + return info, initialized + + def close(self): + """Terminate the communication protocol.""" + if self.is_master and self.controller is not None: + self.controller.shutdown() + + def grab_data(self, Naverage=1, **kwargs): + """Start a grab from the detector. + + Parameters + ---------- + Naverage: int + Number of hardware averaging iterations. + kwargs: dict + Other optional arguments. + """ + num_channels = self.settings['adc', 'num_channels'] + + voltages = [] + labels = [] + for ch in range(num_channels): + voltages.append(np.array([self.ads.get_voltage(ch)])) + labels.append(f'AIN{ch} (V)') + + self.dte_signal.emit(DataToExport( + name='Voltage ADS1115', + data=[DataFromPlugins( + name='Voltage', + data=voltages, + dim='Data0D', + labels=labels, + )] + )) + + def stop(self): + """Stop the current grab hardware wise if necessary.""" + pass + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_arduino/hardware/esp32_telemetrix.py b/src/pymodaq_plugins_arduino/hardware/esp32_telemetrix.py new file mode 100644 index 0000000..ad3bbbe --- /dev/null +++ b/src/pymodaq_plugins_arduino/hardware/esp32_telemetrix.py @@ -0,0 +1,115 @@ +import asyncio +import numbers +import threading +from threading import Lock + +lock = Lock() + +# Maps GPIO pin numbers to ESP32 LEDC hardware channels. +# LEDC channels are required when configuring a pin as PWM output. +PIN_TO_CHANNEL = { + 17: 0, # Fan → LEDC channel 0 + 18: 1, # Heater → LEDC channel 1 +} + + +class ArduinoWifi: + """WiFi wrapper for the ESP32 board using the Telemetrix AIO protocol. + + This object exposes a subset of the Telemetrix API over a WiFi connection + and mirrors the interface of the :class:`Arduino` (USB/telemetrix) class so + that higher-level plugins can target either board with minimal changes. + + Attributes: + ----------- + pin_values_output: dict + Keeps track of the last value written to each output pin. + analog_pin_values_input: dict + Keeps track of the last value read from each analog input channel. + """ + + def __init__(self, ip_address: str): + self.pin_values_output = {} + self.analog_pin_values_input = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._loop.run_forever, daemon=True) + self._thread.start() + + future = asyncio.run_coroutine_threadsafe( + self._init_board(ip_address), self._loop + ) + future.result(timeout=10) + + async def _init_board(self, ip_address: str): + # Lazy import: telemetrix_aio_esp32 (and its optional bleak dependency) is + # only needed when actually connecting to the board. Importing it at module + # level would make the whole plugin fail to load (and break plugin discovery + # and the test suite) on environments where the dependency is missing or + # incompatible. + from telemetrix_aio_esp32 import telemetrix_aio_esp32 + self._board = telemetrix_aio_esp32.TelemetrixAioEsp32( + transport_address=ip_address, + autostart=False, + loop=self._loop, + restart_on_shutdown=False, + shutdown_on_exception=False, + ) + await self._board.start_aio() + + def _run(self, coro): + """Submit a coroutine to the board event-loop and block until done.""" + return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout=5) + + @staticmethod + def round_value(value) -> int: + """Clamp *value* to the valid PWM range [0, 255].""" + return max(0, min(255, int(value))) + + def set_pin_mode_analog_output(self, pin: int): + """Configure *pin* as a PWM output (LEDC) on the ESP32. + + The LEDC channel is resolved from :data:`PIN_TO_CHANNEL`; channel 0 + is used as fallback for unmapped pins. + """ + channel = PIN_TO_CHANNEL.get(pin, 0) + self._run(self._board.set_pin_mode_analog_output(pin_number=pin, channel=channel)) + + def analog_write(self, pin: int, value: int): + """Write a raw PWM duty cycle to *pin*. + + ESP32 Core v3.x uses ``ledcWrite(pin, value)`` where the GPIO number is + passed directly as the channel identifier — hence ``channel=pin`` here. + """ + self._run(self._board.analog_write(channel=pin, value=value)) + + def analog_write_and_memorize(self, pin: int, value): + """Write *value* to *pin* and record it in :attr:`pin_values_output`. + + The value is clamped to [0, 255] before being sent. + Thread-safe. + """ + lock.acquire() + value = self.round_value(value) + self.analog_write(pin, value) + self.pin_values_output[pin] = value + lock.release() + + def set_pins_output_to(self, value: int): + """Write *value* to every pin that has been initialised as an output. + + Thread-safe. + """ + lock.acquire() + for pin in self.pin_values_output: + self.analog_write(pin, int(value)) + lock.release() + + def get_output_pin_value(self, pin: int) -> numbers.Number: + """Return the last value written to *pin*, or 0 if never written.""" + return self.pin_values_output.get(pin, 0) + + def shutdown(self): + """Gracefully stop the Telemetrix session and the event-loop thread.""" + self._run(self._board.shutdown()) + self._loop.call_soon_threadsafe(self._loop.stop) diff --git a/src/pymodaq_plugins_arduino/app/__init__.py b/src/pymodaq_plugins_arduino/hardware/sensors/__init__.py similarity index 100% rename from src/pymodaq_plugins_arduino/app/__init__.py rename to src/pymodaq_plugins_arduino/hardware/sensors/__init__.py diff --git a/src/pymodaq_plugins_arduino/hardware/sensors/ads1115/__init__.py b/src/pymodaq_plugins_arduino/hardware/sensors/ads1115/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pymodaq_plugins_arduino/hardware/sensors/ads1115/i2c_ads1115.py b/src/pymodaq_plugins_arduino/hardware/sensors/ads1115/i2c_ads1115.py new file mode 100644 index 0000000..5ae00f1 --- /dev/null +++ b/src/pymodaq_plugins_arduino/hardware/sensors/ads1115/i2c_ads1115.py @@ -0,0 +1,187 @@ +import asyncio +import time + +from pymodaq_plugins_arduino.hardware.esp32_telemetrix import ArduinoWifi +from pymodaq_plugins_arduino.utils import Config + +config = Config() + +I2C_BEGIN = 9 + +# ADS1115/ADS1015 register addresses +ADS_REG_CONVERSION = 0x00 +ADS_REG_CONFIG = 0x01 + +# MUX bits for single-ended channels (bits 14:12 of config register) +_MUX_SINGLE = { + 0: 0x4000, # AIN0 vs GND + 1: 0x5000, # AIN1 vs GND + 2: 0x6000, # AIN2 vs GND + 3: 0x7000, # AIN3 vs GND +} + +# PGA (Gain) settings: gain_label -> (config_bits, full-scale range in V) +GAIN_CONFIG = { + '2/3': (0x0000, 6.144), + '1': (0x0200, 4.096), + '2': (0x0400, 2.048), + '4': (0x0600, 1.024), + '8': (0x0800, 0.512), + '16': (0x0A00, 0.256), +} + +# Data rate bits (bits 7:5 of config register) +DATA_RATE_CONFIG = { + 8: 0x0000, + 16: 0x0020, + 32: 0x0040, + 64: 0x0060, + 128: 0x0080, + 250: 0x00A0, + 475: 0x00C0, + 860: 0x00E0, +} + + +class ADS1115: + """Software driver for the ADS1115 / ADS1015 I2C ADC. + + Communicates with the chip over I2C via the Telemetrix AIO firmware + running on the ESP32. Pins and address are configurable at construction + time or read from *config_template.toml*. + + Attributes: + ----------- + i2c_address: int + 7-bit I2C address of the chip (0x48–0x4B depending on ADDR pin). + sda_pin, scl_pin: int + GPIO numbers for the I2C bus. + gain: str + PGA gain label (one of '2/3', '1', '2', '4', '8', '16'). + data_rate: int + Samples per second (8, 16, 32, 64, 128, 250, 475, 860). + is_ads1015: bool + True for ADS1015 (12-bit); False for ADS1115 (16-bit, default). + """ + + def __init__(self, controller: ArduinoWifi, + i2c_address: int = None, + sda_pin: int = None, + scl_pin: int = None, + gain: str = '1', + data_rate: int = 128, + is_ads1015: bool = False): + self._board = controller._board + self._run = controller._run + + self.i2c_address = i2c_address if i2c_address is not None else config('ads1115', 'i2c_address') + self.sda_pin = sda_pin if sda_pin is not None else config('ads1115', 'sda_pin') + self.scl_pin = scl_pin if scl_pin is not None else config('ads1115', 'scl_pin') + self.gain = gain + self.data_rate = data_rate + self.is_ads1015 = is_ads1015 + + def ini_ads1115(self): + """Initialise the I2C bus on the ESP32. + + set_pin_mode_i2c() in telemetrix_aio_esp32 2.0.0 takes no pin + arguments, so the I2C_BEGIN command is sent manually with the + SDA/SCL pins as payload. Firmware >= 3.1.2 applies them through + Wire.begin(sda, scl); older firmwares ignore the payload and use + the board defaults (A4 = GPIO11 / A5 = GPIO12 on the Nano ESP32, + which match the config defaults). + """ + async def _begin(): + await self._board._send_command( + [I2C_BEGIN, self.sda_pin, self.scl_pin]) + + self._run(_begin()) + # The lib gates i2c_read/i2c_write behind this flag; set it manually + # because we bypassed set_pin_mode_i2c(). + self._board.i2c_active = True + + def read_channel(self, channel: int) -> float: + """Trigger a single-ended conversion on *channel* and return the voltage in V. + + Parameters + ---------- + channel: int + Analog input channel, 0–3 (AIN0–AIN3 vs GND). + + Returns + ------- + float + Measured voltage in volts. + """ + if channel not in _MUX_SINGLE: + raise ValueError(f"Channel must be 0–3, got {channel}") + + gain_bits, fsr = GAIN_CONFIG[self.gain] + dr_bits = DATA_RATE_CONFIG.get(self.data_rate, 0x0080) + + # Build 16-bit config register: + # OS=1 (start single-shot), MUX, PGA, MODE=1 (single-shot), + # DR, COMP_MODE/POL/LAT=0, COMP_QUE=11 (disabled) + config_reg = ( + 0x8000 | + _MUX_SINGLE[channel] | + gain_bits | + 0x0100 | # single-shot mode + dr_bits | + 0x0003 # disable comparator + ) + + msb = (config_reg >> 8) & 0xFF + lsb = config_reg & 0xFF + + # Write config register to start conversion + self._run(self._board.i2c_write( + self.i2c_address, + [ADS_REG_CONFIG, msb, lsb], + )) + + # Allow time for conversion: 1/data_rate + margin + time.sleep(1.0 / self.data_rate + 0.002) + + # Read 2 bytes from conversion register + data = [] + + async def _read(): + event = asyncio.Event() + + async def _callback(report): + # Firmware sends: [len, I2C_READ_REPORT, num_bytes, address, register, byte0, ...] + # Python lib strips len → report = [I2C_READ_REPORT, num_bytes, address, register, byte0, ...] + # Data bytes start at index 4. + data.extend(report[4:4 + report[1]]) + event.set() + + await self._board.i2c_read( + self.i2c_address, + ADS_REG_CONVERSION, + 2, + _callback, + ) + await asyncio.wait_for(event.wait(), timeout=5) + + self._run(_read()) + + raw = (data[0] << 8) | data[1] + + # ADS1015 result occupies the upper 12 bits → shift right by 4 + if self.is_ads1015: + raw >>= 4 + full_scale = 2048.0 + else: + full_scale = 32768.0 + + # Convert unsigned raw to signed (two's complement) + half = int(full_scale) + if raw >= half: + raw -= 2 * half + + return (raw / full_scale) * fsr + + def get_voltage(self, channel: int) -> float: + """Return the voltage (V) measured on *channel* (0–3).""" + return self.read_channel(channel) diff --git a/src/pymodaq_plugins_arduino/hardware/sensors/max31865/__init__.py b/src/pymodaq_plugins_arduino/hardware/sensors/max31865/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pymodaq_plugins_arduino/hardware/sensors/max31865/spi_max31865.py b/src/pymodaq_plugins_arduino/hardware/sensors/max31865/spi_max31865.py new file mode 100644 index 0000000..0f127e9 --- /dev/null +++ b/src/pymodaq_plugins_arduino/hardware/sensors/max31865/spi_max31865.py @@ -0,0 +1,146 @@ +import asyncio + +from pymodaq_plugins_arduino.hardware.esp32_telemetrix import ArduinoWifi +from pymodaq_plugins_arduino.utils import Config + +config = Config() + +SPI_INIT = 22 + +# ── MAX31865 register map ──────────────────────────────────────────────────── +MAX31865_CONFIG_REG = 0x00 # Configuration register (write address = reg | 0x80) +MAX31865_CONFIG_BIAS = 0x80 # Bias voltage ON +MAX31865_CONFIG_MODEAUTO = 0x40 # Auto (continuous) conversion mode +MAX31865_RTDMSB_REG = 0x01 # RTD resistance data MSB (read-only) + +# ── PT100 Callendar-Van Dusen coefficients ─────────────────────────────────── +RTD_NOMINAL = 100.0 # PT100 nominal resistance at 0 °C (Ω) +RTD_REFERENCE = 430.0 # Default reference resistor (Ω) — Adafruit boards use + # 430 Ω, but clones ship with 350/390/400 Ω: check the + # SMD resistor marked Rref next to the chip. +RTD_A = 3.9083e-3 # CVD coefficient A +RTD_B = -5.775e-7 # CVD coefficient B + + +class MAX31865: + """Software driver for the MAX31865 RTD-to-digital converter. + + Communicates with the chip over bit-banged SPI via the Telemetrix AIO + firmware running on the ESP32. The four SPI pins can be passed at + construction time or read from *config_template.toml*. + + Attributes: + ----------- + cs_pin, sck_pin, miso_pin, mosi_pin: int + GPIO numbers of the four SPI lines. + """ + + def __init__(self, controller: ArduinoWifi, + cs_pin: int = None, sck_pin: int = None, + miso_pin: int = None, mosi_pin: int = None, + ref_resistor: float = None): + # Borrow the board handle and the synchronous _run helper from the controller + self._board = controller._board + self._run = controller._run + + self.cs_pin = cs_pin or config('max31865', 'cs_pin') + self.sck_pin = sck_pin or config('max31865', 'sck_pin') + self.miso_pin = miso_pin or config('max31865', 'miso_pin') + self.mosi_pin = mosi_pin or config('max31865', 'mosi_pin') + self.ref_resistor = ref_resistor or RTD_REFERENCE + + def ini_max31865(self): + """Initialise the SPI bus and put the MAX31865 in auto-conversion mode. + + Sequence: + 1. Send SPI_INIT manually with all four pin numbers. + (set_pin_mode_spi([cs]) only forwards the CS pin; the firmware's + init_spi() also needs SCK, MISO and MOSI.) + 2. Force the internal Telemetrix flags that gate spi_cs_control(). + (Bypassing set_pin_mode_spi leaves spi_enabled=False.) + 3. Set the SPI format: the MAX31865 requires SPI mode 1 or 3 + (CPHA=1); the firmware default is mode 0, which the chip ignores. + 4. Write the configuration byte: bias voltage ON + auto conversion. + + Requires firmware >= 3.1.1 (read address sent as-is + SPI format + actually applied through SPI.beginTransaction()). + """ + self._run(self._manual_spi_init()) + # Telemetrix gates spi_cs_control() behind these two flags; set them + # manually because we bypassed the normal set_pin_mode_spi path. + self._board.spi_enabled = True + if self.cs_pin not in self._board.cs_pins_enabled: + self._board.cs_pins_enabled.append(self.cs_pin) + + # 1 MHz (divisor 16 of the Arduino 16 MHz convention), MSB first, + # SPI mode 1 (AVR constant 0x04) as required by the MAX31865. + self._run(self._board.spi_set_format(16, 1, 0x04)) + + config_byte = MAX31865_CONFIG_BIAS | MAX31865_CONFIG_MODEAUTO + self._run(self._board.spi_cs_control(self.cs_pin, 0)) + self._run(self._board.spi_write_blocking([MAX31865_CONFIG_REG | 0x80, config_byte])) + self._run(self._board.spi_cs_control(self.cs_pin, 1)) + + async def _manual_spi_init(self): + """Send SPI_INIT (cmd 22) with all four pin numbers explicitly. + + Payload expected by firmware init_spi(): + [sck_pin, miso_pin, mosi_pin, num_cs=1, cs_pin] + """ + await self._board._send_command([SPI_INIT, + self.sck_pin, self.miso_pin, self.mosi_pin, + 1, self.cs_pin]) + + def read_rtd_resistance(self) -> float: + """Read the raw RTD register and return the equivalent resistance in Ω. + + The MAX31865 stores the 15-bit ADC result in registers 0x01 (MSB) and + 0x02 (LSB). Bit 0 of the LSB is the fault flag and is discarded by + shifting right one position before computing the resistance. + + Note: requires the firmware fix — read_blocking_spi must NOT OR the + register address with 0x80 (MAX31865 convention: bit7=0 = read). + """ + data = [] + + async def read(): + # asyncio.Event must be created inside the running event-loop thread + event = asyncio.Event() + + async def spi_callback(report): + # report layout from firmware: [len, SPI_REPORT, reg, num_bytes, byte0, byte1, ...] + # report[3:] = [byte0, byte1, ...] + data.extend(report[3:]) + event.set() + + await self._board.spi_cs_control(self.cs_pin, 0) + await self._board.spi_read_blocking( + MAX31865_RTDMSB_REG, + 2, + call_back=spi_callback, + ) + await asyncio.wait_for(event.wait(), timeout=5) + await self._board.spi_cs_control(self.cs_pin, 1) + + self._run(read()) + + rtd_raw = ((data[0] << 8) | data[1]) >> 1 # discard fault bit (LSB) + resistance = (rtd_raw / 32768.0) * self.ref_resistor + return resistance + + def resistance_to_temperature(self, resistance: float) -> float: + """Convert a resistance (Ω) to temperature (°C) via the Callendar-Van Dusen equation. + + This approximation is valid for T > 0 °C. + """ + z1 = -RTD_A + z2 = RTD_A ** 2 - (4 * RTD_B) + z3 = (4 * RTD_B) / RTD_NOMINAL + z4 = 2 * RTD_B + temp = (((z2 + z3 * resistance) ** 0.5) + z1) / z4 + return temp + + def get_temperature(self) -> float: + """Return the current probe temperature in °C.""" + resistance = self.read_rtd_resistance() + return self.resistance_to_temperature(resistance) diff --git a/src/pymodaq_plugins_arduino/resources/config_template.toml b/src/pymodaq_plugins_arduino/resources/config_template.toml index 50c49c8..582afb5 100644 --- a/src/pymodaq_plugins_arduino/resources/config_template.toml +++ b/src/pymodaq_plugins_arduino/resources/config_template.toml @@ -1,6 +1,15 @@ title = "this is the configuration file of the plugin Arduino" com_port = "COM24" +ip_address = "172.17.50.53" +ip_port = 31336 + +[esp32] +ip_address = "172.17.50.53" + +[esp32.pins] +heater_pin = 18 +fan_pin = 17 [presets] preset_for_colorsynthesizer = "ArduinoLED" @@ -19,4 +28,26 @@ rows = 2 [servo] pin = 2 pos_1 = 55 -pos_2 = 80 \ No newline at end of file +pos_2 = 80 + +[FanHeater] +[FanHeater.pins] +heater_pin = 9 +heater_fan_pin = 8 + +[max31865] +#Broches SPI pour le capteur MAX31865 / PT100 +#Valeurs par défaut : Nano ESP32 en mode GPIO Legacy +sck_pin = 48 +miso_pin = 47 +mosi_pin = 38 +cs_pin = 21 + +[ads1115] +#Broches I2C et adresse pour le capteur GY-ADS1115 / ADS1015 +#Adresse I2C : 0x48 (ADDR→GND), 0x49 (ADDR→VDD), 0x4A (ADDR→SDA), 0x4B (ADDR→SCL) +i2c_address = 0x48 +sda_pin = 11 # A4 sur Nano ESP32 (GPIO11, SDA par defaut) +scl_pin = 12 # A5 sur Nano ESP32 (GPIO12, SCL par defaut) +gain = "1" +