From 9fce8b9e56af0c2f9c0f77bd1647553dda9e1ad9 Mon Sep 17 00:00:00 2001 From: Nils Schulte Date: Sun, 17 Jan 2021 22:41:51 +0100 Subject: [PATCH] update --- code/bmp280_node.py | 42 ++++++++ code/lib/rotary.py | 114 ++++++++++++++++++++ code/lib/rotary_irq_esp.py | 68 ++++++++++++ code/plant_box_control_panel.py | 179 ++++++++++++++++++++++++++++++++ code/plant_node.py | 131 +++++++++++++++++++++++ code/update_homie_node.py | 76 ++++++++++++++ 6 files changed, 610 insertions(+) create mode 100644 code/bmp280_node.py create mode 100644 code/lib/rotary.py create mode 100644 code/lib/rotary_irq_esp.py create mode 100644 code/plant_box_control_panel.py create mode 100644 code/plant_node.py create mode 100644 code/update_homie_node.py diff --git a/code/bmp280_node.py b/code/bmp280_node.py new file mode 100644 index 0000000..6541efb --- /dev/null +++ b/code/bmp280_node.py @@ -0,0 +1,42 @@ + +from homie.constants import FLOAT +from homie.property import HomieProperty +from update_homie_node import UpdateHomieNode +# from homie.device import await_ready_state + +# import uasyncio as asyncio +# from time import ticks_ms, ticks_add, ticks_diff + + +class BMP280Node(UpdateHomieNode): + + def __init__( + self, + id, + name, + bmp280, + interval=60*5): + super().__init__(id=id, name=name, type="sensor", interval=interval) + + # BMP280 + self.bmp280 = bmp280 + self.property_temerature = HomieProperty( + id="temperature", + name="Temperatur", + datatype=FLOAT, + unit="°C", + ) + self.add_property(self.property_temerature) + self.property_pressure = HomieProperty( + id="pressure", + name="Druck", + datatype=FLOAT, + unit="Pa", + ) + self.add_property(self.property_pressure) + + def update_data(self): + self.property_pressure.value = "{:1.0f}".format( + self.bmp280.pressure * 100) # hPa = 100 Pa + self.property_temerature.value = "{:1.2f}".format( + self.bmp280.temperature) diff --git a/code/lib/rotary.py b/code/lib/rotary.py new file mode 100644 index 0000000..3265ebc --- /dev/null +++ b/code/lib/rotary.py @@ -0,0 +1,114 @@ +# The MIT License (MIT) +# Copyright (c) 2020 Mike Teachman +# https://opensource.org/licenses/MIT + +# Platform-independent MicroPython code for the rotary encoder module + +# Documentation: +# https://github.com/MikeTeachman/micropython-rotary + +_DIR_CW = const(0x10) # Clockwise step +_DIR_CCW = const(0x20) # Counter-clockwise step + +# Rotary Encoder States +_R_START = const(0x0) +_R_CW_1 = const(0x1) +_R_CW_2 = const(0x2) +_R_CW_3 = const(0x3) +_R_CCW_1 = const(0x4) +_R_CCW_2 = const(0x5) +_R_CCW_3 = const(0x6) +_R_ILLEGAL = const(0x7) + +_transition_table = [ + + #|------------- NEXT STATE -------------| |CURRENT STATE| + # CLK/DT CLK/DT CLK/DT CLK/DT + # 00 01 10 11 + [_R_START, _R_CCW_1, _R_CW_1, _R_START], # _R_START + [_R_CW_2, _R_START, _R_CW_1, _R_START], # _R_CW_1 + [_R_CW_2, _R_CW_3, _R_CW_1, _R_START], # _R_CW_2 + [_R_CW_2, _R_CW_3, _R_START, _R_START | _DIR_CW], # _R_CW_3 + [_R_CCW_2, _R_CCW_1, _R_START, _R_START], # _R_CCW_1 + [_R_CCW_2, _R_CCW_1, _R_CCW_3, _R_START], # _R_CCW_2 + [_R_CCW_2, _R_START, _R_CCW_3, _R_START | _DIR_CCW], # _R_CCW_3 + [_R_START, _R_START, _R_START, _R_START]] # _R_ILLEGAL + +_STATE_MASK = const(0x07) +_DIR_MASK = const(0x30) + +def _wrap(value, incr, lower_bound, upper_bound): + range = upper_bound - lower_bound + 1 + value = value + incr + + if value < lower_bound: + value += range * ((lower_bound - value) // range + 1) + + return lower_bound + (value - lower_bound) % range + +def _bound(value, incr, lower_bound, upper_bound): + return min(upper_bound, max(lower_bound, value + incr)) + +class Rotary(object): + + RANGE_UNBOUNDED = const(1) + RANGE_WRAP = const(2) + RANGE_BOUNDED = const(3) + + def __init__(self, min_val, max_val, reverse, range_mode): + self._min_val = min_val + self._max_val = max_val + self._reverse = -1 if reverse else 1 + self._range_mode = range_mode + self._value = min_val + self._state = _R_START + + def set(self, value=None, min_val=None, max_val=None, reverse=None, range_mode=None): + # disable DT and CLK pin interrupts + self._hal_disable_irq() + + if value != None: + self._value = value + if min_val != None: + self._min_val = min_val + if max_val != None: + self._max_val = max_val + if reverse != None: + self._reverse = -1 if reverse else 1 + if range_mode != None: + self._range_mode = range_mode + self._state = _R_START + + # enable DT and CLK pin interrupts + self._hal_enable_irq() + + def value(self): + return self._value + + def reset(self): + self._value = 0 + + def close(self): + self._hal_close() + + def _process_rotary_pins(self, pin): + clk_dt_pins = (self._hal_get_clk_value() << 1) | self._hal_get_dt_value() + # Determine next state + self._state = _transition_table[self._state & _STATE_MASK][clk_dt_pins] + direction = self._state & _DIR_MASK + + incr = 0 + if direction == _DIR_CW: + incr = 1 + elif direction == _DIR_CCW: + incr = -1 + + incr *= self._reverse + + if self._range_mode == self.RANGE_WRAP: + self._value = _wrap(self._value, incr, self._min_val, self._max_val) + elif self._range_mode == self.RANGE_BOUNDED: + self._value = _bound(self._value, incr, self._min_val, self._max_val) + else: + self._value = self._value + incr + diff --git a/code/lib/rotary_irq_esp.py b/code/lib/rotary_irq_esp.py new file mode 100644 index 0000000..01e9ac1 --- /dev/null +++ b/code/lib/rotary_irq_esp.py @@ -0,0 +1,68 @@ +# The MIT License (MIT) +# Copyright (c) 2020 Mike Teachman +# https://opensource.org/licenses/MIT + +# Platform-specific MicroPython code for the rotary encoder module +# ESP8266/ESP32 implementation + +# Documentation: +# https://github.com/MikeTeachman/micropython-rotary + +from machine import Pin +from rotary import Rotary +from sys import platform + +_esp8266_deny_pins = [16] + +class RotaryIRQ(Rotary): + + def __init__(self, pin_num_clk, pin_num_dt, min_val=0, max_val=10, reverse=False, range_mode=Rotary.RANGE_UNBOUNDED, pull_up=False): + + if platform == 'esp8266': + if pin_num_clk in _esp8266_deny_pins: + raise ValueError('%s: Pin %d not allowed. Not Available for Interrupt: %s' % (platform, pin_num_clk,_esp8266_deny_pins)) + if pin_num_dt in _esp8266_deny_pins: + raise ValueError('%s: Pin %d not allowed. Not Available for Interrupt: %s' % (platform, pin_num_dt,_esp8266_deny_pins)) + + super().__init__(min_val, max_val, reverse, range_mode) + + if pull_up == True: + self._pin_clk = Pin(pin_num_clk, Pin.IN, Pin.PULL_UP) + self._pin_dt = Pin(pin_num_dt, Pin.IN, Pin.PULL_UP) + else: + self._pin_clk = Pin(pin_num_clk, Pin.IN) + self._pin_dt = Pin(pin_num_dt, Pin.IN) + + self._enable_clk_irq(self._process_rotary_pins) + self._enable_dt_irq(self._process_rotary_pins) + + def _enable_clk_irq(self, callback=None): + self._pin_clk.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=callback) + + def _enable_dt_irq(self, callback=None): + self._pin_dt.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=callback) + + def _disable_clk_irq(self): + self._pin_clk.irq(handler=None) + + def _disable_dt_irq(self): + self._pin_dt.irq(handler=None) + + def _hal_get_clk_value(self): + return self._pin_clk.value() + + def _hal_get_dt_value(self): + return self._pin_dt.value() + + def _hal_enable_irq(self): + self._enable_clk_irq(self._process_rotary_pins) + self._enable_dt_irq(self._process_rotary_pins) + + def _hal_disable_irq(self): + self._disable_clk_irq() + self._disable_dt_irq() + + def _hal_close(self): + self._hal_disable_irq() + + diff --git a/code/plant_box_control_panel.py b/code/plant_box_control_panel.py new file mode 100644 index 0000000..0fa5716 --- /dev/null +++ b/code/plant_box_control_panel.py @@ -0,0 +1,179 @@ +from sh1106 import SH1106_I2C +from rotary_irq_esp import RotaryIRQ + +import uasyncio as asyncio +from time import ticks_ms, ticks_add, ticks_diff + + +from homie.constants import BOOLEAN, TRUE, FALSE, FLOAT +from homie.property import HomieProperty +from homie.node import HomieNode +from homie.device import await_ready_state +from machine import Pin +from primitives.pushbutton import Pushbutton + + +class PlantBoxControlPanel(HomieNode): + + def __init__(self, id, name, waterlevel_sensor, bmp280, + i2c, pin_clk, pin_dt, pin_sw): + super().__init__(id=id, name=name, type="controller") + self.i2c = i2c + self.bmp280 = bmp280 + self.display = SH1106_I2C( + i2c=self.i2c, width=128, height=64) + self.display.rotate(True, False) + + self.rotary_encoder = RotaryIRQ( + pin_num_clk=int(str(pin_clk)[4:][:-1]), + pin_num_dt=int(str(pin_dt)[4:][:-1]), + min_val=0, + max_val=3, + reverse=False, + pull_up=True, + range_mode=RotaryIRQ.RANGE_WRAP) + + self.waterlevel_sensor = waterlevel_sensor + pin_sw.init(mode=Pin.IN, pull=Pin.PULL_UP) + self.button = Pushbutton(pin_sw, suppress=True, sense=1) + + self._screen_on = False + self.needs_redraw = False + self.update_interval = 0.3 + + self.property_button_pressed = HomieProperty( + id="button_pressed", + name="Knöpgen gedrückt", + settable=True, + default=FALSE, + on_message=self._on_button_pressed_msg, + datatype=BOOLEAN, + ) + self.add_property(self.property_button_pressed) + + self.property_screen_on = HomieProperty( + id="screen_on", + name="Bildschrim angeschaltet", + settable=True, + default=FALSE, + on_message=self.on_screen_on_msg, + datatype=BOOLEAN, + ) + self.add_property(self.property_screen_on) + + self.screen_timeout = 30 + self.screen_timeout_ticks = 0 + self.property_screen_timeout = HomieProperty( + id="screen_timeout", + name="Bildschrimtimeout", + settable=True, + default=str(self.screen_timeout), + on_message=self.on_screen_timeout_msg, + datatype=FLOAT, + ) + self.add_property(self.property_screen_timeout) + + self.on_button_pressed = None + self.on_button_released = None + asyncio.create_task(self._update_data_async()) + + def _on_button_pressed_msg(self, topic, payload, retained): + if {FALSE: False, TRUE: True}[payload] and not self.button.rawstate(): + self._on_button_pressed() + self._on_button_released() + + def _on_button_pressed(self): + self.property_button_pressed.value = TRUE + self.needs_redraw = True + if self.on_button_pressed: + self.on_button_pressed() + + def _on_button_released(self): + self.property_button_pressed.value = FALSE + self._set_screen_on(True) + self.needs_redraw = True + if self.on_button_released: + self.on_button_released() + + def on_screen_on_msg(self, topic, payload, retained): + self._set_screen_on({FALSE: False, TRUE: True}[payload]) + + def on_screen_timeout_msg(self, topic, payload, retained): + new_screen_timeout = float(payload) + self.screen_timeout_ticks = \ + ticks_add( + ticks_diff( + self.screen_timeout_ticks, + int(self.screen_timeout * 1000)), + int(new_screen_timeout * 1000)) + self.screen_timeout = new_screen_timeout + + def reset_screen_timout(self): + self.screen_timeout_ticks = ticks_add( + ticks_ms(), + int(self.screen_timeout * 1000)) + + # @await_ready_state + async def _update_data_async(self): + self.button.press_func(self._on_button_pressed) + self.button.release_func(self._on_button_released) + rotary_last = 0 + last_temp = 0 + while True: + if ticks_diff(self.screen_timeout_ticks, ticks_ms()) <= 0: + self._set_screen_on(False) + if self._screen_on: + if rotary_last != self.rotary_encoder.value(): + rotary_last = self.rotary_encoder.value() + self.needs_redraw = True + if last_temp != self.bmp280.temperature: + self.needs_redraw = True + last_temp = self.bmp280.temperature + if self.needs_redraw: + self.needs_redraw = False + # self.display.fill(0) + # self.display.text(str(self.rotary_encoder.value()), 0, 20) + # self.display.text(str(self.button.rawstate()), 0, 30) + self.display.fill_rect(0, 0, 128, 10, 0) + self.display.text("{:1.2f}°C".format(self.bmp280.temperature), 1, 1) + actions = [("Einst.", None), ("Wasser", lambda : self._set_screen_on(False))] + self.rotary_encoder.set(max_val=len(actions) - 1) + for i, (a, br) in enumerate(actions): + selected = i == self.rotary_encoder.value() + color = 0 if selected else 1 + screen_dif = int(128 / len(actions)) + self.display.fill_rect( + screen_dif * i+1, 50, screen_dif-2, 10, 1-color) + self.display.text(a, screen_dif*i+2, 51, color) + if not self.screen_just_turned_on: + if selected: + self.on_button_released = br + self.display.show() + self.screen_just_turned_on = False + else: + self.rotary_encoder.set(value=0) + self.on_button_released, self.on_button_released = None, None + await asyncio.sleep_ms(int(self.update_interval*1000.0)) + + @property + def screen_on(self): + return self._screen_on + + @screen_on.setter + def set_screen_on(self, on): + self._set_screen_on(on) + + def _set_screen_on(self, on): + if on == self._screen_on: + if on: + self.reset_screen_timout() + else: + self._screen_on = on + if on: + # self.display.poweron() + self.reset_screen_timout() + self.needs_redraw = True + self.screen_just_turned_on = True + else: + self.display.poweroff() + self.property_screen_on = TRUE if on else FALSE diff --git a/code/plant_node.py b/code/plant_node.py new file mode 100644 index 0000000..bef0f45 --- /dev/null +++ b/code/plant_node.py @@ -0,0 +1,131 @@ +from homie.constants import BOOLEAN, FALSE, TRUE, FLOAT +from homie.property import HomieProperty +from update_homie_node import UpdateHomieNode +# from homie.device import await_ready_state +from machine import Pin, ADC +import uasyncio as asyncio + + +class PlantNode(UpdateHomieNode): + + def __init__( + self, + id, + name, + pin_watering, + pin_moisture, + pin_water_tank, + waterlevel_sensor, + interval=60*5, + interval_watering=0.1): + super().__init__( + id=id, name=name, type="watering", + interval=interval, + interval_short=interval_watering) + + # Update Interval + self.interval_normal = interval + self.interval_watering = interval_watering + + # WaterLevelSensor + self.waterlevel_sensor = waterlevel_sensor + self.property_waterlevel = HomieProperty( + id="waterlevel", + name="Wassertankstand", + datatype=FLOAT, + unit="L", + ) + self.add_property(self.property_waterlevel) + self.property_waterlevel_percent = HomieProperty( + id="waterlevel_percent", + name="Wassertankstand [%]", + datatype=FLOAT, + format="0.00:100.00", + unit="%", + ) + self.add_property(self.property_waterlevel_percent) + + self.property_waterlevel_volume_liter = HomieProperty( + id="waterlevel_volume_max", + name="Wassertankgröße", + settable=True, + datatype=FLOAT, + unit="L", + on_message=self._set_waterlevel_volume + ) + self.add_property(self.property_waterlevel_volume_liter) + + # Moisture + self.adc = ADC(pin_moisture) + self.adc.atten(ADC.ATTN_11DB) + self.property_moisture = HomieProperty( + id="moisture", + name="Feuchte", + datatype=FLOAT, + format="0.00:100.00", + unit="%", + ) + self.add_property(self.property_moisture) + + # Watering Motor + self.pin_watering_motor = pin_watering + self.pin_watering_motor.init(mode=Pin.OUT, value=0) + self.property_water_power = HomieProperty( + id="power", + name="Bewässerung", + settable=True, + datatype=BOOLEAN, + default=FALSE, + on_message=self.toggle_motor, + ) + self.add_property(self.property_water_power) + + self.property_watering_max_duration = HomieProperty( + id="watering_duration_max", + name="Bewässerungszeit", + settable=True, + datatype=FLOAT, + default=3, + unit="s", + ) + self.add_property(self.property_watering_max_duration) + + def update_data(self): + self.property_moisture.value = "{:1.2f}".format( + (4096 - self.adc.read()) / 40.96) + + self.property_waterlevel_percent.value = "{:1.0f}".format( + self.waterlevel_sensor.level_percent) + self.property_waterlevel.value = "{:1.2f}".format( + self.waterlevel_sensor.level) + self.property_waterlevel_volume_liter.value = "{:1.4f}".format( + self.waterlevel_sensor.volume) + + if self.pin_watering_motor.value() == 1: + self.interval_normal = self._interval + self.set_interval(self.interval_watering) + + def toggle_motor(self, topic, payload, retained): + ONOFF = {FALSE: 0, TRUE: 1} + v = ONOFF[payload] + self.pin_watering_motor(v) + if v == 1: + self.interval_normal = self._interval + self.set_interval(self.interval_watering) + asyncio.create_task(self.stop_motor()) + + def stop_motor(self): + await asyncio.sleep_ms( + int(float(self.property_watering_max_duration.value) * 1000)) + self.pin_watering_motor.value(0) + self.set_interval(self.interval_normal) + self.property_water_power.value = FALSE + + def _set_waterlevel_min_value(self, topic, payload, retained): + self.waterlevel_sensor.value_min = float(payload) + + def _set_waterlevel_max_value(self, topic, payload, retained): + self.waterlevel_sensor.value_max = float(payload) + + def _set_waterlevel_volume(self, topic, payload, retained): + self.waterlevel_sensor.volume = float(payload) diff --git a/code/update_homie_node.py b/code/update_homie_node.py new file mode 100644 index 0000000..9aa3cab --- /dev/null +++ b/code/update_homie_node.py @@ -0,0 +1,76 @@ + +from homie.constants import FLOAT +from homie.property import HomieProperty +from homie.node import HomieNode +from homie.device import await_ready_state + +import uasyncio as asyncio +from time import ticks_ms, ticks_add, ticks_diff + + +class UpdateHomieNode(HomieNode): + + def __init__( + self, + id, + name, + type, + interval=60*5, + interval_short=0.1): + super().__init__(id=id, name=name, type=type) + + self.interval_changed = False + self.interval_short = interval_short + # Update Interval + self._interval = interval + self.property_interval = HomieProperty( + id="update_interval", + name="Aktualisierungsrate", + datatype=FLOAT, # TODO ISO8601 + settable=True, + on_message=self._set_interval, + unit="s", + ) + self.add_property(self.property_interval) + + asyncio.create_task(self._update_data_async()) + + @await_ready_state + async def _update_data_async(self): + while True: + + # call child callback + self.update_data() + + # TODO ISO8601 + # self.property_interval.value = "PT{:1.3f}S".format(self.interval) + self.property_interval.value = "{:1.3f}".format(self.interval) + + # We don't simply wait the update interval, as it can change while waiting. + last_update = ticks_ms() + wait_till = ticks_add(last_update, int(self.interval * 1000.0)) + while ticks_diff(ticks_ms(), wait_till) < 0: + if self.interval_changed: + self.interval_changed = False + wait_till = ticks_add( + last_update, + int(self.interval * 1000)) + sleep_for = min(int(self.interval_short * 1000.0), + ticks_diff(wait_till, ticks_ms())) + await asyncio.sleep_ms(sleep_for) + + @property + def interval(self): + return self._interval + + @interval.setter + def setter_interval(self, i): + self.set_interval(i) + + def set_interval(self, i): + if i != self._interval: + self.interval_changed = True + self._interval = float(i) + + def _set_interval(self, topic, payload, retained): + self.set_interval(float(payload))