diff --git a/.gitignore b/.gitignore index f6ca97595231f2d23b61c18cb37c837890a4b585..3de227f432a2f46f84857758127bc31704c58e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ ENV/ # Rope project settings .ropeproject + +#Kate +*.kate-swp diff --git a/README.md b/README.md index 765140b5a928a12ca86532382a3c343374fccbce..380e60e8c573b08a0c96b2a4edf87abc7e12de39 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,20 @@ PI MQTT GPIO ============ -Expose the Raspberry Pi GPIO pins and/or external IO modules to an MQTT server. This allows pins to be read and switched by reading or writing messages to MQTT topics. +Expose the Raspberry Pi GPIO pins, external IO modules and I2C sensors to an MQTT server. This allows pins to be read and switched by reading or writing messages to MQTT topics. The I2C sensors will be read periodically and publish their values. -Modules -------- +GPIO Modules +------------ - Raspberry Pi GPIO (`raspberrypi`) - PCF8574 IO chip (`pcf8574`) - PiFaceDigital 2 IO board (`piface2`) +I2C Sensors +----------- + +- LM75 i2c temperature sensor (`lm75`) + Installation ------------ @@ -82,6 +87,32 @@ digital_inputs: pulldown: no ``` +### Sensors + +Receive updates on the value of a sensor by subscribing to the `home/sensor/temperature` topic: + +```yaml +mqtt: + host: test.mosquitto.org + port: 1883 + user: "" + password: "" + topic_prefix: home + +sensor_modules: + - name: lm75 + module: lm75 + i2c_bus_num: 1 + chip_addr: 0x48 + cleanup: no # This optional boolean value sets whether the module's `cleanup()` function will be called when the software exits. + +sensor_inputs: + - name: temperature + module: lm75 + interval: 15 #interval in seconds, that a value is read from the sensor and a update is published + digits: 4 # number of digits to be round +``` + #### SSL/TLS You may want to connect to a remote server, in which case it's a good idea to use an encrypted connection. If the server supports this, then you can supply the relevant config values for the [tls_set()](https://github.com/eclipse/paho.mqtt.python#tls_set) command. diff --git a/config.example.yml b/config.example.yml index b3cd54c824881359d24b563cc53049506beed2af..7874e621d2a52bf7d4c804e27a961190f7047ff9 100644 --- a/config.example.yml +++ b/config.example.yml @@ -18,6 +18,12 @@ gpio_modules: module: stdio cleanup: no +sensor_modules: + - name: lm75 + module: lm75 + i2c_bus_num: 1 + chip_addr: 0x48 + digital_inputs: - name: button module: raspberrypi @@ -30,7 +36,7 @@ digital_inputs: digital_outputs: - name: bell module: pcf8574 - pin: 20 + pin: 5 on_payload: "ON" off_payload: "OFF" initial: low @@ -40,3 +46,10 @@ digital_outputs: pin: 1 on_payload: "ON" off_payload: "OFF" + +sensor_inputs: + - name: temperature + module: lm75 + interval: 15 + digits: 4 + diff --git a/config.schema.yml b/config.schema.yml index 9f5ff233b2571f7da29eefe21db69bc25bd00497..f32768cec137a9f5ec1de8465beb6739063f094c 100644 --- a/config.schema.yml +++ b/config.schema.yml @@ -92,7 +92,29 @@ mqtt: gpio_modules: type: list - required: yes + required: no + default: [] + schema: + type: dict + allow_unknown: yes + schema: + name: + type: string + required: yes + empty: no + module: + type: string + required: yes + empty: no + cleanup: + type: boolean + required: no + default: yes + +sensor_modules: + type: list + required: no + default: [] schema: type: dict allow_unknown: yes @@ -189,3 +211,33 @@ digital_outputs: type: boolean required: no default: no + +sensor_inputs: + type: list + required: no + default: [] + schema: + type: dict + schema: + name: + type: string + required: yes + empty: no + module: + type: string + required: yes + empty: no + retain: + type: boolean + required: no + default: no + interval: + type: integer + required: no + default: 60 + min: 1 + digits: + type: integer + required: no + default: 2 + min: 0 diff --git a/pi_mqtt_gpio/__init__.py b/pi_mqtt_gpio/__init__.py index 9d26b90f776d1554630dcf76c7b8070884de1f03..f246cba753ea15c522e373de08883b4734852c06 100644 --- a/pi_mqtt_gpio/__init__.py +++ b/pi_mqtt_gpio/__init__.py @@ -95,7 +95,29 @@ mqtt: gpio_modules: type: list - required: yes + required: no + default: [] + schema: + type: dict + allow_unknown: yes + schema: + name: + type: string + required: yes + empty: no + module: + type: string + required: yes + empty: no + cleanup: + type: boolean + required: no + default: yes + +sensor_modules: + type: list + required: no + default: [] schema: type: dict allow_unknown: yes @@ -193,4 +215,34 @@ digital_outputs: required: no default: no +sensor_inputs: + type: list + required: no + default: [] + schema: + type: dict + schema: + name: + type: string + required: yes + empty: no + module: + type: string + required: yes + empty: no + retain: + type: boolean + required: no + default: no + interval: + type: integer + required: no + default: 60 + min: 1 + digits: + type: integer + required: no + default: 2 + min: 0 + """) diff --git a/pi_mqtt_gpio/modules/__init__.py b/pi_mqtt_gpio/modules/__init__.py index 7b7002c3103ef396fb5e7308c89bc80009077b3a..c835bc874ca5d16a54333a6575ece749ce45bcf7 100644 --- a/pi_mqtt_gpio/modules/__init__.py +++ b/pi_mqtt_gpio/modules/__init__.py @@ -55,3 +55,25 @@ class GenericGPIO(object): Called when closing the program to handle any cleanup operations. """ pass + + +class GenericSensor(object): + """ + Abstracts a generic sensor interface to be implemented + by the modules in this directory. + """ + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def setup_sensor(self, config): + pass + + @abc.abstractmethod + def get_value(self, sensor): + pass + + def cleanup(self): + """ + Called when closing the program to handle any cleanup operations. + """ + pass diff --git a/pi_mqtt_gpio/modules/lm75.py b/pi_mqtt_gpio/modules/lm75.py new file mode 100644 index 0000000000000000000000000000000000000000..573f54506cbae4c7c49d26f6e2a07c3ec067bb54 --- /dev/null +++ b/pi_mqtt_gpio/modules/lm75.py @@ -0,0 +1,41 @@ +from pi_mqtt_gpio.modules import GenericSensor + + +"""REQUIREMENTS = ("smbus",)""" +CONFIG_SCHEMA = { + "i2c_bus_num": { + "type": "integer", + "required": True, + "empty": False + }, + "chip_addr": { + "type": "integer", + "required": True, + "empty": False + } +} + +LM75_TEMP_REGISTER = 0 + + +class Sensor(GenericSensor): + """ + Implementation of Sensor class for the LM75 temperature sensor. + """ + def __init__(self, config): + import smbus + self.bus = smbus.SMBus(config["i2c_bus_num"]) + self.address = config["chip_addr"] + + def setup_sensor(self, config): + return True # nothing to do here + + def get_value(self, sensor): + """get the temperature value from the sensor""" + value = self.bus.read_word_data(self.address, + LM75_TEMP_REGISTER) & 0xFFFF + value = ((value << 8) & 0xFF00) + (value >> 8) + return self.convert_to_celsius(value) + + def convert_to_celsius(self, value): + return (value / 32.0) / 8.0 diff --git a/pi_mqtt_gpio/server.py b/pi_mqtt_gpio/server.py index bf8fa4df230c424824a17b30be3f1e56bcecb904..24ed5c96a1a0ca5921fba67a61d48ed1f043bf44 100644 --- a/pi_mqtt_gpio/server.py +++ b/pi_mqtt_gpio/server.py @@ -8,6 +8,10 @@ from time import sleep, time from importlib import import_module from hashlib import sha1 +import threading # For callback functions +from fractions import gcd # for calculating the callback periodic time +from functools import reduce + import paho.mqtt.client as mqtt import cerberus @@ -23,15 +27,19 @@ LOG_LEVEL_MAP = { mqtt.MQTT_LOG_ERR: logging.ERROR, mqtt.MQTT_LOG_DEBUG: logging.DEBUG } + RECONNECT_DELAY_SECS = 5 -GPIO_MODULES = {} -GPIO_CONFIGS = {} +GPIO_MODULES = {} # storage for gpio modules +SENSOR_MODULES = {} # storage for sensor modules +GPIO_CONFIGS = {} # storage for gpios +SENSOR_CONFIGS = {} # storage for sensors LAST_STATES = {} SET_TOPIC = "set" SET_ON_MS_TOPIC = "set_on_ms" SET_OFF_MS_TOPIC = "set_off_ms" OUTPUT_TOPIC = "output" INPUT_TOPIC = "input" +SENSOR_TOPIC = "sensor" _LOG = logging.getLogger(__name__) _LOG.addHandler(logging.StreamHandler()) @@ -414,6 +422,29 @@ def configure_gpio_module(gpio_config): return gpio_module.GPIO(gpio_config) +def configure_sensor_module(sensor_config): + """ + Imports sensor module, validates its config and returns an instance of it. + :param sensor_config: Module configuration values + :type sensor_config: dict + :return: Configured instance of the sensor module + :rtype: pi_mqtt_gpio.modules.GenericSensor + """ + sensor_module = import_module( + "pi_mqtt_gpio.modules.%s" % sensor_config["module"]) + # Doesn't need to be a deep copy because we won't modify the base + # validation rules, just add more of them. + module_config_schema = BASE_SCHEMA.copy() + module_config_schema.update( + getattr(sensor_module, "CONFIG_SCHEMA", {})) + module_validator = cerberus.Validator(module_config_schema) + if not module_validator.validate(sensor_config): + raise ModuleConfigInvalid(module_validator.errors) + sensor_config = module_validator.normalized(sensor_config) + install_missing_requirements(sensor_module) + return sensor_module.Sensor(sensor_config) + + def initialise_digital_input(in_conf, gpio): """ Initialises digital input. @@ -446,6 +477,85 @@ def initialise_digital_output(out_conf, gpio): gpio.setup_pin(out_conf["pin"], PinDirection.OUTPUT, None, out_conf) +def initialise_sensor_input(sens_conf, sensor): + """ + Initialises sensor input. + :param in_conf: Sensor config + :type in_conf: dict + :param sensor: Instance of GenericSensor to use + :type sensor: pi_mqtt_gpio.modules.GenericSensor + :return: None + :rtype: NoneType + """ + sensor.setup_sensor(sens_conf) + + +def sensor_timer_thread(SENSOR_MODULES, sensor_inputs, topic_prefix): + """ + Timer thread for the sensors + To reduce cpu usage, there is only one cyclic thread for all sensors. + At the beginning the cycle time is calculated (ggT) to match all intervals. + For each sensor interval, the reduction value is calculated, that triggers + the read, round and publish for the sensor, when loop_count is a multiple + of it. In worst case, the cycle_time is 1 second, in best case, e.g., when + there is only one sensor, cycle_time is its interval. + """ + + # calculate the min time + arr = [] + for sens_conf in sensor_inputs: + arr.append(sens_conf.get("interval", 60)) + + # get the greatest common divisor (gcd) for the list of interval times + cycle_time = reduce(lambda x, y: gcd(x, y), arr) + + _LOG.debug( + "sensor_timer_thread: calculated cycle_time will be %d seconds", + cycle_time) + + for sens_conf in sensor_inputs: + sens_conf["interval_reduction"] = sens_conf.get("interval", + 60) / cycle_time + + # Start the cyclic thread + loop_count = 0 + next_call = time() + while True: + loop_count += 1 + for sens_conf in sensor_inputs: + if loop_count % sens_conf["interval_reduction"] == 0: + sensor = SENSOR_MODULES[sens_conf["module"]] + + try: + value = round(sensor.get_value(sensor), + sens_conf["digits"]) + + _LOG.info( + "sensor_timer_thread: reading sensor '%s' value %r", + sens_conf["name"], + value + ) + + # publish each value + client.publish( + "%s/%s/%s" % ( + topic_prefix, SENSOR_TOPIC, sens_conf["name"] + ), + payload=value, + retain=sens_conf["retain"] + ) + except ModuleConfigInvalid as exc: + _LOG.error( + "sensor_timer_thread: failed to read sensor '%s': %s", + sens_conf["name"], + exc + ) + + # schedule next call + next_call = next_call+cycle_time # every cycle_time sec + sleep(next_call - time()) + + if __name__ == "__main__": p = argparse.ArgumentParser() p.add_argument("config") @@ -463,9 +573,11 @@ if __name__ == "__main__": digital_inputs = config["digital_inputs"] digital_outputs = config["digital_outputs"] + sensor_inputs = config["sensor_inputs"] client = init_mqtt(config["mqtt"], config["digital_outputs"]) + # Install modules for GPIOs for gpio_config in config["gpio_modules"]: GPIO_CONFIGS[gpio_config["name"]] = gpio_config try: @@ -478,6 +590,20 @@ if __name__ == "__main__": gpio_config["name"], yaml.dump(exc.errors) ) + + # Install modules for Sensors + for sensor_config in config["sensor_modules"]: + SENSOR_CONFIGS[sensor_config["name"]] = sensor_config + try: + SENSOR_MODULES[sensor_config["name"]] = configure_sensor_module( + sensor_config) + except ModuleConfigInvalid as exc: + _LOG.error( + "Config for %r module named %r did not validate:\n%s", + sensor_config["module"], + sensor_config["name"], + yaml.dump(exc.errors) + ) sys.exit(1) for in_conf in digital_inputs: @@ -487,6 +613,9 @@ if __name__ == "__main__": for out_conf in digital_outputs: initialise_digital_output(out_conf, GPIO_MODULES[out_conf["module"]]) + for sens_conf in sensor_inputs: + initialise_sensor_input(sens_conf, SENSOR_MODULES[sens_conf["module"]]) + try: client.connect(config["mqtt"]["host"], config["mqtt"]["port"], 60) except socket.error as err: @@ -498,6 +627,20 @@ if __name__ == "__main__": topic_prefix = config["mqtt"]["topic_prefix"] try: + # Starting the sensor thread (if there are sensors configured) + if sensor_inputs: + sensor_thread = threading.Thread(target=sensor_timer_thread, + kwargs={'SENSOR_MODULES': + SENSOR_MODULES, + 'sensor_inputs': + sensor_inputs, + 'topic_prefix': + topic_prefix}) + sensor_thread.name = 'pi-mqtt-gpio_SensorReader' + # stops the thread, when main program terminates + sensor_thread.daemon = True + sensor_thread.start() + while True: for in_conf in digital_inputs: gpio = GPIO_MODULES[in_conf["module"]] @@ -524,6 +667,7 @@ if __name__ == "__main__": except KeyboardInterrupt: print("") finally: + client.publish( "%s/%s" % (topic_prefix, config["mqtt"]["status_topic"]), config["mqtt"]["status_payload_stopped"], qos=1, retain=True)