Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Develop model for single-file input modules #525

Closed
kizniche opened this issue Sep 6, 2018 · 16 comments
Closed

Develop model for single-file input modules #525

kizniche opened this issue Sep 6, 2018 · 16 comments

Comments

@kizniche
Copy link
Owner

kizniche commented Sep 6, 2018

Allow a new input to be added to Mycodo with a single file.

This issue is to track the progress of this potential feature. A new branch will be created for this, single_file_input_modules.

Example commit of the minimal amount of internal code editing required to add a new input module (with new measurements/units that don't already exist): 133a2dc

@kizniche
Copy link
Owner Author

kizniche commented Sep 6, 2018

One issue I foresee is how to properly determine the options to display for each Input on the Data page. I may have to list all available variables (e.g. 'resolution', 'sensitivity', etc.) in INPUT_INFORMATION with an empty list ([]) being an option that isn't used, and if the list is populated with only one value, that is the default value available that is editable (test field), and if the list is populated with multiple values, this will present as a drop-down with the first option of the list selected.

@Theoi-Meteoroi
Copy link
Contributor

I had been wondering about that. This dust sensor has two modes. We are using the default power-up mode right now. I'm not sure how to present a second mode that would only run the sample fan just before and during a sample in the Q&A mode without it being a separate sensor variant. The logic to manage the device changes dramatically.

@kizniche
Copy link
Owner Author

kizniche commented Sep 6, 2018

My current progress includes adding the following dictionary to 2 test modules (test_humidity_01.py and test_temperature_01.py):

INPUT_INFORMATION = {
# Input information
'common_name_input': 'Humidity Sensor 01',
'unique_name_input': 'SEN_HUM_01',
# Measurement information
'common_name_measurements': 'Humidity',
'unique_name_measurements': {
'humidity': 'percent'
},
# Python module dependencies
# This must be a module that is able to be installed with pip via pypi.org
# Leave the list empty if there are no pip or github dependencies
'dependencies_pypi': [],
'dependencies_github': [],
#
# The below options are available from the input_dev variable
# See the example, below: "self.resolution = input_dev.resolution"
#
# Interface options: 'I2C' or 'UART'
'interface': 'I2C',
# I2C options
'location': ['0x01', '0x02'], # I2C address(s). Must be list. Enter more than one if multiple addresses exist.
# UART options
'serial_default_baud_rate': 9600,
'pin_cs': None,
'pin_miso': None,
'pin_mosi': None,
'pin_clock': None,
# Miscellaneous options available
'resolution': [],
'resolution_2': [],
'sensitivity': [],
'thermocouple_type': [],
}

Then, I created a parser (parse_inputs.py) that iterates through the modules in the directory and compiles a dictionary of all inputs and their information. The output from running the parser is this:

pi@tango:~/Mycodo/mycodo/inputs/custom_inputs $ ~/Mycodo/env/bin/python ./parse_inputs.py
2018-09-06 17:27:15,135 INFO: Starting parser
2018-09-06 17:27:15,136 INFO:
2018-09-06 17:27:15,136 INFO: Found input module: /home/pi/Mycodo/mycodo/inputs/custom_inputs/test_temperature_01.py
2018-09-06 17:27:17,436 INFO: Module name: SEN_TEMP_01
2018-09-06 17:27:17,436 INFO:
2018-09-06 17:27:17,437 INFO: Found input module: /home/pi/Mycodo/mycodo/inputs/custom_inputs/test_humidity_01.py
2018-09-06 17:27:17,438 INFO: Module name: SEN_HUM_01
2018-09-06 17:27:17,439 INFO:
2018-09-06 17:27:17,439 INFO: Run time: 2.303 seconds
2018-09-06 17:27:17,440 INFO:
2018-09-06 17:27:17,445 INFO: Parsed Input Information:
{'SEN_HUM_01': {'common_name_input': 'Humidity Sensor 01',
                'common_name_measurements': 'Humidity',
                'dependencies_github': [],
                'dependencies_pypi': [],
                'interface': 'I2C',
                'location': ['0x01', '0x02'],
                'pin_clock': None,
                'pin_cs': None,
                'pin_miso': None,
                'pin_mosi': None,
                'resolution': [],
                'resolution_2': [],
                'sensitivity': [],
                'serial_default_baud_rate': 9600,
                'thermocouple_type': [],
                'unique_name_measurements': {'humidity': 'percent'}},
 'SEN_TEMP_01': {'common_name_input': 'Temperature Sensor 01',
                 'common_name_measurements': 'Temperature',
                 'dependencies_github': [],
                 'dependencies_pypi': [],
                 'interface': 'I2C',
                 'location': ['0x01', '0x02'],
                 'pin_clock': None,
                 'pin_cs': None,
                 'pin_miso': None,
                 'pin_mosi': None,
                 'resolution': [],
                 'resolution_2': [],
                 'sensitivity': [],
                 'serial_default_baud_rate': 9600,
                 'thermocouple_type': [],
                 'unique_name_measurements': {'temperature': 'C'}}}

Now I need to begin modifying the various parts of the system that rely on reading this information from various non-central sources: config_devices_units.py (1, 2), utils_input.py, controller_input.py, and Flask templates for input options.

@kizniche
Copy link
Owner Author

kizniche commented Sep 6, 2018

Since this parser takes quite a while to complete (2+ seconds), this should only need to be executed once and stored by the running daemon, then accessed from the daemon thereafter. There will need to be a system for adding and removing modules after the dictionary is created, or merely deleting and recreating the dictionary (pausing any potential dictionary reads while this process executes).

kizniche added a commit that referenced this issue Sep 6, 2018
@kizniche
Copy link
Owner Author

kizniche commented Sep 6, 2018

Some nice progress:

  • The daemon now scans input modules and compiles a dictionary of information for each upon starting and can serve this dictionary to daemon threads and the frontend.

mycodo single file daemon parse inputs 01

  • The add input selection now queries this dictionary that's served from the daemon to create the list of available inputs that can be added.

mycodo single file input add 01

That's all for today.

@kizniche
Copy link
Owner Author

kizniche commented Sep 6, 2018

Interestingly the time it takes to scan the input modules when the code is executed in the daemon is much faster than when it's executed in my test script.

@kizniche
Copy link
Owner Author

kizniche commented Sep 7, 2018

I realized needing to query the daemon from the frontend could be an issue if the daemon is not running. Back to make some fixes.

@kizniche
Copy link
Owner Author

kizniche commented Sep 7, 2018

I was able to successfully refactor the input addition code (actually adding a new input from the Data page), though for this to be complete, I need to now add the new input module dictionary to all of the current inputs and switch over to parsing the actual input directory (not just the dummy directory currently set up), for the input choices (found at various parts of Mycodo, such as the available measurements when adding a new Graph) to properly be compiled.

@kizniche
Copy link
Owner Author

kizniche commented Sep 8, 2018

So far I have working: sorted list of available inputs that can be added, generating input choices for inputs that have been added (for selecting measurements to display on Graphs, for instance), adding inputs (properly modify the SQL database).

Things are moving along well. It's nice to finally be at a point where I see the few built-in modules beside the test modules, all being detected and rendered using only information contained within each module's file.

screenshot_2018-09-07_22-22-17

kizniche added a commit that referenced this issue Sep 8, 2018
@kizniche
Copy link
Owner Author

kizniche commented Sep 8, 2018

Within the last commit I found it helpful to begin creating a central translation dictionary that contains the phrases used in tooltips, which will make finding and editing specific phrases much easier.

TOOLTIPS_INPUT = {
'period': lazy_gettext('The duration (seconds) between input reads'),
'pre_output_id': lazy_gettext('Turn the selected output on before taking every measurement'),
'pre_output_duration': lazy_gettext('If a Pre Output is selected, set the duration (seconds) to turn the Pre Output on for before every measurement is acquired.'),
'pre_output_during_measure': lazy_gettext('Check to turn the output off after (opposed to before) the measurement is complete'),
'i2c_location': lazy_gettext('The I2C address to access the device'),
# '': lazy_gettext(''),
}

kizniche added a commit that referenced this issue Sep 8, 2018
@kizniche
Copy link
Owner Author

kizniche commented Sep 8, 2018

I'm really liking how this is coming along. Thus far, it's working beautifully, and it's not a lot of extra information added to each input module. For instance, this is all that's needed to be added to the Atlas PT-1000 Temperature sensor module to make it completely independent and able to be loaded from the single file:

INPUT_INFORMATION = {
'unique_name_input': 'ATLAS_PT1000',
'input_manufacturer': 'Atlas',
'common_name_input': 'Atlas PT-1000',
'common_name_measurements': 'Temperature',
'unique_name_measurements': ['temperature'], # List of strings
'dependencies_pypi': [], # List of strings
'interfaces': ['I2C', 'UART'], # List of strings
'i2c_location': ['0x66'], # List of strings
'i2c_address_editable': True, # Boolean
'uart_location': '/dev/ttyAMA0',
'options_enabled': ['i2c_location', 'uart_location', 'period', 'convert_unit', 'pre_output'],
'options_disabled': ['interface']
}

kizniche added a commit that referenced this issue Sep 10, 2018
@kizniche
Copy link
Owner Author

kizniche commented Sep 11, 2018

I'm fairly sure I have nearly all refactoring complete. I fixed all pytests and got TravisCI to succeed, and I just added a new configuration page that allows importing and deleting of custom input modules. I still need to add a few more sanity checks for the incoming files, but so far it's working beautifully.

screenshot-192 168 0 9-2018 09 10-21-52-54

screenshot_2018-09-10_22-14-16

@kizniche
Copy link
Owner Author

kizniche commented Sep 11, 2018

I made a minimal example of basic input module, at https://github.com/kizniche/Mycodo/blob/single_file_input_modules/mycodo/inputs/examples/minimal_humidity_temperature.py

The links referenced at the beginning are to these other example files with a full list of the available options and descriptions:

https://github.com/kizniche/Mycodo/blob/single_file_input_modules/mycodo/inputs/examples/example_all_options_temperature.py

# coding=utf-8
import logging

from mycodo.inputs.base_input import AbstractInput
from mycodo.inputs.sensorutils import convert_units
from mycodo.inputs.sensorutils import calculate_dewpoint


# Input information
# See the inputs directory for examples of working modules.
# The following link provides the full list of options with descriptions:
# https://github.com/kizniche/Mycodo/blob/single_file_input_modules/mycodo/inputs/examples/example_all_options_temperature.py
INPUT_INFORMATION = {
    'unique_name_input': 'TEST_00',
    'input_manufacturer': 'Company X',
    'common_name_input': 'Input 00',
    'common_name_measurements': 'Humidity/Temperature',
    'unique_name_measurements': ['dewpoint', 'humidity', 'temperature'],  # List of strings
    'dependencies_pypi': ['random'],
    'interfaces': ['I2C'],
    'i2c_location': ['0x5c'],
    'i2c_address_editable': False,
    'options_enabled': ['period', 'convert_unit', 'pre_output'],
    'options_disabled': ['interface', 'i2c_location']
}


class InputModule(AbstractInput):
    """ Input support class """
    def __init__(self, input_dev, testing=False):
        super(InputModule, self).__init__()
        self.logger = logging.getLogger("mycodo.inputs.{name_lower}".format(
            name_lower=INPUT_INFORMATION['unique_name_input'].lower()))

        self._dewpoint = None
        self._humidity = None
        self._temperature = None

        if not testing:
            self.logger = logging.getLogger(
                "mycodo.inputs.{name_lower}_{id}".format(
                    name_lower=INPUT_INFORMATION['unique_name_input'].lower(),
                    id=input_dev.id))
            self.convert_to_unit = input_dev.convert_to_unit
            self.interface = input_dev.interface

            # Load dependent modules
            import random
            self.random = random

    def __repr__(self):
        """  Representation of object """
        return "<{cls}(dewpoint={dewpoint})(humidity={humidity})(temperature={temperature})>".format(
            cls=type(self).__name__,
            dewpoint="{0:.2f}".format(self._dewpoint),
            humidity="{0:.2f}".format(self._humidity),
            temperature="{0:.2f}".format(self._humidity))

    def __str__(self):
        """ Return measurement information """
        return "Dewpoint: {dewpoint}, Humidity: {humidity}, Temperature: {temperature}".format(
            dewpoint="{0:.2f}".format(self._dewpoint),
            humidity="{0:.2f}".format(self._humidity),
            temperature="{0:.2f}".format(self._temperature))

    def __iter__(self):
        """ iterates through readings """
        return self

    def next(self):
        """ Get next reading """
        if self.read():
            raise StopIteration
        return dict(dewpoint=float('{0:.2f}'.format(self._dewpoint)),
                    humidity=float('{0:.2f}'.format(self._humidity)),
                    temperature=float('{0:.2f}'.format(self._temperature)))

    @property
    def dewpoint(self):
        """ dewpoint """
        if self._dewpoint is None:
            self.read()
        return self._dewpoint
    
    @property
    def humidity(self):
        """ temperature """
        if self._humidity is None:
            self.read()
        return self._humidity

    @property
    def temperature(self):
        """ temperature """
        if self._temperature is None:
            self.read()
        return self._temperature

    def get_measurement(self):
        """ Measures temperature and humidity """
        # Resetting these values ensures old measurements aren't mistaken for new measurements
        self._dewpoint = None
        self._humidity = None
        self._temperature = None

        dewpoint = None
        humidity = None
        temperature = None

        # Actual input measurement code
        try:
            humidity = self.random.randint(0, 100)
            temperature = self.random.randint(0, 50)
            dewpoint = calculate_dewpoint(temperature, humidity)
        except Exception as msg:
            self.logger.error("Exception: {}".format(msg))

        # Unit conversions
        # A conversion may be specified for each measurement

        # Humidity is returned as %, but may be converted to another unit (e.g. decimal)
        humidity = convert_units(
            'humidity', '%', self.convert_to_unit,
            humidity)

        # Temperature is returned as C, but may be converted to another unit (e.g. K, F)
        temperature = convert_units(
            'temperature', 'C', self.convert_to_unit,
            temperature)

        # Dewpoint is returned as C, but may be converted to another unit (e.g. K, F)
        dewpoint = convert_units(
            'dewpoint', 'C', self.convert_to_unit,
            dewpoint)

        return humidity, temperature, dewpoint

    def read(self):
        """
        Takes a reading and updates the self._dewpoint, self._temperature, and
        self._humidity values
        :returns: None on success or 1 on error
        """
        try:
            # These measurements must be in the same order as the returned tuple from get_measurement()
            self._humidity, self._temperature, self._dewpoint = self.get_measurement()
            if self._temperature is not None:
                return  # success - no errors
        except Exception as e:
            self.logger.exception(
                "{cls} raised an exception when taking a reading: "
                "{err}".format(cls=type(self).__name__, err=e))
        return 1

@kizniche
Copy link
Owner Author

I wanted to be able to add options that could be created on the fly from any Input module, so that if I wanted to create a module with options that don't already exist in Mycodo (or add more options to a module), I could use the INPUT_INFORMATION dictionary to define them. This would allow new options to be created without having to modify the Mycodo system files, which truly makes a standalone Input Module.

I was able to do this, and as a proof of concept, I added a checkbox option to enable the fan of the ZH03B sensor to be turned off while it wasn't recording a measurement. The new INPUT_INFORMATION looks like this for this module:

# Input information
INPUT_INFORMATION = {
    'input_name_unique': 'WINSEN_ZH03B',
    'input_manufacturer': 'Winsen',
    'input_name': 'ZH03B',
    'measurements_name': 'Particulates',
    'measurements_list': ['particulate_matter_1_0', 'particulate_matter_2_5', 'particulate_matter_10_0'],
    'options_enabled': ['uart_location', 'uart_baud_rate', 'custom_options', 'period', 'convert_unit', 'pre_output'],
    'options_disabled': ['interface'],

    'dependencies_module': [
        ('pip-pypi', 'binascii', 'binascii')
    ],
    'interfaces': ['UART'],
    'uart_location': '/dev/ttyAMA0',
    'uart_baud_rate': 9600,

    'custom_options': [
        {
            'id': 'modulate_fan',
            'type': 'checkbox',
            'default_value': True,
            'name': lazy_gettext('Fan Off After Measure'),
            'phrase': lazy_gettext('Turn the fan on only during the measurement')
        },
        {
            'id': 'another_option',
            'type': 'textbox',
            'default_value': 'my_text_value',
            'name': lazy_gettext('Another Custom Option'),
            'phrase': lazy_gettext('Another custom option description (this is translatable)')
        }
    ]
}

And the result on the Data page looks like this:

mycodo custom options

I'll be improving the code to include the ability to create dropdown menus and potentially other form types, but I just wanted to document this new feature in the appropriate thread and to share my excitement for a powerful feature that was relatively easy to code.

@Theoi-Meteoroi
Copy link
Contributor

Woohoo! I keep finding new interesting intelligent sensors to integrate with Mycodo and this is pretty cool in my world view.

@kizniche
Copy link
Owner Author

kizniche commented Oct 10, 2018

While improving the code, I came across a neat way to allow constraints for each custom input. Simply set the dictionary value of 'constraints_pass' to the constraints_pass() function, then create whatever checks you desire in that function. Below is a basic example, but it allows for much more user control over the input module options.

def constraints_pass_fan_seconds(value):
    """
    Check if the user input is acceptable
    :param value: float
    :return: tuple: (bool, list of strings)
    """
    errors = []
    all_passed = True
    # Ensure value is positive
    if value <= 0:
        all_passed = False
        errors.append("Must be a positive value")
    return all_passed, errors


# Input information
INPUT_INFORMATION = {
    'input_name_unique': 'WINSEN_ZH03B',
    'input_manufacturer': 'Winsen',
    'input_name': 'ZH03B',
    'measurements_name': 'Particulates',
    'measurements_list': ['particulate_matter_1_0', 'particulate_matter_2_5', 'particulate_matter_10_0'],
    'options_enabled': ['uart_location', 'uart_baud_rate', 'custom_options', 'period', 'convert_unit', 'pre_output'],
    'options_disabled': ['interface'],

    'dependencies_module': [
        ('pip-pypi', 'binascii', 'binascii')
    ],
    'interfaces': ['UART'],
    'uart_location': '/dev/ttyAMA0',
    'uart_baud_rate': 9600,

    'custom_options': [
        {
            'id': 'fan_modulate',
            'type': 'bool',
            'default_value': True,
            'name': lazy_gettext('Fan Off After Measure'),
            'phrase': lazy_gettext('Turn the fan on only during the measurement')
        },
        {
            'id': 'fan_seconds',
            'type': 'float',
            'default_value': 5.0,
            'constraints_pass': constraints_pass_fan_seconds,
            'name': lazy_gettext('Fan On Duration'),
            'phrase': lazy_gettext('How long to turn the fan on (seconds) before acquiring measurements')
        },
    ]
}

kizniche added a commit that referenced this issue Oct 11, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants