diff --git a/.gitignore b/.gitignore index 7dc8d00..ffeb2a9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,14 +4,115 @@ scripts/ tests/ img/ build/ -media/ mudpi.config *.log # Python compiled __pycache__/ -*.egg-info +*.py[cod] +*$py.class -# Virtual Environments +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments .env .venv env/ @@ -20,6 +121,13 @@ ENV/ env.bak/ venv.bak/ +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + # IDE configs .idea/ -.DS_Store +.DS_Store \ No newline at end of file diff --git a/examples/custom_extension/grow/__init__.py b/examples/custom_extension/grow/__init__.py index 6706ab4..4dbe865 100644 --- a/examples/custom_extension/grow/__init__.py +++ b/examples/custom_extension/grow/__init__.py @@ -1,36 +1,37 @@ """ - Custom Extension Example - Provide a good description of what - your extension is adding to MudPi - or what it does. + Custom Extension Example + Provide a good description of what + your extension is adding to MudPi + or what it does. """ from mudpi.extensions import BaseExtension + # Your extension should extend the BaseExtension class class Extension(BaseExtension): # The minimum your extension needs is a namespace. This # should be the same as your folder name and unique for # all extensions. Interfaces all components use this namespace. - namespace = 'grow' + namespace = 'grow' + + # You can also set an update interval at which components + # should be updated to gather new data / state. + update_interval = 1 - # You can also set an update interval at which components - # should be updated to gather new data / state. - update_interval = 1 - - def init(self, config): - """ Prepare the extension and all components """ - # This is called on MudPi start and passed config on start. - # Here is where devices should be setup, connections made, - # components created and added etc. - - # Must return True or an error will be assumed disabling the extension - return True + def init(self, config): + """ Prepare the extension and all components """ + # This is called on MudPi start and passed config on start. + # Here is where devices should be setup, connections made, + # components created and added etc. + + # Must return True or an error will be assumed disabling the extension + return True def validate(self, config): """ Validate the extension configuration """ # Here the extension configuration is passed in before the init() method - # is called. The validate method is used to prepare a valid configuration + # is called. The validate method is used to prepare a valid configuration # for the extension before initialization. This method should return the # validated config or raise a ConfigError. - - return config \ No newline at end of file + + return config diff --git a/mudpi/config.py b/mudpi/config.py index 14b0d3f..93dfdae 100644 --- a/mudpi/config.py +++ b/mudpi/config.py @@ -1,8 +1,9 @@ import os import json import yaml -from mudpi.constants import (FONT_YELLOW, RED_BACK, FONT_RESET, IMPERIAL_SYSTEM, PATH_MUDPI, PATH_CONFIG, DEFAULT_CONFIG_FILE) -from mudpi.exceptions import ConfigNotFoundError, ConfigError +from mudpi.constants import (FONT_YELLOW, RED_BACK, FONT_RESET, IMPERIAL_SYSTEM, PATH_MUDPI, + PATH_CONFIG, DEFAULT_CONFIG_FILE) +from mudpi.exceptions import ConfigNotFoundError, ConfigError, ConfigFormatError class Config(object): @@ -11,14 +12,15 @@ class Config(object): A class to represent the MudPi configuration that is typically pulled from a file. """ + def __init__(self, config_path=None): self.config_path = config_path or os.path.abspath(os.path.join(os.getcwd(), PATH_CONFIG)) - + self.config = {} self.set_defaults() - """ Properties """ + @property def name(self): return self.config.get('mudpi', {}).get('name', 'MudPi') @@ -134,7 +136,8 @@ def load_from_json(self, json_data): self.config = json.loads(json_data) return self.config except Exception as e: - print(f'{RED_BACK}Problem loading configs from JSON {FONT_RESET}\n{FONT_YELLOW}{e}{FONT_RESET}\r') + print( + f'{RED_BACK}Problem loading configs from JSON {FONT_RESET}\n{FONT_YELLOW}{e}{FONT_RESET}\r') def load_from_yaml(self, yaml_data): """ Load configs from YAML """ @@ -142,7 +145,8 @@ def load_from_yaml(self, yaml_data): self.config = yaml.load(yaml_data, yaml.FullLoader) return self.config except Exception as e: - print(f'{RED_BACK}Problem loading configs from YAML {FONT_RESET}\n{FONT_YELLOW}{e}{FONT_RESET}\r') + print( + f'{RED_BACK}Problem loading configs from YAML {FONT_RESET}\n{FONT_YELLOW}{e}{FONT_RESET}\r') def save_to_file(self, file=None, format=None, config=None): """ Save current configs to a file @@ -170,17 +174,18 @@ def save_to_file(self, file=None, format=None, config=None): return True def validate_file(self, file, exists=True): - """ Validate a file path and return a prepared path to save + """ Validate a file path and return a prepared path to save Set exists to False to prevent file exists check """ - if '.' in file: + if '.' in file: if not self.file_exists(file) and exists: raise ConfigNotFoundError(f"The config path {file} does not exist.") - return False + extensions = ['.config', '.json', '.yaml', '.conf'] + if not any([file.endswith(extension) for extension in extensions]): - raise ConfigFormatError("An unknown config file format was provided in the config path.") - return False + raise ConfigFormatError( + "An unknown config file format was provided in the config path.") else: # Path provided but not file file = os.path.join(file, DEFAULT_CONFIG_FILE) @@ -188,15 +193,15 @@ def validate_file(self, file, exists=True): def config_format(self, file): """ Returns the file format if supported """ + + config_format = None if '.' in file: if any(extension in file for extension in ['.config', '.json', '.conf']): config_format = 'json' elif '.yaml' in file: config_format = 'yaml' - else: - config_format = None - - return config_format + + return config_format def get(self, key, default=None, replace_char=None): """ Get an item from the config with a default @@ -212,4 +217,4 @@ def get(self, key, default=None, replace_char=None): def __repr__(self): """ Debug print of config """ - return f'' \ No newline at end of file + return f'' diff --git a/mudpi/debug/dump.py b/mudpi/debug/dump.py index ba41653..1f13937 100644 --- a/mudpi/debug/dump.py +++ b/mudpi/debug/dump.py @@ -107,9 +107,9 @@ def dumpall(): for x in a.register.names: r = a.register.get(x) if r.size == 2: - v = '0x%04X' % (r.value) + v = '0x%04X' % r.value else: - v = ' 0x%02X' % (r.value) + v = ' 0x%02X' % r.value print('%-20s = %s @0x%2X (size:%s)' % (r.name, v, r.address, r.size)) diff --git a/mudpi/events/adaptors/__init__.py b/mudpi/events/adaptors/__init__.py index be3d783..cf015b3 100644 --- a/mudpi/events/adaptors/__init__.py +++ b/mudpi/events/adaptors/__init__.py @@ -1,54 +1,54 @@ class Adaptor: - """ Base adaptor for pubsub event system """ - - # This key should represent key in configs that it will load form - key = None - - adaptors = {} - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - cls.adaptors[cls.key] = cls - - def __init__(self, config={}): - self.config = config - - def connect(self): - """ Authenticate to system and cache connections """ - raise NotImplementedError() - - def disconnect(self): - """ Close active connections and cleanup subscribers """ - raise NotImplementedError() - - def subscribe(self, topic, callback): - """ Listen on a topic and pass event data to callback """ - raise NotImplementedError() - - def unsubscribe(self, topic): - """ Stop listening for events on a topic """ - raise NotImplementedError() - - def publish(self, topic, data=None): - """ Publish an event on the topic """ - raise NotImplementedError() - - """ No need to override this unless necessary """ - def subscribe_once(self, topic, callback): - """ Subscribe to topic for only one event """ - def handle_once(data): - """ Wrapper to unsubscribe after event handled """ - self.unsubscribe(topic) - if callable(callback): - # Pass data to real callback - callback(data) - - return self.subscribe(topic, handle_once) - - def get_message(self): - """ Some protocols need to initate a poll for new messages """ - pass - + """ Base adaptor for pubsub event system """ + + # This key should represent key in configs that it will load form + key = None + + adaptors = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.adaptors[cls.key] = cls + + def __init__(self, config={}): + self.config = config + + def connect(self): + """ Authenticate to system and cache connections """ + raise NotImplementedError() + + def disconnect(self): + """ Close active connections and cleanup subscribers """ + raise NotImplementedError() + + def subscribe(self, topic, callback): + """ Listen on a topic and pass event data to callback """ + raise NotImplementedError() + + def unsubscribe(self, topic): + """ Stop listening for events on a topic """ + raise NotImplementedError() + + def publish(self, topic, data=None): + """ Publish an event on the topic """ + raise NotImplementedError() + + """ No need to override this unless necessary """ + def subscribe_once(self, topic, callback): + """ Subscribe to topic for only one event """ + def handle_once(data): + """ Wrapper to unsubscribe after event handled """ + self.unsubscribe(topic) + if callable(callback): + # Pass data to real callback + callback(data) + + return self.subscribe(topic, handle_once) + + def get_message(self): + """ Some protocols need to initate a poll for new messages """ + pass + # Import adaptors from . import redis, mqtt diff --git a/mudpi/extensions/dht/sensor.py b/mudpi/extensions/dht/sensor.py index 0fcd8e9..ef1f988 100644 --- a/mudpi/extensions/dht/sensor.py +++ b/mudpi/extensions/dht/sensor.py @@ -3,10 +3,11 @@ Connects to a DHT device to get humidity and temperature readings. """ -import re import time -import board + import adafruit_dht +import board + from mudpi.constants import METRIC_SYSTEM from mudpi.extensions import BaseInterface from mudpi.extensions.sensor import Sensor @@ -32,21 +33,13 @@ def validate(self, config): if not conf.get('pin'): raise ConfigError('Missing `pin` in DHT config.') - if not re.match(r'D\d+$', str(conf['pin'])) and not re.match(r'A\d+$', str(conf['pin'])): - raise ConfigError( - "Cannot detect pin type (Digital or analog), " - "should be Dxx or Axx for digital or analog. " - "Please refer to " - "https://github.com/adafruit/Adafruit_Blinka/tree/master/src/adafruit_blinka/board" - ) - if str(conf.get('model')) not in DHTSensor.models: conf['model'] = '11' Logger.log( LOG_LEVEL["warning"], 'Sensor Model Error: Defaulting to DHT11' ) - + return config @@ -66,6 +59,7 @@ class DHTSensor(Sensor): } # AM2302 = DHT22 """ Properties """ + @property def id(self): """ Return a unique id for the component """ @@ -75,7 +69,7 @@ def id(self): def name(self): """ Return the display name of the component """ return self.config.get('name') or f"{self.id.replace('_', ' ').title()}" - + @property def state(self): """ Return the state of the component (from memory, no IO!) """ @@ -96,8 +90,8 @@ def read_attempts(self): """ Number of times to try sensor for good data """ return int(self.config.get('read_attempts', self._read_attempts)) - """ Methods """ + def init(self): """ Connect to the device """ self._sensor = None @@ -107,7 +101,7 @@ def init(self): self._dht_device = self.models[self.type] self.check_dht() - + return True def check_dht(self): @@ -138,7 +132,7 @@ def update(self): while _attempts < self.read_attempts: try: # Calling temperature or humidity triggers measure() - temperature_c = self._sensor.temperature + temperature_c = self._sensor.temperature humidity = self._sensor.humidity except RuntimeError as error: # Errors happen fairly often, DHT's are hard to read diff --git a/mudpi/extensions/gpio/sensor.py b/mudpi/extensions/gpio/sensor.py index 80380b4..50a9ab3 100644 --- a/mudpi/extensions/gpio/sensor.py +++ b/mudpi/extensions/gpio/sensor.py @@ -4,6 +4,7 @@ take analog or digital readings. """ import re + import board import digitalio from mudpi.extensions import BaseInterface diff --git a/mudpi/importer.py b/mudpi/importer.py index 21a5962..78141c9 100644 --- a/mudpi/importer.py +++ b/mudpi/importer.py @@ -78,9 +78,9 @@ def get_extension_importer(mudpi, extension, install_requirements=False): 'Ready', 'success' ) else: - Logger.log_formatted( - "error", f'Import Preperations for {extension.title()}', - 'Failed', 'error' + Logger.log_formatted(LOG_LEVEL["debug"], + f'Import Preperations for {extension.title()}', + 'error', 'error' ) Logger.log( LOG_LEVEL["debug"], diff --git a/mudpi/logger/Logger.py b/mudpi/logger/Logger.py index d50ab9b..eb070c9 100644 --- a/mudpi/logger/Logger.py +++ b/mudpi/logger/Logger.py @@ -15,7 +15,7 @@ class Logger: - logger = None + logger = logging.getLogger("mudpi_stream") def __init__(self, config: dict): if "logging" in config.keys(): diff --git a/mudpi/managers/core_manager.py b/mudpi/managers/core_manager.py index c9bf016..29e3c4a 100644 --- a/mudpi/managers/core_manager.py +++ b/mudpi/managers/core_manager.py @@ -188,8 +188,7 @@ def validate_config(self, config_path): """ Validate that config path was provided and a file """ if not os.path.exists(config_path): raise ConfigNotFoundError(f"Config File Doesn't Exist at {config_path}") - return False - else: + else: # No config file provided just a path pass diff --git a/mudpi/managers/extension_manager.py b/mudpi/managers/extension_manager.py index 0558b21..0b3357d 100644 --- a/mudpi/managers/extension_manager.py +++ b/mudpi/managers/extension_manager.py @@ -124,7 +124,6 @@ def find_or_create_interface(self, interface_name, interface_config = {}): if self.config is None: raise MudPiError("Config was null in extension manager. Call `init(config)` first.") - return # Get the interface and extension interface, extension = self.importer.prepare_interface_and_import(interface_name) diff --git a/mudpi/sensors/arduino/soil_sensor.py b/mudpi/sensors/arduino/soil_sensor.py new file mode 100644 index 0000000..30e8f6d --- /dev/null +++ b/mudpi/sensors/arduino/soil_sensor.py @@ -0,0 +1,57 @@ +import time +import datetime +import json +import redis +from .sensor import Sensor +from nanpy import (ArduinoApi, SerialManager) +import sys + + + +import constants + +default_connection = SerialManager(device='/dev/ttyUSB0') +# r = redis.Redis(host='127.0.0.1', port=6379) + +# Wet Water = 287 +# Dry Air = 584 +AirBounds = 590 +WaterBounds = 280 +intervals = int((AirBounds - WaterBounds) / 3) + + +class SoilSensor(Sensor): + + def __init__(self, pin, name=None, key=None, connection=default_connection, + redis_conn=None): + super().__init__(pin, name=name, key=key, connection=connection, + redis_conn=redis_conn) + return + + def init_sensor(self): + # read data using pin specified pin + self.api.pinMode(self.pin, self.api.INPUT) + + def read(self): + resistance = self.api.analogRead(self.pin) + moistpercent = ((resistance - WaterBounds) / ( + AirBounds - WaterBounds)) * 100 + if (moistpercent > 80): + moisture = 'Very Dry - ' + str(int(moistpercent)) + elif (moistpercent <= 80 and moistpercent > 45): + moisture = 'Dry - ' + str(int(moistpercent)) + elif (moistpercent <= 45 and moistpercent > 25): + moisture = 'Wet - ' + str(int(moistpercent)) + else: + moisture = 'Very Wet - ' + str(int(moistpercent)) + # print("Resistance: %d" % resistance) + # TODO: Put redis store into sensor worker + self.r.set(self.key, + resistance) # TODO: CHANGE BACK TO 'moistpercent' (PERSONAL CONFIG) + return resistance + + def read_raw(self): + resistance = self.api.analogRead(self.pin) + # print("Resistance: %d" % resistance) + self.r.set(self.key + '_raw', resistance) + return resistance diff --git a/mudpi/server/mudpi_server.py b/mudpi/server/mudpi_server.py new file mode 100644 index 0000000..d52e8c5 --- /dev/null +++ b/mudpi/server/mudpi_server.py @@ -0,0 +1,116 @@ + +import sys +import json +import time +import redis +import socket +import threading + +from logger.Logger import Logger, LOG_LEVEL + + +class MudpiServer(object): + """ + A socket server used to allow incoming wiresless connections. + MudPi will listen on the socket server for clients to join and + send a message that should be broadcast on the event system. + """ + + def __init__(self, config, system_running): + self.port = int(config.get("port", 7007)) + self.host = config.get("host", "127.0.0.1") + self.system_running = system_running + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.client_threads = [] + + + try: + self.sock.bind((self.host, self.port)) + except socket.error as msg: + Logger.log(LOG_LEVEL['error'], 'Failed to create socket. Error Code: ', str(msg[0]), ' , Error Message: ', msg[1]) + sys.exit() + + # PubSub + try: + self.r = config["redis"] + except KeyError: + self.r = redis.Redis(host='127.0.0.1', port=6379) + + def listen(self): + self.sock.listen(0) # number of clients to listen for. + Logger.log(LOG_LEVEL['info'], 'MudPi Server...\t\t\t\t\033[1;32m Online\033[0;0m ') + while self.system_running.is_set(): + try: + client, address = self.sock.accept() + client.settimeout(600) + ip, port = client.getpeername() + Logger.log(LOG_LEVEL['info'], 'Socket \033[1;32mClient {0}\033[0;0m from \033[1;32m{1} Connected\033[0;0m'.format(port, ip)) + t = threading.Thread(target = self.listenToClient, args = (client, address, ip)) + self.client_threads.append(t) + t.start() + except Exception as e: + Logger.log(LOG_LEVEL['error'], e) + time.sleep(1) + pass + self.sock.close() + if len(self.client_threads > 0): + for client in self.client_threads: + client.join() + Logger.log(LOG_LEVEL['info'], 'Server Shutdown...\t\t\t\033[1;32m Complete\033[0;0m') + + def listenToClient(self, client, address, ip): + size = 1024 + while self.system_running.is_set(): + try: + data = client.recv(size) + if data: + data = self.decodeMessageData(data) + if data.get("topic", None) is not None: + self.r.publish(data["topic"], json.dumps(data)) + Logger.log(LOG_LEVEL['info'], "Socket Event \033[1;36m{event}\033[0;0m from \033[1;36m{source}\033[0;0m Dispatched".format(**data)) + + # response = { + # "status": "OK", + # "code": 200 + # } + # client.send(json.dumps(response).encode('utf-8')) + else: + Logger.log(LOG_LEVEL['error'], "Socket Data Recieved. \033[1;31mDispatch Failed:\033[0;0m Missing Data 'Topic'") + Logger.log(LOG_LEVEL['debug'], data) + else: + pass + # raise error('Client Disconnected') + except Exception as e: + Logger.log(LOG_LEVEL['info'], "Socket Client \033[1;31m{0} Disconnected\033[0;0m".format(ip)) + client.close() + return False + Logger.log(LOG_LEVEL['info'], 'Closing Client Connection...\t\t\033[1;32m Complete\033[0;0m') + + def decodeMessageData(self, message): + if isinstance(message, dict): + return message # print('Dict Found') + elif isinstance(message.decode('utf-8'), str): + try: + temp = json.loads(message.decode('utf-8')) + return temp # print('Json Found') + except: + return {'event':'Unknown', 'data':message.decode('utf-8')} # print('Json Error. Str Found') + else: + return {'event':'Unknown', 'data':message} # print('Failed to detect type') + +if __name__ == "__main__": + config = { + "host": '', + "port": 7007 + } + system_ready = threading.Event() + system_ready.set() + server = MudpiServer(config, system_ready) + server.listen() + try: + while system_ready.is_set(): + time.sleep(1) + except KeyboardInterrupt: + system_ready.clear() + finally: + system_ready.clear()