From d8a7c9c6e0e4bfe56e5f7a1a07c844e241856c8a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 5 Jan 2020 00:46:46 +0100 Subject: [PATCH] Added Arduino over Firmata integration - closes #92 --- docs/source/conf.py | 1 + platypush/backend/sensor/__init__.py | 18 +- platypush/backend/sensor/arduino.py | 18 ++ platypush/message/request/__init__.py | 27 +- platypush/plugins/arduino.py | 382 ++++++++++++++++++++++++++ platypush/plugins/switch/tplink.py | 1 + requirements.txt | 4 + setup.py | 2 + 8 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 platypush/backend/sensor/arduino.py create mode 100644 platypush/plugins/arduino.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 137519489..2bd472bc9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -233,6 +233,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'trello', 'telegram', 'telegram.ext', + 'pyfirmata2' ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/backend/sensor/__init__.py b/platypush/backend/sensor/__init__.py index 04780e563..263556af5 100644 --- a/platypush/backend/sensor/__init__.py +++ b/platypush/backend/sensor/__init__.py @@ -119,15 +119,16 @@ class SensorBackend(Backend): continue tolerance = None - isNaN = False + is_nan = False + old_v = None try: v = float(v) old_v = float(self.data.get(k)) except (TypeError, ValueError): - isNaN = True + is_nan = True - if not isNaN: + if not is_nan: if isinstance(self.tolerance, dict): tolerance = float(self.tolerance.get(k, self.default_tolerance)) else: @@ -136,12 +137,19 @@ class SensorBackend(Backend): except (TypeError, ValueError): pass - if tolerance is None or isNaN or \ - abs(v - old_v) >= tolerance: + if tolerance is None or is_nan or abs(v - old_v) >= tolerance: ret[k] = v return ret + def on_stop(self): + if not self.plugin: + return + + plugin = get_plugin(self.plugin) + if plugin and hasattr(plugin, 'close'): + plugin.close() + def run(self): super().run() self.logger.info('Initialized {} sensor backend'.format(self.__class__.__name__)) diff --git a/platypush/backend/sensor/arduino.py b/platypush/backend/sensor/arduino.py new file mode 100644 index 000000000..a89d12e43 --- /dev/null +++ b/platypush/backend/sensor/arduino.py @@ -0,0 +1,18 @@ +from platypush.backend.sensor import SensorBackend + + +class SensorArduinoBackend(SensorBackend): + """ + This backend listens for new events from an Arduino with a Firmata-compatible firmware. + + Requires: + + * The :class:`platypush.plugins.arduino.ArduinoPlugin` plugin configured. + + """ + + def __init__(self, **kwargs): + super().__init__(plugin='arduino', **kwargs) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/request/__init__.py b/platypush/message/request/__init__.py index becaf298c..5423e4ddb 100644 --- a/platypush/message/request/__init__.py +++ b/platypush/message/request/__init__.py @@ -51,8 +51,10 @@ class Request(Message): 'args': msg.get('args', {}), 'id': msg['id'] if 'id' in msg else cls._generate_id(), 'timestamp': msg['_timestamp'] if '_timestamp' in msg else time.time()} - if 'origin' in msg: args['origin'] = msg['origin'] - if 'token' in msg: args['token'] = msg['token'] + if 'origin' in msg: + args['origin'] = msg['origin'] + if 'token' in msg: + args['token'] = msg['token'] return cls(**args) @staticmethod @@ -78,11 +80,12 @@ class Request(Message): def _expand_context(self, event_args=None, **context): from platypush.config import Config - if event_args is None: event_args = copy.deepcopy(self.args) + if event_args is None: + event_args = copy.deepcopy(self.args) constants = Config.get_constants() context['constants'] = {} - for (name,value) in constants.items(): + for (name, value) in constants.items(): context['constants'][name] = value keys = [] @@ -103,6 +106,7 @@ class Request(Message): return event_args + # noinspection PyBroadException @classmethod def expand_value_from_context(cls, _value, **context): for (k, v) in context.items(): @@ -142,9 +146,9 @@ class Request(Message): parsed_value += prefix + ( json.dumps(context_value) - if isinstance(context_value, list) - or isinstance(context_value, dict) - else str(context_value)) + if isinstance(context_value, list) or isinstance(context_value, dict) + else str(context_value) + ) else: parsed_value += _value _value = '' @@ -219,6 +223,10 @@ class Request(Message): elif not response.disable_logging: logger.info('Processed response from action {}: {}'. format(action, str(response))) + except AssertionError as e: + plugin.logger.exception(e) + logger.warning('Assertion error from action [{}]: {}'.format(action, str(e))) + response = Response(output=None, errors=[str(e)]) except Exception as e: # Retry mechanism plugin.logger.exception(e) @@ -230,10 +238,10 @@ class Request(Message): errors.append(str(e)) response = Response(output=None, errors=errors) - if _n_tries-1 > 0: + if _n_tries - 1 > 0: logger.info('Reloading plugin {} and retrying'.format(module_name)) get_plugin(module_name, reload=True) - response = _thread_func(_n_tries=_n_tries - 1, errors=errors) + response = _thread_func(_n_tries=_n_tries-1, errors=errors) finally: self._send_response(response) return response @@ -266,5 +274,4 @@ class Request(Message): '_timestamp': self.timestamp, }) - # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/arduino.py b/platypush/plugins/arduino.py new file mode 100644 index 000000000..8de22b171 --- /dev/null +++ b/platypush/plugins/arduino.py @@ -0,0 +1,382 @@ +import enum +import time + +from typing import Optional, Dict, Union, Callable, Tuple, Type + +from pyfirmata2 import Arduino, ArduinoMega, ArduinoDue, ArduinoNano, Pin, util, ANALOG, INPUT, PWM + +from platypush.plugins import action +from platypush.plugins.gpio.sensor import GpioSensorPlugin + + +class PinType(enum.IntEnum): + ANALOG = 1 + DIGITAL = 2 + + +class BoardType(enum.Enum): + MEGA = 'mega' + DUE = 'due' + NANO = 'nano' + + +# noinspection PyBroadException +class ArduinoPlugin(GpioSensorPlugin): + """ + Interact with an Arduino connected to the host machine over USB using the + `Firmata `_ protocol. + + You have two options to communicate with an Arduino-compatible board over USB: + + - Use this plugin if you want to use the general-purpose Firmata protocol - in this case most of your + processing logic will be on the host side and you can read/write data to the Arduino transparently. + - Use the :class:`platypush.plugins.serial.SerialPlugin` if instead you want to run more custom logic + on the Arduino and communicate back with the host computer through JSON formatted messages. + + Download and flash the + `Standard Firmata `_ + firmware to the Arduino in order to use this plugin. + + Requires: + + * **pyfirmata2** (``pip install pyfirmata2``) + + """ + + def __init__(self, + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: int = 57600, + analog_pins: Optional[Dict[str, int]] = None, + digital_pins: Optional[Dict[str, int]] = None, + timeout: float = 20.0, + conv_functions: Optional[Dict[Union[str, int], Union[str, Callable]]] = None, + **kwargs): + """ + :param board: Default board name or path (e.g. ``COM3`` on Windows or ``/dev/ttyUSB0`` on Unix). If not set + then the plugin will attempt an auto-discovery. + + :param board_type: Default board type. It can be 'mega', 'due' or 'nano'. Leave empty for auto-detection. + :param baud_rate: Default serial baud rate (default: 57600) + :param analog_pins: Optional analog PINs map name->pin_number. + :param digital_pins: Optional digital PINs map name->pin_number. + :param timeout: Board communication timeout in seconds. + :param conv_functions: Optional mapping of conversion functions to apply to the analog values read from a + certain PIN. The key can either be the PIN number or the name as specified in ``analog_pins``, the value + can be a function that takes an argument and transforms it or its lambda string representation. + Note that ``analog_read`` returns by default float values in the range [0.0, 1.0]. Example: + + ..code-block:: yaml + + arduino: + board: /dev/ttyUSB0 + analog_pins: + temperature: 1 # Analog PIN 1 + + conv_functions: + temperature: 'lambda t: t * 500.0' + + """ + super().__init__(**kwargs) + + self.board = board + self.board_type = self._get_board_type(board_type) + self.baud_rate = baud_rate + self.timeout = timeout + + self._pin_number_by_name = { + PinType.ANALOG: analog_pins or {}, + PinType.DIGITAL: digital_pins or {}, + } + + self._pin_name_by_number = { + PinType.ANALOG: {number: name for name, number in self._pin_number_by_name[PinType.ANALOG].items()}, + PinType.DIGITAL: {number: name for name, number in self._pin_number_by_name[PinType.DIGITAL].items()}, + } + + self.conv_functions: Dict[Union[str, int], Callable] = { + (self._pin_number_by_name[PinType.ANALOG].get(pin, pin)): (f if callable(f) else eval(f)) + for pin, f in (conv_functions or {}).items() + } + + self._boards: Dict[str, Arduino] = {} + self._board_iterators: Dict[str, util.Iterator] = {} + + @staticmethod + def _get_board_type(board_type: Optional[str] = None) -> Type: + if not board_type: + return Arduino + + board_type = board_type.lower() + if board_type == BoardType.DUE.value: + return ArduinoDue + if board_type == BoardType.NANO.value: + return ArduinoNano + if board_type == BoardType.MEGA.value: + return ArduinoMega + + raise AssertionError('Invalid board_type: {}'.format(board_type)) + + def _get_board(self, + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + timeout: Optional[float] = None): + board_name = board or self.board or Arduino.AUTODETECT + baud_rate = baud_rate or self.baud_rate + timeout = timeout or self.timeout + + if board_name in self._boards: + return self._boards[board_name] + + board_type = self._get_board_type(board_type) if board_type else self.board_type + board = board_type(board_name, baudrate=baud_rate, timeout=timeout) + self.logger.info('Connected to board {}'.format(board.name)) + self._boards[board_name] = board + + self._board_iterators[board.name] = util.Iterator(board) + self._board_iterators[board.name].start() + return board + + def _get_board_and_pin(self, + pin: Union[int, str], + pin_type: PinType, + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + timeout: Optional[int] = None) -> Tuple[Arduino, int]: + board = self._get_board(board, board_type=board_type, baud_rate=baud_rate, timeout=timeout) + if pin in self._pin_number_by_name[pin_type]: + pin = self._pin_number_by_name[pin_type][pin] + + assert isinstance(pin, int), 'Invalid PIN number/name: {}'.format(pin) + return board, pin + + @staticmethod + def _get_pin(pin: int, board: Arduino, pin_type: PinType) -> Pin: + pins = None + if pin_type == PinType.ANALOG: + pins = board.analog + if pin_type == PinType.DIGITAL: + pins = board.digital + + assert pins, 'Invalid pin_type: {}'.format(pin_type) + + if pins[pin].mode in [ANALOG, INPUT]: + pins[pin].enable_reporting() + return pins[pin] + + def _poll_value(self, + pin: int, + board: Arduino, + pin_type: PinType, + timeout: Optional[float] = None) -> Optional[Union[bool, float]]: + value = None + poll_start = time.time() + + while value is None: + if timeout and time.time() - poll_start >= timeout: + raise RuntimeError('Read timeout') + + pin_ = self._get_pin(pin=pin, board=board, pin_type=pin_type) + if pin_.mode not in [INPUT, ANALOG]: + self.logger.warning('PIN {} is not configured in input/analog mode'.format(pin)) + return + + value = pin_.read() + if value is None: + time.sleep(0.001) + + if pin_type == PinType.DIGITAL: + value = bool(value) + + return value + + @action + def analog_read(self, + pin: Union[int, str], + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + conv_function: Optional[Union[str, Callable]] = None, + timeout: Optional[int] = None) -> float: + """ + Read an analog value from a PIN. + + :param pin: PIN number or configured name. + :param board: Board path or name (default: default configured ``board``). + :param board_type: Board type. It can be 'mega', 'due' or 'nano' (default: configured ``board_type``). + :param baud_rate: Baud rate (default: default configured ``baud_rate``). + :param conv_function: Optional conversion function override to apply to the output. It can be either a function + object or its lambda string representation (e.g. ``lambda x: x*x``). Keep in mind that ``analog_read`` + returns by default float values in the range ``[0.0, 1.0]``. + :param timeout: Communication timeout in seconds (default: default configured ``timeout``). + """ + conv_function = conv_function or self.conv_functions.get(pin) + board, pin = self._get_board_and_pin(pin=pin, + pin_type=PinType.ANALOG, + board=board, + board_type=board_type, + baud_rate=baud_rate, + timeout=timeout) + + conv_function = conv_function or self.conv_functions.get(pin) + value = self._poll_value(pin=pin, board=board, pin_type=PinType.ANALOG, timeout=timeout) + + if conv_function: + value = conv_function(value) + return value + + @action + def digital_read(self, + pin: Union[int, str], + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + timeout: Optional[int] = None) -> bool: + """ + Read a digital value from a PIN. + + :param pin: PIN number or configured name. + :param board: Board path or name (default: default configured ``board``). + :param board_type: Board type. It can be 'mega', 'due' or 'nano' (default: configured ``board_type``). + :param baud_rate: Baud rate (default: default configured ``baud_rate``). + :param timeout: Communication timeout in seconds (default: default configured ``timeout``). + """ + board, pin = self._get_board_and_pin(pin=pin, + pin_type=PinType.DIGITAL, + board=board, + board_type=board_type, + baud_rate=baud_rate, + timeout=timeout) + + return self._poll_value(pin=pin, board=board, pin_type=PinType.DIGITAL, timeout=timeout) + + @action + def analog_write(self, pin: Union[int, str], + value: float, + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + timeout: Optional[int] = None): + """ + Write a value to an analog PIN. + + :param pin: PIN number or configured name. + :param value: Voltage to be sent, a real number normalized between 0 and 1. + :param board: Board path or name (default: default configured ``board``). + :param board_type: Board type. It can be 'mega', 'due' or 'nano' (default: configured ``board_type``). + :param baud_rate: Baud rate (default: default configured ``baud_rate``). + :param timeout: Communication timeout in seconds (default: default configured ``timeout``). + """ + board, pin = self._get_board_and_pin(pin=pin, + pin_type=PinType.ANALOG, + board=board, + board_type=board_type, + baud_rate=baud_rate, + timeout=timeout) + board.analog[pin].write(value) + + @action + def digital_write(self, pin: Union[int, str], + value: bool, + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + timeout: Optional[int] = None): + """ + Write a value to a digital PIN. + + :param pin: PIN number or configured name. + :param value: True (HIGH) or False (LOW). + :param board: Board path or name (default: default configured ``board``). + :param board_type: Board type. It can be 'mega', 'due' or 'nano' (default: configured ``board_type``). + :param baud_rate: Baud rate (default: default configured ``baud_rate``). + :param timeout: Communication timeout in seconds (default: default configured ``timeout``). + """ + board, pin = self._get_board_and_pin(pin=pin, + pin_type=PinType.DIGITAL, + board=board, + board_type=board_type, + baud_rate=baud_rate, + timeout=timeout) + board.digital[pin].write(value) + + @action + def pwm_write(self, pin: Union[int, str], + value: float, + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + timeout: Optional[int] = None): + """ + Write a PWM value to a digital PIN. + + :param pin: PIN number or configured name. + :param value: PWM real value normalized between 0 and 1. + :param board: Board path or name (default: default configured ``board``). + :param board_type: Board type. It can be 'mega', 'due' or 'nano' (default: configured ``board_type``). + :param baud_rate: Baud rate (default: default configured ``baud_rate``). + :param timeout: Communication timeout in seconds (default: default configured ``timeout``). + """ + board, pin = self._get_board_and_pin(pin=pin, + pin_type=PinType.DIGITAL, + board=board, + board_type=board_type, + baud_rate=baud_rate, + timeout=timeout) + + assert board.digital[pin].PWM_CAPABLE, 'PIN {} is not PWM capable'.format(pin) + if board.digital[pin] != PWM: + board.digital[pin].mode = PWM + time.sleep(0.001) + + board.digital[pin].write(value) + + @action + def get_measurement(self, + board: Optional[str] = None, + board_type: Optional[str] = None, + baud_rate: Optional[int] = None, + timeout: Optional[int] = None) -> Dict[str, float]: + """ + Get a measurement from all the configured PINs. + + :param board: Board path or name (default: default configured ``board``) + :param board_type: Board type. It can be 'mega', 'due' or 'nano' (default: configured ``board_type``). + :param baud_rate: Baud rate (default: default configured ``baud_rate``) + :param timeout: Communication timeout in seconds (default: default configured ``timeout``). + :return: dict, where the keys are either the configured names of the PINs (see ``analog_pins`` configuration) + or all the analog PINs (names will be in the format 'A0..A7' in that case), and the values will be the + real values measured, either normalized between 0 and 1 if no conversion functions were provided, or + transformed through the configured ``conv_functions``. + """ + ret = {} + board = self._get_board(board=board, board_type=board_type, baud_rate=baud_rate, timeout=timeout) + + for pin in board.analog: + if self._pin_name_by_number[PinType.ANALOG] and \ + pin.pin_number not in self._pin_name_by_number[PinType.ANALOG]: + continue + + name = self._pin_name_by_number[PinType.ANALOG].get(pin.pin_number, 'A{}'.format(pin.pin_number)) + value = self._poll_value(pin=pin.pin_number, board=board, pin_type=PinType.ANALOG, + timeout=timeout or self.timeout) + + if value is None: + continue + + conv_function = self.conv_functions.get(name, self.conv_functions.get(pin.pin_number)) + if conv_function: + value = conv_function(value) + + ret[name] = value + + return ret + + @action + def close(self): + for board in self._boards.values(): + board.exit() + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/switch/tplink.py b/platypush/plugins/switch/tplink.py index 0787ed487..57a482b74 100644 --- a/platypush/plugins/switch/tplink.py +++ b/platypush/plugins/switch/tplink.py @@ -14,6 +14,7 @@ class SwitchTplinkPlugin(SwitchPlugin): Requires: * **pyHS100** (``pip install pyHS100``) + """ _ip_to_dev = {} diff --git a/requirements.txt b/requirements.txt index c96a2c53d..b5d3631cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -206,3 +206,7 @@ croniter # Support for Telegram integration # python-telegram-bot + +# Support for Arduino integration +# pyserial +# pyfirmata2 diff --git a/setup.py b/setup.py index edc42dd13..277e7a540 100755 --- a/setup.py +++ b/setup.py @@ -267,5 +267,7 @@ setup( 'buienradar': ['buienradar'], # Support for Telegram integration 'telegram': ['python-telegram-bot'], + # Support for Arduino integration + 'arduino': ['pyserial', 'pyfirmata2'], }, )