diff --git a/enviro/__init__.py b/enviro/__init__.py index 956e704..bd3587e 100644 --- a/enviro/__init__.py +++ b/enviro/__init__.py @@ -93,12 +93,17 @@ def stop_activity_led(): # control never returns to here, provisioning takes over completely # all the other imports, so many shiny modules -import machine, sys, os, json +import machine, sys, os, ujson from machine import RTC, ADC import phew from pcf85063a import PCF85063A import enviro.helpers as helpers +# read the state of vbus to know if we were woken up by USB +vbus_present = Pin("WL_GPIO2", Pin.IN).value() + +#BUG Temporarily disabling battery reading, as it seems to cause issues when connected to Thonny +""" # read battery voltage - we have to toggle the wifi chip select # pin to take the reading - this is probably not ideal but doesn't # seem to cause issues. there is no obvious way to shut down the @@ -113,10 +118,12 @@ def stop_activity_led(): battery_voltage /= sample_count battery_voltage = round(battery_voltage, 3) Pin(WIFI_CS_PIN).value(old_state) +""" # set up the button, external trigger, and rtc alarm pins rtc_alarm_pin = Pin(RTC_ALARM_PIN, Pin.IN, Pin.PULL_DOWN) -external_trigger_pin = Pin(EXTERNAL_INTERRUPT_PIN, Pin.IN, Pin.PULL_DOWN) +# BUG This should only be set up for Enviro Camera +# external_trigger_pin = Pin(EXTERNAL_INTERRUPT_PIN, Pin.IN, Pin.PULL_DOWN) # intialise the pcf85063a real time clock chip rtc = PCF85063A(i2c) @@ -124,6 +131,7 @@ def stop_activity_led(): rtc.enable_timer_interrupt(False) t = rtc.datetime() +# BUG ERRNO 22, EINVAL, when date read from RTC is invalid for the pico's RTC. RTC().datetime((t[0], t[1], t[2], t[6], t[3], t[4], t[5], 0)) # synch PR2040 rtc too # jazz up that console! toot toot! @@ -143,16 +151,19 @@ def stop_activity_led(): print("") - +import network # TODO this was removed from 0.0.8 def connect_to_wifi(): + """ TODO what it was changed to if phew.is_connected_to_wifi(): logging.info(f"> already connected to wifi") return True + """ wifi_ssid = config.wifi_ssid wifi_password = config.wifi_password logging.info(f"> connecting to wifi network '{wifi_ssid}'") + """ TODO what it was changed to ip = phew.connect_to_wifi(wifi_ssid, wifi_password, timeout_seconds=30) if not ip: @@ -160,13 +171,32 @@ def connect_to_wifi(): return False logging.info(" - ip address: ", ip) + """ + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + wlan.connect(wifi_ssid, wifi_password) + + start = time.ticks_ms() + while time.ticks_diff(time.ticks_ms(), start) < 30000: + if wlan.status() < 0 or wlan.status() >= 3: + break + time.sleep(0.5) - return True + seconds_to_connect = int(time.ticks_diff(time.ticks_ms(), start) / 1000) -# returns the reason we woke up -def wake_reason(): - reason = get_wake_reason() - return wake_reason_name(reason) + if wlan.status() != 3: + logging.error(f"! failed to connect to wireless network {wifi_ssid}") + return False + + # a slow connection time will drain the battery faster and may + # indicate a poor quality connection + if seconds_to_connect > 5: + logging.warn(" - took", seconds_to_connect, "seconds to connect to wifi") + + ip_address = wlan.ifconfig()[0] + logging.info(" - ip address: ", ip_address) + + return True # log the error, blink the warning led, and go back to sleep def halt(message): @@ -174,6 +204,15 @@ def halt(message): warn_led(WARN_LED_BLINK) sleep() +# log the exception, blink the warning led, and go back to sleep +def exception(exc): + import sys, io + buf = io.StringIO() + sys.print_exception(exc, buf) + logging.exception("! " + buf.getvalue()) + warn_led(WARN_LED_BLINK) + sleep() + # returns True if we've used up 90% of the internal filesystem def low_disk_space(): if not phew.remote_mount: # os.statvfs doesn't exist on remote mounts @@ -189,10 +228,13 @@ def sync_clock_from_ntp(): from phew import ntp if not connect_to_wifi(): return False + #TODO Fetch only does one attempt. Can also optionally set Pico RTC (do we want this?) timestamp = ntp.fetch() if not timestamp: + logging.error(" - failed to fetch time from ntp server") return False rtc.datetime(timestamp) # set the time on the rtc chip + logging.info(" - rtc synched") return True # set the state of the warning led (off, on, blinking) @@ -210,13 +252,18 @@ def warn_led(state): # returns the reason the board woke up from deep sleep def get_wake_reason(): + import wakeup + wake_reason = None - if button_pin.value(): + if wakeup.get_gpio_state() & (1 << BUTTON_PIN): wake_reason = WAKE_REASON_BUTTON_PRESS - elif rtc_alarm_pin.value(): + elif wakeup.get_gpio_state() & (1 << RTC_ALARM_PIN): wake_reason = WAKE_REASON_RTC_ALARM - elif not external_trigger_pin.value(): - wake_reason = WAKE_REASON_EXTERNAL_TRIGGER + # TODO Temporarily removing this as false reporting on non-camera boards + #elif not external_trigger_pin.value(): + # wake_reason = WAKE_REASON_EXTERNAL_TRIGGER + elif vbus_present: + wake_reason = WAKE_REASON_USB_POWERED return wake_reason # convert a wake reason into it's name @@ -227,26 +274,53 @@ def wake_reason_name(wake_reason): WAKE_REASON_BUTTON_PRESS: "button", WAKE_REASON_RTC_ALARM: "rtc_alarm", WAKE_REASON_EXTERNAL_TRIGGER: "external_trigger", - WAKE_REASON_RAIN_TRIGGER: "rain_sensor" + WAKE_REASON_RAIN_TRIGGER: "rain_sensor", + WAKE_REASON_USB_POWERED: "usb_powered" } return names.get(wake_reason) # get the readings from the on board sensors def get_sensor_readings(): - readings = get_board().get_sensor_readings() - readings["voltage"] = battery_voltage + seconds_since_last = 0 + now_str = helpers.datetime_string() + if helpers.file_exists("last_time.txt"): + now = helpers.timestamp(now_str) + + time_entries = [] + with open("last_time.txt", "r") as timefile: + time_entries = timefile.read().split("\n") + + # read the first line from the time file + last = now + for entry in time_entries: + if entry: + last = helpers.timestamp(entry) + break + + seconds_since_last = now - last + logging.info(f" - seconds since last reading: {seconds_since_last}") + + + readings = get_board().get_sensor_readings(seconds_since_last) + readings["voltage"] = 0.0 # battery_voltage #Temporarily removed until issue is fixed + + # write out the last time log + with open("last_time.txt", "w") as timefile: + timefile.write(now_str) + return readings # save the provided readings into a todays readings data file def save_reading(readings): # open todays reading file and save readings helpers.mkdir_safe("readings") - readings_filename = f"readings/{helpers.date_string()}.txt" + readings_filename = f"readings/{helpers.datetime_file_string()}.txt" new_file = not helpers.file_exists(readings_filename) with open(readings_filename, "a") as f: if new_file: # new readings file so write out column headings first f.write("timestamp," + ",".join(readings.keys()) + "\r\n") + # write sensor data row = [helpers.datetime_string()] for key in readings.keys(): @@ -263,10 +337,12 @@ def cache_upload(readings): "model": model, "uid": helpers.uid() } - uploads_filename = f"uploads/{helpers.datetime_string()}.json" + + uploads_filename = f"uploads/{helpers.datetime_file_string()}.json" helpers.mkdir_safe("uploads") with open(uploads_filename, "w") as upload_file: - json.dump(payload, upload_file) + #json.dump(payload, upload_file) # TODO what it was changed to + upload_file.write(ujson.dumps(payload)) # return the number of cached results waiting to be uploaded def cached_upload_count(): @@ -279,47 +355,91 @@ def is_upload_needed(): # upload cached readings to the configured destination def upload_readings(): if not connect_to_wifi(): + logging.error(f" - cannot upload readings, wifi connection failed") return False destination = config.destination - exec(f"import enviro.destinations.{destination}") - destination_module = sys.modules[f"enviro.destinations.{destination}"] - for cache_file in os.ilistdir("uploads"): - with open(f"uploads/{cache_file[0]}", "r") as upload_file: - success = destination_module.upload_reading(json.load(upload_file)) - if not success: - logging.error(f"! failed to upload '{cache_file[0]}' to {destination}") + try: + exec(f"import enviro.destinations.{destination}") + destination_module = sys.modules[f"enviro.destinations.{destination}"] + destination_module.log_destination() + + for cache_file in os.ilistdir("uploads"): + try: + with open(f"uploads/{cache_file[0]}", "r") as upload_file: + status = destination_module.upload_reading(ujson.load(upload_file)) + if status == UPLOAD_SUCCESS: + os.remove(f"uploads/{cache_file[0]}") + logging.info(f" - uploaded {cache_file[0]}") + elif status == UPLOAD_RATE_LIMITED: + # write out that we want to attempt a reupload + with open("reattempt_upload.txt", "w") as attemptfile: + attemptfile.write("") + + logging.info(f" - rate limited, going to sleep for 1 minute") + sleep(1) + else: + logging.error(f" ! failed to upload '{cache_file[0]}' to {destination}") + return False + + except OSError: + logging.error(f" ! failed to open '{cache_file[0]}'") return False - # remove the cache file now uploaded - logging.info(f" - uploaded {cache_file[0]} to {destination}") - - os.remove(f"uploads/{cache_file[0]}") + except KeyError: + logging.error(f" ! skipping '{cache_file[0]}' as it is missing data. It was likely created by an older version of the enviro firmware") + except ImportError: + logging.error(f"! cannot find destination {destination}") + return False + return True def startup(): # write startup banner into log file logging.debug("> performing startup") + # get the reason we were woken up + reason = get_wake_reason() + # give each board a chance to perform any startup it needs # =========================================================================== board = get_board() if hasattr(board, "startup"): - board.startup() + continue_startup = board.startup(reason) + # put the board back to sleep if the startup doesn't need to continue + # and the RTC has not triggered since we were awoken + if not continue_startup and not rtc.read_alarm_flag(): + logging.debug(" - wake reason: trigger") + sleep() # log the wake reason - logging.info(" - wake reason:", wake_reason()) + logging.info(" - wake reason:", wake_reason_name(reason)) # also immediately turn on the LED to indicate that we're doing something logging.debug(" - turn on activity led") pulse_activity_led(0.5) -def sleep(): + # see if we were woken to attempt a reupload + if helpers.file_exists("reattempt_upload.txt"): + os.remove("reattempt_upload.txt") + + logging.info(f"> {cached_upload_count()} cache file(s) still to upload") + if not upload_readings(): + halt("! reading upload failed") + + # if it was the RTC that woke us, go to sleep until our next scheduled reading + # otherwise continue with taking new readings etc + # Note, this *may* result in a missed reading + if reason == WAKE_REASON_RTC_ALARM: + sleep() + +def sleep(time_override=None): logging.info("> going to sleep") # make sure the rtc flags are cleared before going back to sleep - logging.debug(" - clearing and disabling timer and alarm") + logging.debug(" - clearing and disabling previous alarm") + rtc.clear_timer_flag() # TODO this was removed from 0.0.8 rtc.clear_alarm_flag() # set alarm to wake us up for next reading @@ -327,8 +447,12 @@ def sleep(): hour, minute = dt[3:5] # calculate how many minutes into the day we are - minute = math.floor(minute / config.reading_frequency) * config.reading_frequency - minute += config.reading_frequency + if time_override is not None: + minute += time_override + else: + minute = math.floor(minute / config.reading_frequency) * config.reading_frequency + minute += config.reading_frequency + while minute >= 60: minute -= 60 hour += 1 @@ -356,9 +480,13 @@ def sleep(): sys.exit() # we'll wait here until the rtc timer triggers and then reset the board - logging.debug(" - on usb power (so can't shutdown) halt and reset instead") - while not rtc.read_alarm_flag(): - time.sleep(0.25) + logging.debug(" - on usb power (so can't shutdown). Halt and wait for alarm or user reset instead") + board = get_board() + while not rtc.read_alarm_flag(): + if hasattr(board, "check_trigger"): + board.check_trigger() + + #time.sleep(0.25) if button_pin.value(): # allow button to force reset break @@ -366,4 +494,4 @@ def sleep(): logging.debug(" - reset") # reset the board - machine.reset() \ No newline at end of file + machine.reset() diff --git a/enviro/boards/grow.py b/enviro/boards/grow.py index dc4a1d8..48158cc 100644 --- a/enviro/boards/grow.py +++ b/enviro/boards/grow.py @@ -5,6 +5,8 @@ from enviro import i2c from phew import logging +CHANNEL_NAMES = ['A', 'B', 'C'] + bme280 = BreakoutBME280(i2c, 0x77) ltr559 = BreakoutLTR559(i2c) @@ -34,7 +36,7 @@ def moisture_readings(): first = None last = None ticks = 0 - while ticks < 10 and time.ticks_ms() - start <= 1000: + while ticks < 10 and time.ticks_diff(time.ticks_ms(), start) <= 1000: value = sensor.value() if last_value != value: if first == None: @@ -48,7 +50,7 @@ def moisture_readings(): continue # calculate the average tick between transitions in ms - average = (last - first) / ticks + average = time.ticks_diff(last, first) / ticks # scale the result to a 0...100 range where 0 is very dry # and 100 is standing in water # @@ -73,9 +75,9 @@ def drip_noise(): def water(moisture_levels): from enviro import config targets = [ - config.moisture_target_1, - config.moisture_target_2, - config.moisture_target_3 + config.moisture_target_a, + config.moisture_target_b, + config.moisture_target_c ] for i in range(0, 3): @@ -83,17 +85,20 @@ def water(moisture_levels): # determine a duration to run the pump for duration = round((targets[i] - moisture_levels[i]) / 25, 1) + logging.info(f"> sensor {CHANNEL_NAMES[i]} below moisture target {targets[i]} (currently at {int(moisture_levels[i])}).") + if config.auto_water: - logging.info(f"> running pump {i} for {duration} second (currently at {int(moisture_levels[i])}, target {targets[i]})") + logging.info(f" - running pump {CHANNEL_NAMES[i]} for {duration} second(s)") pump_pins[i].value(1) time.sleep(duration) pump_pins[i].value(0) else: + logging.info(f" - playing beep") for j in range(0, i + 1): drip_noise() time.sleep(0.5) -def get_sensor_readings(): +def get_sensor_readings(seconds_since_last): # bme280 returns the register contents immediately and then starts a new reading # we want the current reading so do a dummy read to discard register contents first bme280.read() @@ -103,11 +108,10 @@ def get_sensor_readings(): ltr_data = ltr559.get_reading() moisture_levels = moisture_readings() - + water(moisture_levels) # run pumps if needed from ucollections import OrderedDict - return OrderedDict({ "temperature": round(bme280_data[0], 2), "humidity": round(bme280_data[2], 2), diff --git a/enviro/boards/indoor.py b/enviro/boards/indoor.py index 2b8b794..a06bc24 100644 --- a/enviro/boards/indoor.py +++ b/enviro/boards/indoor.py @@ -40,7 +40,7 @@ def colour_temperature_from_rgbc(r, g, b, c): ct = 10000 return round(ct) -def get_sensor_readings(): +def get_sensor_readings(seconds_since_last): data = bme688.read() temperature = round(data[0], 2) @@ -63,5 +63,5 @@ def get_sensor_readings(): "gas_resistance": gas_resistance, "aqi": aqi, "luminance": lux_from_rgbc(r, g, b, c), - "color_temperature": colour_temperature_from_rgbc(r, g, b, c), + "color_temperature": colour_temperature_from_rgbc(r, g, b, c) }) \ No newline at end of file diff --git a/enviro/boards/urban.py b/enviro/boards/urban.py index 4180fd5..6f5a07e 100644 --- a/enviro/boards/urban.py +++ b/enviro/boards/urban.py @@ -5,6 +5,9 @@ from phew import logging from enviro import i2c +# how long to capture the microphone signal for when taking a reading, in milliseconds +MIC_SAMPLE_TIME_MS = 500 + sensor_reset_pin = Pin(9, Pin.OUT, value=True) sensor_enable_pin = Pin(10, Pin.OUT, value=False) boost_enable_pin = Pin(11, Pin.OUT, value=False) @@ -31,7 +34,7 @@ def particulates(particulate_data, measure): multiplier = 10 if measure >= PM0_3_PER_LITRE else 1 return ((particulate_data[measure * 2] << 8) | particulate_data[measure * 2 + 1]) * multiplier -def get_sensor_readings(): +def get_sensor_readings(seconds_since_last): # bme280 returns the register contents immediately and then starts a new reading # we want the current reading so do a dummy read to discard register contents first bme280.read() @@ -52,25 +55,25 @@ def get_sensor_readings(): sensor_enable_pin.value(False) boost_enable_pin.value(False) - sample_time_ms = 500 + logging.debug(" - taking microphone reading") start = time.ticks_ms() min_value = 1.65 max_value = 1.65 - while time.ticks_ms() - start < sample_time_ms: - value = noise_adc.read_u16() / (3.3 * 65535) + while time.ticks_diff(time.ticks_ms(), start) < MIC_SAMPLE_TIME_MS: + value = (noise_adc.read_u16() * 3.3) / 65535 min_value = min(min_value, value) max_value = max(max_value, value) - noise_vpp = round((max_value - min_value), 3) + noise_vpp = max_value - min_value from ucollections import OrderedDict return OrderedDict({ "temperature": round(bme280_data[0], 2), "humidity": round(bme280_data[2], 2), "pressure": round(bme280_data[1] / 100.0, 2), - "noise": round(noise_vpp, 2), + "noise": round(noise_vpp, 3), "pm1": particulates(particulate_data, PM1_UGM3), "pm2_5": particulates(particulate_data, PM2_5_UGM3), - "pm10": particulates(particulate_data, PM10_UGM3), + "pm10": particulates(particulate_data, PM10_UGM3) }) diff --git a/enviro/boards/weather.py b/enviro/boards/weather.py index a64328e..3fc4b8e 100644 --- a/enviro/boards/weather.py +++ b/enviro/boards/weather.py @@ -1,11 +1,12 @@ -import time, math +import time, math, os from breakout_bme280 import BreakoutBME280 from breakout_ltr559 import BreakoutLTR559 from machine import Pin, PWM from pimoroni import Analog -from enviro import i2c, hold_vsys_en_pin +from enviro import i2c, activity_led import enviro.helpers as helpers from phew import logging +from enviro.constants import WAKE_REASON_RTC_ALARM, WAKE_REASON_BUTTON_PRESS # amount of rain required for the bucket to tip in mm RAIN_MM_PER_TICK = 0.2794 @@ -21,8 +22,11 @@ wind_direction_pin = Analog(26) wind_speed_pin = Pin(9, Pin.IN, Pin.PULL_UP) +rain_pin = Pin(10, Pin.IN, Pin.PULL_DOWN) +last_rain_trigger = False -def startup(): +def startup(reason): + global last_rain_trigger import wakeup # check if rain sensor triggered wake @@ -36,7 +40,7 @@ def startup(): rain_entries = rainfile.read().split("\n") # add new entry - logging.info("> add new rain trigger at {helpers.datetime_string()}") + logging.info(f"> add new rain trigger at {helpers.datetime_string()}") rain_entries.append(helpers.datetime_string()) # limit number of entries to 190 - each entry is 21 bytes including @@ -48,8 +52,44 @@ def startup(): with open("rain.txt", "w") as rainfile: rainfile.write("\n".join(rain_entries)) - # go immediately back to sleep, we'll wake up at next scheduled reading - hold_vsys_en_pin.init(Pin.IN) + last_rain_trigger = True + + # if we were woken by the RTC or a Poke continue with the startup + return (reason is WAKE_REASON_RTC_ALARM + or reason is WAKE_REASON_BUTTON_PRESS) + + # there was no rain trigger so continue with the startup + return True + +def check_trigger(): + global last_rain_trigger + rain_sensor_trigger = rain_pin.value() + + if rain_sensor_trigger and not last_rain_trigger: + activity_led(100) + time.sleep(0.05) + activity_led(0) + + # read the current rain entries + rain_entries = [] + if helpers.file_exists("rain.txt"): + with open("rain.txt", "r") as rainfile: + rain_entries = rainfile.read().split("\n") + + # add new entry + logging.info(f"> add new rain trigger at {helpers.datetime_string()}") + rain_entries.append(helpers.datetime_string()) + + # limit number of entries to 190 - each entry is 21 bytes including + # newline so this keeps the total rain.txt filesize just under one + # filesystem block (4096 bytes) + rain_entries = rain_entries[-190:] + + # write out adjusted rain log + with open("rain.txt", "w") as rainfile: + rainfile.write("\n".join(rain_entries)) + + last_rain_trigger = rain_sensor_trigger def wind_speed(sample_time_ms=1000): # get initial sensor state @@ -118,34 +158,29 @@ def wind_direction(): return closest_index * 45 -def timestamp(dt): - year = int(dt[0:4]) - month = int(dt[5:7]) - day = int(dt[8:10]) - hour = int(dt[11:13]) - minute = int(dt[14:16]) - second = int(dt[17:19]) - return time.mktime((year, month, day, hour, minute, second, 0, 0)) - -def rainfall(): - if not helpers.file_exists("rain.txt"): - return 0 - - now = timestamp(helpers.datetime_string()) - with open("rain.txt", "r") as rainfile: - rain_entries = rainfile.read().split("\n") - - # count how many rain ticks in past hour +def rainfall(seconds_since_last): amount = 0 - for entry in rain_entries: - if entry: - ts = timestamp(entry) - if now - ts < 60 * 60: - amount += RAIN_MM_PER_TICK - - return amount - -def get_sensor_readings(): + now = helpers.timestamp(helpers.datetime_string()) + if helpers.file_exists("rain.txt"): + with open("rain.txt", "r") as rainfile: + rain_entries = rainfile.read().split("\n") + + # count how many rain ticks since the last reading + for entry in rain_entries: + if entry: + ts = helpers.timestamp(entry) + if now - ts < seconds_since_last: + amount += RAIN_MM_PER_TICK + + os.remove("rain.txt") + + per_second = 0 + if seconds_since_last > 0: + per_second = amount / seconds_since_last + + return amount, per_second + +def get_sensor_readings(seconds_since_last): # bme280 returns the register contents immediately and then starts a new reading # we want the current reading so do a dummy read to discard register contents first bme280.read() @@ -153,15 +188,16 @@ def get_sensor_readings(): bme280_data = bme280.read() ltr_data = ltr559.get_reading() + rain, rain_per_second = rainfall(seconds_since_last) from ucollections import OrderedDict return OrderedDict({ "temperature": round(bme280_data[0], 2), "humidity": round(bme280_data[2], 2), "pressure": round(bme280_data[1] / 100.0, 2), - "light": round(ltr_data[BreakoutLTR559.LUX], 2), + "luminance": round(ltr_data[BreakoutLTR559.LUX], 2), "wind_speed": wind_speed(), - "rain": rainfall(), + "rain": rain, + "rain_per_second": rain_per_second, "wind_direction": wind_direction() }) - diff --git a/enviro/config_template.py b/enviro/config_template.py index 8cc8641..92d36d3 100644 --- a/enviro/config_template.py +++ b/enviro/config_template.py @@ -15,7 +15,7 @@ # how often to wake up and take a reading (in minutes) reading_frequency = 15 -# where to upload to ("web_hook", "mqtt", "adafruitio") +# where to upload to ("http", "mqtt", "adafruit_io", "influxdb") destination = None # how often to upload data (number of cached readings) @@ -43,6 +43,6 @@ # grow specific settings auto_water = False -moisture_target_1 = 50 -moisture_target_2 = 50 -moisture_target_3 = 50 \ No newline at end of file +moisture_target_a = 50 +moisture_target_b = 50 +moisture_target_c = 50 \ No newline at end of file diff --git a/enviro/constants.py b/enviro/constants.py index de1b2d2..8d57b80 100644 --- a/enviro/constants.py +++ b/enviro/constants.py @@ -26,8 +26,14 @@ WAKE_REASON_RTC_ALARM = 3 WAKE_REASON_EXTERNAL_TRIGGER = 4 WAKE_REASON_RAIN_TRIGGER = 5 +WAKE_REASON_USB_POWERED = 6 # warning led states WARN_LED_OFF = 0 WARN_LED_ON = 1 WARN_LED_BLINK = 2 + +# upload status +UPLOAD_SUCCESS = 0 +UPLOAD_FAILED = 1 +UPLOAD_RATE_LIMITED = 2 diff --git a/enviro/destinations/adafruit_io.py b/enviro/destinations/adafruit_io.py index 742e860..4409bbb 100644 --- a/enviro/destinations/adafruit_io.py +++ b/enviro/destinations/adafruit_io.py @@ -1,6 +1,11 @@ +from enviro import logging +from enviro.constants import UPLOAD_SUCCESS, UPLOAD_FAILED, UPLOAD_RATE_LIMITED import urequests import config +def log_destination(): + logging.info(f"> uploading cached readings to Adafruit.io: {config.adafruit_io_username}") + def upload_reading(reading): # create adafruit.io payload format payload = { @@ -12,7 +17,10 @@ def upload_reading(reading): nickname = config.nickname for key, value in reading["readings"].items(): key = key.replace("_", "-") - payload["feeds"].append({"key": f"{nickname}-{key}", "value": value}) + payload["feeds"].append({ + "key": f"{nickname}-{key}", + "value": value + }) # send the payload username = config.adafruit_io_username @@ -22,8 +30,14 @@ def upload_reading(reading): try: result = urequests.post(url, json=payload, headers=headers) result.close() - return result.status_code == 200 + if result.status_code == 429: + return UPLOAD_RATE_LIMITED + + if result.status_code == 200: + return UPLOAD_SUCCESS + + logging.debug(f" - upload issue ({result.status_code} {result.reason})") except: - pass - - return False \ No newline at end of file + logging.debug(f" - an exception occurred when uploading") + + return UPLOAD_FAILED diff --git a/enviro/destinations/http.py b/enviro/destinations/http.py index 0b5b42d..681bbd7 100644 --- a/enviro/destinations/http.py +++ b/enviro/destinations/http.py @@ -1,6 +1,11 @@ +from enviro import logging +from enviro.constants import UPLOAD_SUCCESS, UPLOAD_FAILED import urequests import config +def log_destination(): + logging.info(f"> uploading cached readings to url: {config.custom_http_url}") + def upload_reading(reading): url = config.custom_http_url @@ -12,8 +17,12 @@ def upload_reading(reading): # post reading data to http endpoint result = urequests.post(url, auth=auth, json=reading) result.close() - return result.status_code in [200, 201, 202] + + if result.status_code in [200, 201, 202]: + return UPLOAD_SUCCESS + + logging.debug(f" - upload issue ({result.status_code} {result.reason})") except: - pass + logging.debug(f" - an exception occurred when uploading") - return False \ No newline at end of file + return UPLOAD_FAILED diff --git a/enviro/destinations/influxdb.py b/enviro/destinations/influxdb.py index b449aac..57e013d 100644 --- a/enviro/destinations/influxdb.py +++ b/enviro/destinations/influxdb.py @@ -1,3 +1,5 @@ +from enviro import logging +from enviro.constants import UPLOAD_SUCCESS, UPLOAD_FAILED import urequests, time import config @@ -13,6 +15,9 @@ def url_encode(t): result += f"%{ord(c):02X}" return result +def log_destination(): + logging.info(f"> uploading cached readings to Influxdb bucket: {config.influxdb_bucket}") + def upload_reading(reading): bucket = config.influxdb_bucket @@ -46,8 +51,12 @@ def upload_reading(reading): # post reading data to http endpoint result = urequests.post(url, headers=headers, data=payload) result.close() - return result.status_code == 204 # why 204? we'll never know... + + if result.status_code == 204: # why 204? we'll never know... + return UPLOAD_SUCCESS + + logging.debug(f" - upload issue ({result.status_code} {result.reason})") except: - pass + logging.debug(f" - an exception occurred when uploading") - return False \ No newline at end of file + return UPLOAD_FAILED \ No newline at end of file diff --git a/enviro/destinations/mqtt.py b/enviro/destinations/mqtt.py index 804cf52..e6d26cf 100644 --- a/enviro/destinations/mqtt.py +++ b/enviro/destinations/mqtt.py @@ -1,7 +1,12 @@ +from enviro import logging +from enviro.constants import UPLOAD_SUCCESS, UPLOAD_FAILED from enviro.mqttsimple import MQTTClient import ujson import config +def log_destination(): + logging.info(f"> uploading cached readings to MQTT broker: {config.mqtt_broker_address}") + def upload_reading(reading): server = config.mqtt_broker_address username = config.mqtt_broker_username @@ -10,12 +15,12 @@ def upload_reading(reading): try: # attempt to publish reading - mqtt_client = MQTTClient(reading["uid"], server, user=username, password=password) + mqtt_client = MQTTClient(reading["uid"], server, user=username, password=password, keepalive=60) mqtt_client.connect() mqtt_client.publish(f"enviro/{nickname}", ujson.dumps(reading), retain=True) mqtt_client.disconnect() - return True + return UPLOAD_SUCCESS except: - pass + logging.debug(f" - an exception occurred when uploading") - return False \ No newline at end of file + return UPLOAD_FAILED diff --git a/enviro/helpers.py b/enviro/helpers.py index c2e123a..a9ead63 100644 --- a/enviro/helpers.py +++ b/enviro/helpers.py @@ -1,6 +1,5 @@ from enviro.constants import * - -import machine, os +import machine, os, time # miscellany # =========================================================================== @@ -8,10 +7,23 @@ def datetime_string(): dt = machine.RTC().datetime() return "{0:04d}-{1:02d}-{2:02d}T{4:02d}:{5:02d}:{6:02d}Z".format(*dt) +def datetime_file_string(): + dt = machine.RTC().datetime() + return "{0:04d}-{1:02d}-{2:02d}T{4:02d}_{5:02d}_{6:02d}Z".format(*dt) + def date_string(): dt = machine.RTC().datetime() return "{0:04d}-{1:02d}-{2:02d}".format(*dt) +def timestamp(dt): + year = int(dt[0:4]) + month = int(dt[5:7]) + day = int(dt[8:10]) + hour = int(dt[11:13]) + minute = int(dt[14:16]) + second = int(dt[17:19]) + return time.mktime((year, month, day, hour, minute, second, 0, 0)) + def uid(): return "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}".format(*machine.unique_id()) diff --git a/enviro/html/header.html b/enviro/html/header.html index bdebc4e..070ca6b 100644 --- a/enviro/html/header.html +++ b/enviro/html/header.html @@ -43,7 +43,7 @@ select {appearance: none; font-family: var(--theme-font); outline: none; background-color: var(--black); color: var(--white); border-radius: var(--input-radius); padding: 1rem 2rem; margin: 1rem 0; border: 0; cursor: pointer; font-size: 1.6rem;} fieldset {display: grid;} fieldset input[type="text"] {box-shadow: none !important;} - input[type="text"], input[type="email"], input[type="password"] {appearance: none; font-family: var(--theme-font); outline: none; background-color: var(--black); color: var(--white); border-radius: var(--input-radius); padding: 1rem 2rem; margin: 1rem 0; border: 0; cursor: pointer; font-size: 1.6rem;} + input[type="text"], input[type="email"], input[type="password"], input[type="number"] {appearance: none; font-family: var(--theme-font); outline: none; background-color: var(--black); color: var(--white); border-radius: var(--input-radius); padding: 1rem 2rem; margin: 1rem 0; border: 0; cursor: pointer; font-size: 1.6rem;} input[type="checkbox"] {cursor: pointer; appearance: none;} input[type="checkbox"]::before {content: ""; color: var(--white); line-height: 0.9rem; font-size: 1.6rem; border: solid 1px var(--grey); width: 1rem; height: 1rem; margin: 0rem 0.1rem 0rem 0rem; vertical-align: middle; display: inline-block; position: relative; top: -0.1rem;} input[type="checkbox"]:checked::before {content: "×"; background-color: var(--black); border: solid 1px var(--black);} diff --git a/enviro/html/provision-step-4-destination.html b/enviro/html/provision-step-4-destination.html index fe28a73..afacdc4 100644 --- a/enviro/html/provision-step-4-destination.html +++ b/enviro/html/provision-step-4-destination.html @@ -140,7 +140,7 @@

HTTP authentication credentials (optional).

diff --git a/enviro/html/provision-step-grow-sensors.html b/enviro/html/provision-step-grow-sensors.html new file mode 100644 index 0000000..1402025 --- /dev/null +++ b/enviro/html/provision-step-grow-sensors.html @@ -0,0 +1,90 @@ +{{render_template("enviro/html/header.html")}} + +{{render_template("enviro/html/header-mini.html", board=board)}} + +
+
+

Let's set up your moisture sensors.

+

Every plant likes a different amount of moisture in its soil! We've provided some default percentage values below to start you off, but later on you might want to come back and adjust these levels.

+

When the soil moisture level gets below the target, Grow will chirp at you with three unique tones to let you know which plant needs watering.

+ + +
+ +

Channel A moisture target.

+
+ +
+ +

Channel B moisture target.

+
+ +
+ +

Channel C moisture target.

+
+ +
+ +
+ +

Do you want to Auto Water your plants?

+

If you have pumps connected to your Enviro Grow, you can enable and disable auto-watering below. Grow will automatically water your pots until the soil reaches the target moisture level set in the previous step.

+ + + + + +
+ + +
+ +{{render_template("enviro/html/footer.html")}} diff --git a/enviro/mqttsimple.py b/enviro/mqttsimple.py index afc4049..c648c80 100644 --- a/enviro/mqttsimple.py +++ b/enviro/mqttsimple.py @@ -62,9 +62,10 @@ def set_last_will(self, topic, msg, retain=False, qos=0): self.lw_qos = qos self.lw_retain = retain - def connect(self, clean_session=True, timeout=30): + def connect(self, clean_session=True): + #def connect(self, clean_session=True, timeout=30): # TODO this was added to 0.0.8 self.sock = socket.socket() - self.sock.settimeout(timeout) + #self.sock.settimeout(timeout) # TODO this was added to 0.0.8 addr = socket.getaddrinfo(self.server, self.port)[0][-1] self.sock.connect(addr) if self.ssl: diff --git a/enviro/provisioning.py b/enviro/provisioning.py index 8ce0e4e..1baaef2 100644 --- a/enviro/provisioning.py +++ b/enviro/provisioning.py @@ -6,6 +6,8 @@ DOMAIN = "pico.wireless" +# BUG Issues with page loading when running provisioning a while after a board reset (where other things have been run before) + # create fresh config file if missing if not helpers.file_exists("config.py"): helpers.copy_file("enviro/config_template.py", "config.py") @@ -50,7 +52,7 @@ def write_config(): logging.info("> creating web server...") - +# TODO This did not seem to work for me... @server.route("/wrong-host-redirect", methods=["GET"]) def wrong_host_redirect(request): # if the client requested a resource at the wrong host then present @@ -124,11 +126,40 @@ def provision_step_4_destination(request): write_config() - return redirect(f"http://{DOMAIN}/provision-step-5-done") + if model == "grow": + return redirect(f"http://{DOMAIN}/provision-step-grow-sensors") + else: + return redirect(f"http://{DOMAIN}/provision-step-5-done") else: return render_template("enviro/html/provision-step-4-destination.html", board=model) +@server.route("/provision-step-grow-sensors", methods=["GET", "POST"]) +def provision_step_grow_sensors(request): + if request.method == "POST": + config.auto_water = (request.form["auto_water"] == 'True') + try: + config.moisture_target_a = int(request.form["moisture_target_a"]) + except ValueError: + pass + + try: + config.moisture_target_b = int(request.form["moisture_target_b"]) + except ValueError: + pass + + try: + config.moisture_target_c = int(request.form["moisture_target_c"]) + except ValueError: + pass + + write_config() + + return redirect(f"http://{DOMAIN}/provision-step-5-done") + else: + return render_template("enviro/html/provision-step-grow-sensors.html", board=model) + + @server.route("/provision-step-5-done", methods=["GET", "POST"]) def provision_step_5_done(request): config.provisioned = True diff --git a/main.py b/main.py index dd8f4a8..13c17a0 100644 --- a/main.py +++ b/main.py @@ -19,55 +19,68 @@ # # - the Pimoroni pirate crew +# uncomment the below two lines to change the amount of logging enviro will do +# from phew import logging +# logging.disable_logging_types(logging.LOG_DEBUG) + # import enviro firmware, this will trigger provisioning if needed import enviro +import os -# initialise enviro -enviro.startup() - -# now that we know the device is provisioned import the config try: - import config -except: - enviro.halt("! failed to load config.py") + # initialise enviro + enviro.startup() -# if the clock isn't set... -if not enviro.is_clock_set(): - enviro.logging.info("> clock not set, synchronise from ntp server") - if not enviro.sync_clock_from_ntp(): - # failed to talk to ntp server go back to sleep for another cycle - enviro.halt("! failed to synchronise clock") - enviro.logging.info(" - rtc synched") + # if the clock isn't set... + if not enviro.is_clock_set(): + enviro.logging.info("> clock not set, synchronise from ntp server") + if not enviro.sync_clock_from_ntp(): + # failed to talk to ntp server go back to sleep for another cycle + enviro.halt("! failed to synchronise clock") -# check disk space... -if enviro.low_disk_space(): - # less than 10% of diskspace left, this probably means cached results - # are not getting uploaded so warn the user and halt with an error - enviro.halt("! low disk space") + # check disk space... + if enviro.low_disk_space(): + # less than 10% of diskspace left, this probably means cached results + # are not getting uploaded so warn the user and halt with an error + enviro.halt("! low disk space") -# take a reading from the onboard sensors -reading = enviro.get_sensor_readings() + # TODO this seems to be useful to keep around? + filesystem_stats = os.statvfs(".") + enviro.logging.debug(f"> {filesystem_stats[3]} blocks free out of {filesystem_stats[2]}") -# here you can customise the sensor readings by adding extra information -# or removing readings that you don't want, for example: -# -# del readings["temperature"] # remove the temperature reading -# -# readings["custom"] = my_reading() # add my custom reading value + # TODO should the board auto take a reading when the timer has been set, or wait for the time? + # take a reading from the onboard sensors + enviro.logging.debug(f"> taking new reading") + reading = enviro.get_sensor_readings() + + # here you can customise the sensor readings by adding extra information + # or removing readings that you don't want, for example: + # + # del readings["temperature"] # remove the temperature reading + # + # readings["custom"] = my_reading() # add my custom reading value + + # is an upload destination set? + if enviro.config.destination: + # if so cache this reading for upload later + enviro.logging.debug(f"> caching reading for upload") + enviro.cache_upload(reading) -# is an upload destination set? -if enviro.config.destination: - # if so cache this reading for upload later - enviro.cache_upload(reading) + # if we have enough cached uploads... + if enviro.is_upload_needed(): + enviro.logging.info(f"> {enviro.cached_upload_count()} cache file(s) need uploading") + if not enviro.upload_readings(): + enviro.halt("! reading upload failed") + else: + enviro.logging.info(f"> {enviro.cached_upload_count()} cache file(s) not being uploaded. Waiting until there are {enviro.config.upload_frequency} file(s)") + else: + # otherwise save reading to local csv file (look in "/readings") + enviro.logging.debug(f"> saving reading locally") + enviro.save_reading(reading) - # if we have enough cached uploads... - if enviro.is_upload_needed(): - enviro.logging.info(f"> {enviro.cached_upload_count()} cache files need uploading") - if not enviro.upload_readings(): - enviro.halt("! reading upload failed") -else: - # otherwise save reading to local csv file (look in "/readings") - enviro.save_reading(reading) + # go to sleep until our next scheduled reading + enviro.sleep() -# go to sleep until our next scheduled reading -enviro.sleep() \ No newline at end of file +# handle any unexpected exception that has occurred +except Exception as exc: + enviro.exception(exc) diff --git a/phew b/phew index bba8abc..b33a44b 160000 --- a/phew +++ b/phew @@ -1 +1 @@ -Subproject commit bba8abce70b51b61a4529ef8fe355ba28c52d231 +Subproject commit b33a44bffeb1354b730b9572410232b10f6e62e9