diff --git a/.envrc b/.envrc deleted file mode 100644 index 94840b3..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -layout python3 diff --git a/.idea/misc.xml b/.idea/misc.xml index 0bbdfbc..03e1fee 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,7 @@ - + + + \ No newline at end of file diff --git a/.idea/plant-badge.iml b/.idea/plant-badge.iml index 34acb50..f781b9c 100644 --- a/.idea/plant-badge.iml +++ b/.idea/plant-badge.iml @@ -13,7 +13,7 @@ - + diff --git a/.rtx.toml b/.rtx.toml new file mode 100644 index 0000000..f295e18 --- /dev/null +++ b/.rtx.toml @@ -0,0 +1,2 @@ +[tools] +python = {version='latest', virtualenv='.venv'} diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 09895e5..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -python@3.10.9 diff --git a/provisioning.yaml b/provisioning.yaml index 5b33921..114172b 100644 --- a/provisioning.yaml +++ b/provisioning.yaml @@ -94,20 +94,20 @@ e6614864d3269c34: ha_plant_min_dli: number.aloe_vera_min_dli e6614864d3938334: - HA_PLANT_ID: plant.senecio_himalaya - HA_PLANT_MOISTURE_SENSOR: sensor.senecio_himalaya_soil_moisture - HA_PLANT_TEMPERATURE_SENSOR: sensor.senecio_himalaya_temperature - HA_PLANT_CONDUCTIVITY_SENSOR: sensor.senecio_himalaya_conductivity - HA_PLANT_ILLUMINANCE_SENSOR: sensor.senecio_himalaya_illuminance - HA_PLANT_DLI_SENSOR: sensor.senecio_himalaya_dli + HA_PLANT_ID: plant.chlorophytum_comosum + HA_PLANT_MOISTURE_SENSOR: sensor.chlorophytum_comosum_soil_moisture + HA_PLANT_TEMPERATURE_SENSOR: sensor.chlorophytum_comosum_temperature + HA_PLANT_CONDUCTIVITY_SENSOR: sensor.chlorophytum_comosum_conductivity + HA_PLANT_ILLUMINANCE_SENSOR: sensor.chlorophytum_comosum_illuminance + HA_PLANT_DLI_SENSOR: sensor.chlorophytum_comosum_dli - ha_plant_max_moisture: number.senecio_himalaya_max_soil_moisture - ha_plant_min_moisture: number.senecio_himalaya_min_soil_moisture - ha_plant_max_temperature: number.senecio_himalaya_max_temperature - ha_plant_min_temperature: number.senecio_himalaya_min_temperature - ha_plant_max_illuminance: number.senecio_himalaya_max_illuminance - ha_plant_min_illuminance: number.senecio_himalaya_min_illuminance - ha_plant_max_conductivity: number.senecio_himalaya_max_conductivity - ha_plant_min_conductivity: number.senecio_himalaya_min_conductivity - ha_plant_max_dli: number.senecio_himalaya_max_dli - ha_plant_min_dli: number.senecio_himalaya_min_dli + ha_plant_max_moisture: number.chlorophytum_comosum_max_soil_moisture + ha_plant_min_moisture: number.chlorophytum_comosum_min_soil_moisture + ha_plant_max_temperature: number.chlorophytum_comosum_max_temperature + ha_plant_min_temperature: number.chlorophytum_comosum_min_temperature + ha_plant_max_illuminance: number.chlorophytum_comosum_max_illuminance + ha_plant_min_illuminance: number.chlorophytum_comosum_min_illuminance + ha_plant_max_conductivity: number.chlorophytum_comosum_max_conductivity + ha_plant_min_conductivity: number.chlorophytum_comosum_min_conductivity + ha_plant_max_dli: number.chlorophytum_comosum_max_dli + ha_plant_min_dli: number.chlorophytum_comosum_min_dli diff --git a/src/apps/plant.py b/src/apps/plant.py index 896d3ac..82522b9 100644 --- a/src/apps/plant.py +++ b/src/apps/plant.py @@ -1,5 +1,6 @@ import urequests import jpegdec +import sys from badger2040 import ( WIDTH, @@ -224,9 +225,13 @@ def display_header(text): display.rectangle(0, 0, WIDTH, 20) # Write text in header + if len(text) > 15: + text = text.split(" ")[0] + if len(text) > 15: + text = text[:14] + "." display.set_font("bitmap6") display.set_pen(WHITE) - display.text(text, 3, 4) + display.text(text[:16], 3, 4) # Display time hour, minute = get_time() @@ -249,7 +254,7 @@ while True: try: main() except Exception as e: - print(e) + sys.print_exception(e) warning(display, str(e)) display.set_timer_minutes_with_jitter(secrets.ERROR_REFRESH_INTERVAL_MINUTES) display.halt() diff --git a/src/launcher.py b/src/launcher.py index 39ea8ed..ab0c249 100644 --- a/src/launcher.py +++ b/src/launcher.py @@ -165,6 +165,10 @@ if exited_to_launcher or not woken_by_button: display.set_update_speed(badger2040.UPDATE_FAST) while True: + # Sometimes a button press or hold will keep the system + # powered *through* HALT, so latch the power back on. + display.keepalive() + if display.pressed(badger2040.BUTTON_A): button(badger2040.BUTTON_A) if display.pressed(badger2040.BUTTON_B): diff --git a/src/lib/badger2040.py b/src/lib/badger2040.py deleted file mode 100644 index 9d680e0..0000000 --- a/src/lib/badger2040.py +++ /dev/null @@ -1,183 +0,0 @@ -import machine -import micropython -from picographics import PicoGraphics, DISPLAY_INKY_PACK -import network -from network_manager import NetworkManager -import WIFI_CONFIG -import uasyncio -import time -import gc -import wakeup - - -BUTTON_DOWN = 11 -BUTTON_A = 12 -BUTTON_B = 13 -BUTTON_C = 14 -BUTTON_UP = 15 -BUTTON_USER = None # User button not available on W - -BUTTON_MASK = 0b11111 << 11 - -SYSTEM_VERY_SLOW = 0 -SYSTEM_SLOW = 1 -SYSTEM_NORMAL = 2 -SYSTEM_FAST = 3 -SYSTEM_TURBO = 4 - -UPDATE_NORMAL = 0 -UPDATE_MEDIUM = 1 -UPDATE_FAST = 2 -UPDATE_TURBO = 3 - -LED = 22 -ENABLE_3V3 = 10 -BUSY = 26 - -WIDTH = 296 -HEIGHT = 128 - -SYSTEM_FREQS = [4000000, 12000000, 48000000, 133000000, 250000000] - -BUTTONS = { - BUTTON_DOWN: machine.Pin(BUTTON_DOWN, machine.Pin.IN, machine.Pin.PULL_DOWN), - BUTTON_A: machine.Pin(BUTTON_A, machine.Pin.IN, machine.Pin.PULL_DOWN), - BUTTON_B: machine.Pin(BUTTON_B, machine.Pin.IN, machine.Pin.PULL_DOWN), - BUTTON_C: machine.Pin(BUTTON_C, machine.Pin.IN, machine.Pin.PULL_DOWN), - BUTTON_UP: machine.Pin(BUTTON_UP, machine.Pin.IN, machine.Pin.PULL_DOWN), -} - -WAKEUP_MASK = 0 - - -def is_wireless(): - return True - - -def woken_by_button(): - return wakeup.get_gpio_state() & BUTTON_MASK > 0 - - -def pressed_to_wake(button): - return wakeup.get_gpio_state() & (1 << button) > 0 - - -def reset_pressed_to_wake(): - wakeup.reset_gpio_state() - - -def pressed_to_wake_get_once(button): - global WAKEUP_MASK - result = (wakeup.get_gpio_state() & ~WAKEUP_MASK & (1 << button)) > 0 - WAKEUP_MASK |= 1 << button - return result - - -def system_speed(speed): - try: - machine.freq(SYSTEM_FREQS[speed]) - except IndexError: - pass - - -class Badger2040: - def __init__(self): - self.display = PicoGraphics(DISPLAY_INKY_PACK) - self._led = machine.PWM(machine.Pin(LED)) - self._led.freq(1000) - self._led.duty_u16(0) - self._update_speed = 0 - - def __getattr__(self, item): - # Glue to redirect calls to PicoGraphics - return getattr(self.display, item) - - def update(self): - t_start = time.ticks_ms() - self.display.update() - t_elapsed = time.ticks_ms() - t_start - - delay_ms = [4700, 2600, 900, 250][self._update_speed] - - if t_elapsed < delay_ms: - time.sleep((delay_ms - t_elapsed) / 1000) - - def set_update_speed(self, speed): - self.display.set_update_speed(speed) - self._update_speed = speed - - def led(self, brightness): - brightness = max(0, min(255, brightness)) - self._led.duty_u16(int(brightness * 256)) - - def invert(self, invert): - raise RuntimeError("Display invert not supported in PicoGraphics.") - - def thickness(self, thickness): - raise RuntimeError("Thickness not supported in PicoGraphics.") - - def halt(self): - time.sleep(0.05) - enable = machine.Pin(ENABLE_3V3, machine.Pin.OUT) - enable.off() - while not self.pressed_any(): - pass - - def pressed(self, button): - return BUTTONS[button].value() == 1 or pressed_to_wake_get_once(button) - - def pressed_any(self): - for button in BUTTONS.values(): - if button.value(): - return True - return False - - @micropython.native - def icon(self, data, index, data_w, icon_size, x, y): - s_x = (index * icon_size) % data_w - s_y = int((index * icon_size) / data_w) - - for o_y in range(icon_size): - for o_x in range(icon_size): - o = ((o_y + s_y) * data_w) + (o_x + s_x) - bm = 0b10000000 >> (o & 0b111) - if data[o >> 3] & bm: - self.display.pixel(x + o_x, y + o_y) - - def image(self, data, w, h, x, y): - for oy in range(h): - row = data[oy] - for ox in range(w): - if row & 0b1 == 0: - self.display.pixel(x + ox, y + oy) - row >>= 1 - - def status_handler(self, mode, status, ip): - print(mode, status, ip) - self.display.set_pen(15) - self.display.clear() - self.display.set_pen(0) - if status: - self.display.text("Connected!", 10, 10, 300, 0.5) - self.display.text(ip, 10, 30, 300, 0.5) - else: - self.display.text("Connecting...", 10, 10, 300, 0.5) - self.update() - - def isconnected(self): - return network.WLAN(network.STA_IF).isconnected() - - def ip_address(self): - return network.WLAN(network.STA_IF).ifconfig()[0] - - def connect(self): - if WIFI_CONFIG.COUNTRY == "": - raise RuntimeError("You must populate WIFI_CONFIG.py for networking.") - self.display.set_update_speed(2) - network_manager = NetworkManager( - WIFI_CONFIG.COUNTRY, status_handler=self.status_handler - ) - uasyncio.get_event_loop().run_until_complete( - network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK) - ) - gc.collect() diff --git a/src/lib/badger_os.py b/src/lib/badger_os.py deleted file mode 100644 index e6bf406..0000000 --- a/src/lib/badger_os.py +++ /dev/null @@ -1,211 +0,0 @@ -import os -import gc -import time -import json -import machine -import badger2040 - - -def get_battery_level(): - # Battery measurement - vbat_adc = machine.ADC(badger2040.PIN_BATTERY) - vref_adc = machine.ADC(badger2040.PIN_1V2_REF) - vref_en = machine.Pin(badger2040.PIN_VREF_POWER) - vref_en.init(machine.Pin.OUT) - vref_en.value(0) - - # Enable the onboard voltage reference - vref_en.value(1) - - # Calculate the logic supply voltage, as will be lower that the usual 3.3V when running off low batteries - vdd = 1.24 * (65535 / vref_adc.read_u16()) - vbat = ( - (vbat_adc.read_u16() / 65535) * 3 * vdd - ) # 3 in this is a gain, not rounding of 3.3V - - # Disable the onboard voltage reference - vref_en.value(0) - - # Convert the voltage to a level to display onscreen - return vbat - - -def get_disk_usage(): - # f_bfree and f_bavail should be the same? - # f_files, f_ffree, f_favail and f_flag are unsupported. - f_bsize, f_frsize, f_blocks, f_bfree, _, _, _, _, _, f_namemax = os.statvfs("/") - - f_total_size = f_frsize * f_blocks - f_total_free = f_bsize * f_bfree - f_total_used = f_total_size - f_total_free - - f_used = 100 / f_total_size * f_total_used - f_free = 100 / f_total_size * f_total_free - - return f_total_size, f_used, f_free - - -def state_running(): - state = {"running": "launcher"} - state_load("launcher", state) - return state["running"] - - -def state_clear_running(): - running = state_running() - state_modify("launcher", {"running": "launcher"}) - return running != "launcher" - - -def state_set_running(app): - state_modify("launcher", {"running": app}) - - -def state_launch(): - app = state_running() - if app is not None and app != "launcher": - launch(app) - - -def state_delete(app): - try: - os.remove("/state/{}.json".format(app)) - except OSError: - pass - - -def state_save(app, data): - try: - with open("/state/{}.json".format(app), "w") as f: - f.write(json.dumps(data)) - f.flush() - except OSError: - import os - - try: - os.stat("/state") - except OSError: - os.mkdir("/state") - state_save(app, data) - - -def state_modify(app, data): - state = {} - state_load(app, state) - state.update(data) - state_save(app, state) - - -def state_load(app, defaults): - try: - data = json.loads(open("/state/{}.json".format(app), "r").read()) - if type(data) is dict: - defaults.update(data) - return True - except (OSError, ValueError): - pass - - state_save(app, defaults) - return False - - -def launch(file): - state_set_running(file) - - gc.collect() - - button_a = machine.Pin(badger2040.BUTTON_A, machine.Pin.IN, machine.Pin.PULL_DOWN) - button_c = machine.Pin(badger2040.BUTTON_C, machine.Pin.IN, machine.Pin.PULL_DOWN) - - def quit_to_launcher(pin): - if button_a.value() and button_c.value(): - machine.reset() - - button_a.irq(trigger=machine.Pin.IRQ_RISING, handler=quit_to_launcher) - button_c.irq(trigger=machine.Pin.IRQ_RISING, handler=quit_to_launcher) - - try: - __import__(file) - - except ImportError as e: - # If the app doesn't exist, notify the user - warning(None, f"Could not launch: {file}. {e}") - time.sleep(4.0) - except Exception as e: - # If the app throws an error, catch it and display! - print(e) - state_clear_running() - display = badger2040.Badger2040() - warning(display, str(e)) - display.halt() - - # If the app exits or errors, do not relaunch! - state_clear_running() - machine.reset() # Exit back to launcher - - -# Draw an overlay box with a given message within it -def warning( - display, - message, - width=badger2040.WIDTH - 20, - height=badger2040.HEIGHT - 20, - line_spacing=20, - text_size=0.6, -): - print(message) - - if display is None: - display = badger2040.Badger2040() - display.led(128) - - # Draw a light grey background - display.set_pen(12) - display.rectangle( - (badger2040.WIDTH - width) // 2, - (badger2040.HEIGHT - height) // 2, - width, - height, - ) - - width -= 20 - height -= 20 - - display.set_pen(15) - display.rectangle( - (badger2040.WIDTH - width) // 2, - (badger2040.HEIGHT - height) // 2, - width, - height, - ) - - # Take the provided message and split it up into - # lines that fit within the specified width - words = message.split(" ") - - lines = [] - current_line = "" - for word in words: - if display.measure_text(current_line + word + " ", text_size) < width: - current_line += word + " " - else: - lines.append(current_line.strip()) - current_line = word + " " - lines.append(current_line.strip()) - - display.set_pen(0) - - # Display each line of text from the message, centre-aligned - num_lines = len(lines) - for i in range(num_lines): - length = display.measure_text(lines[i], text_size) - current_line = (i * line_spacing) - ((num_lines - 1) * line_spacing) // 2 - display.text( - lines[i], - (badger2040.WIDTH - length) // 2, - (badger2040.HEIGHT // 2) + current_line, - badger2040.WIDTH, - text_size, - ) - - display.update() diff --git a/src/lib/network_manager.py b/src/lib/network_manager.py deleted file mode 100644 index 8feffaf..0000000 --- a/src/lib/network_manager.py +++ /dev/null @@ -1,117 +0,0 @@ -import rp2 -import network -import machine -import uasyncio - - -class NetworkManager: - _ifname = ("Client", "Access Point") - - def __init__( - self, - country="GB", - client_timeout=30, - access_point_timeout=5, - status_handler=None, - error_handler=None, - ): - rp2.country(country) - self._ap_if = network.WLAN(network.AP_IF) - self._sta_if = network.WLAN(network.STA_IF) - - self._mode = network.STA_IF - self._client_timeout = client_timeout - self._access_point_timeout = access_point_timeout - self._status_handler = status_handler - self._error_handler = error_handler - self.UID = ("{:02X}" * 8).format(*machine.unique_id()) - - def isconnected(self): - return self._sta_if.isconnected() or self._ap_if.isconnected() - - def config(self, var): - if self._sta_if.active(): - return self._sta_if.config(var) - else: - if var == "password": - return self.UID - return self._ap_if.config(var) - - def mode(self): - if self._sta_if.isconnected(): - return self._ifname[0] - if self._ap_if.isconnected(): - return self._ifname[1] - return None - - def ifaddress(self): - if self._sta_if.isconnected(): - return self._sta_if.ifconfig()[0] - if self._ap_if.isconnected(): - return self._ap_if.ifconfig()[0] - return "0.0.0.0" - - def disconnect(self): - if self._sta_if.isconnected(): - self._sta_if.disconnect() - if self._ap_if.isconnected(): - self._ap_if.disconnect() - - async def wait(self, mode): - while not self.isconnected(): - self._handle_status(mode, None) - await uasyncio.sleep_ms(1000) - - def _handle_status(self, mode, status): - if callable(self._status_handler): - self._status_handler(self._ifname[mode], status, self.ifaddress()) - - def _handle_error(self, mode, msg): - if callable(self._error_handler): - if self._error_handler(self._ifname[mode], msg): - return - raise RuntimeError(msg) - - async def client(self, ssid, psk): - if self._sta_if.isconnected(): - self._handle_status(network.STA_IF, True) - return - - self._ap_if.disconnect() - self._ap_if.active(False) - - self._sta_if.active(True) - self._sta_if.connect(ssid, psk) - self._sta_if.config(pm=0xA11140) - - try: - await uasyncio.wait_for(self.wait(network.STA_IF), self._client_timeout) - self._handle_status(network.STA_IF, True) - - except uasyncio.TimeoutError: - self._sta_if.active(False) - self._handle_status(network.STA_IF, False) - self._handle_error(network.STA_IF, "WIFI Client Failed") - - async def access_point(self): - if self._ap_if.isconnected(): - self._handle_status(network.AP_IF, True) - return - - self._sta_if.disconnect() - self._sta_if.active(False) - - self._ap_if.ifconfig(("10.10.1.1", "255.255.255.0", "10.10.1.1", "10.10.1.1")) - self._ap_if.config(password=self.UID) - self._ap_if.active(True) - - try: - await uasyncio.wait_for( - self.wait(network.AP_IF), self._access_point_timeout - ) - self._handle_status(network.AP_IF, True) - - except uasyncio.TimeoutError: - self._sta_if.active(False) - self._handle_status(network.AP_IF, False) - self._handle_error(network.AP_IF, "WIFI Client Failed")