diff --git a/documentation/boards/enviro-weather.md b/documentation/boards/enviro-weather.md index 7b3866d..f654ba2 100644 --- a/documentation/boards/enviro-weather.md +++ b/documentation/boards/enviro-weather.md @@ -10,14 +10,22 @@ Enviro Weather is a super slimline all in one board for keeping a (weather) eye |---|---|---|---|---| |Temperature|`temperature`|celcius|°C|`22.11`| |Humidity|`humidity`|percent|%|`55.42`| +|Dew Point|`dewpoint`|celcius|°C|`12.21`| |Air Pressure|`pressure`|hectopascals|hPa|`997.16`| +|Adjusted Sea Level Air Pressure|`sea_level_pressure`|hectopascals|hPa|`1014.06`| |Luminance|`luminance`|lux|lx|`35`| |Rainfall|`rain`|millimetres|mm|`1.674`| -|Rainfall Average|`rain_per_second`|millimetres per second|mm/s|`1.674`| +|Rainfall Average Second|`rain_per_second`|millimetres per second|mm/s|`1.674`| +|Rainfall Average Hour|`rain_per_hour`|millimetres per hour|mm/h|`1.674`| +|Rainfall Today (local time)|`rain_today`|millimetres accumulated today|mm/s|`1.674`| |Wind Direction|`wind_direction`|angle|°|`45`| |Wind Speed|`wind_speed`|metres per second|m/s|`0.45`| |Voltage|`voltage`|volts|V|`4.035`| +The rain today value is adjusted for DST in the UK by setting uk_bst = True in config.py +For static time zone offsets (not taking account of DST), modify the utc_offset value in config.py +The time zone offset value is ignored if uk_bst = True + ## On-board devices - BME280 temperature, pressure, humidity sensor. [View datasheet](https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf) diff --git a/documentation/destinations/wunderground.md b/documentation/destinations/wunderground.md new file mode 100644 index 0000000..f00bc3d --- /dev/null +++ b/documentation/destinations/wunderground.md @@ -0,0 +1,30 @@ +# Weather Underground + +Referred to as "wunderground in config and code", Weather Underground is an online service provided by IBM for uploading and viewing information from personal Weather Stations (PWS) and provides general weather forecasts and news. + +You can sign up for a free account, configure a device and drop the credentials into the Enviro configuration to have the values appear on your own weather station dashboard that is also shared with the world. + +## Setting up a Weather Underground account and device + +1. Visit [Weather Underground](https://www.wunderground.com/) +2. Create an account by clicking [Join](https://www.wunderground.com/signup) in the navigation bar +3. Create your account using your email and a new password +4. Browse to your [devices](https://www.wunderground.com/member/devices) in your profile +5. Click Add New Device - Select "Raspberry Pi" as the device hardware and follow the wizard selecting appropriate values (if in doubt, go American units) +6. Credentials are displayed on the summary screen, but you can check these at any time on the device page + +When you provision your device enter the station ID and station key and Enviro will automatically start sending data on the schedule you have requested. + +Ensure you have enabled the sea level pressure option and provided accurate [elevation data](https://whatismyelevation.com/) if you want to return pressure data. + +You can view the dashboard for your station by clicking the station name on the [devices](https://www.wunderground.com/member/devices) page. + +## Supported readings +The following readings will be uploaded. Depending on board type, other readings may be collected, but not sent to Weather Underground and essentially dropped. +- Temperature +- Humidity +- Sea level pressure +- Wind speed +- Wind direction + +There is potential to build logic to calculate rain per hour and convert luminance to solar radiation to capture all Enviro Weather sensor output, other boards may not be well suited to this destination. \ No newline at end of file diff --git a/enviro/boards/weather.py b/enviro/boards/weather.py index 9b44e13..5eeb74c 100644 --- a/enviro/boards/weather.py +++ b/enviro/boards/weather.py @@ -1,9 +1,9 @@ -import time, math, os +import time, math, os, config from breakout_bme280 import BreakoutBME280 from breakout_ltr559 import BreakoutLTR559 from machine import Pin, PWM from pimoroni import Analog -from enviro import i2c, activity_led +from enviro import i2c, activity_led, config import enviro.helpers as helpers from phew import logging from enviro.constants import WAKE_REASON_RTC_ALARM, WAKE_REASON_BUTTON_PRESS @@ -25,6 +25,28 @@ rain_pin = Pin(10, Pin.IN, Pin.PULL_DOWN) last_rain_trigger = False +def log_rain(): + # 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) + if len(rain_entries) > 190: + logging.info("Rain log file exceeded 190 entries and was truncated") + rain_entries = rain_entries[-190:] + + # write out adjusted rain log + with open("rain.txt", "w") as rainfile: + rainfile.write("\n".join(rain_entries)) + def startup(reason): global last_rain_trigger import wakeup @@ -33,24 +55,7 @@ def startup(reason): rain_sensor_trigger = wakeup.get_gpio_state() & (1 << 10) if rain_sensor_trigger: - # 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)) + log_rain() last_rain_trigger = True @@ -70,24 +75,7 @@ def check_trigger(): 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)) + log_rain() last_rain_trigger = rain_sensor_trigger @@ -159,26 +147,65 @@ def wind_direction(): return closest_index * 45 def rainfall(seconds_since_last): - amount = 0 + new_rain_entries = [] + amount = 0 # rain since last reading + per_hour = 0 + today = 0 + offset = 0 # UTC offset hours + + # configure offset variable for UK BST or timezone offset from config file + # and BST lookup function + if config.uk_bst == True: + if helpers.uk_bst(): + offset = 1 + elif config.utc_offset != 0: + offset += config.utc_offset + + # determine current day number and timestamp now = helpers.timestamp(helpers.datetime_string()) + now_day = helpers.timestamp_day(helpers.datetime_string(), offset) + logging.info(f"> current day number is {now_day}") + + # process the rain file data 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 + # populate latest, per second, today and last hour readings from rain log + # file, write new rain log file dropping any yesterday readings for entry in rain_entries: if entry: ts = helpers.timestamp(entry) + tsday = helpers.timestamp_day(entry, config.utc_offset) + logging.info(f"> rain reading day number is {tsday}") + # populate amount with rain since the last reading if now - ts < seconds_since_last: amount += RAIN_MM_PER_TICK - - os.remove("rain.txt") + # add any rain ticks from yesterday since the previous reading + # this will misallocate day totals, but will ensure the hourly total + # is correct without introducing complexity backdating yesterday and + # the error will be minimised with frequent readings + # TODO sum yesterday rain and generate a rain_today reading with + # 23:59:59 timestamp of yesterday + if tsday != now_day: + today += RAIN_MM_PER_TICK + # count how many rain ticks in the last hour + if now - ts < 3600: + per_hour += RAIN_MM_PER_TICK + # count how many rain ticks today and drop older entries for new file + if tsday == now_day: + today += RAIN_MM_PER_TICK + new_rain_entries.append(entry) + + # write out new adjusted rain log + with open("rain.txt", "w") as newrainfile: + newrainfile.write("\n".join(new_rain_entries)) per_second = 0 if seconds_since_last > 0: per_second = amount / seconds_since_last - return amount, per_second + return amount, per_second, per_hour, today def get_sensor_readings(seconds_since_last, is_usb_power): # bme280 returns the register contents immediately and then starts a new reading @@ -188,16 +215,33 @@ def get_sensor_readings(seconds_since_last, is_usb_power): bme280_data = bme280.read() ltr_data = ltr559.get_reading() - rain, rain_per_second = rainfall(seconds_since_last) + rain, rain_per_second, rain_per_hour, rain_today = rainfall(seconds_since_last) + + pressure = bme280_data[1] / 100.0 + temperature = bme280_data[0] + humidity = bme280_data[2] 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), + readings = OrderedDict({ + "temperature": round(temperature, 2), + "humidity": round(humidity, 2), + "pressure": round(pressure, 2), "luminance": round(ltr_data[BreakoutLTR559.LUX], 2), "wind_speed": wind_speed(), "rain": rain, "rain_per_second": rain_per_second, - "wind_direction": wind_direction() + "rain_per_hour": rain_per_hour, + "rain_today": rain_today, + "wind_direction": wind_direction(), + "dewpoint": round(helpers.calculate_dewpoint(temperature, humidity), 2) }) + + # Add adjusted pressure to calculated sea level value if set to in config + if config.sea_level_pressure: + logging.info(f" - recorded temperature: {temperature}") + logging.info(f" - recorded pressure: {pressure}") + sea_level_pressure = round(helpers.get_sea_level_pressure(pressure, temperature, config.height_above_sea_level), 2) + logging.info(f" - calculated mean sea level pressure: {sea_level_pressure}") + readings["sea_level_pressure"] = round(sea_level_pressure, 2) + + return readings \ No newline at end of file diff --git a/enviro/config_defaults.py b/enviro/config_defaults.py index 63a5877..0c0cbbf 100644 --- a/enviro/config_defaults.py +++ b/enviro/config_defaults.py @@ -2,6 +2,8 @@ from phew import logging DEFAULT_USB_POWER_TEMPERATURE_OFFSET = 4.5 +DEFAULT_UTC_OFFSET = 0 +DEFAULT_UK_BST = True def add_missing_config_settings(): @@ -24,5 +26,41 @@ def add_missing_config_settings(): warn_missing_config_setting("wifi_country") config.wifi_country = "GB" + try: + config.wunderground_id + except AttributeError: + warn_missing_config_setting("wunderground_id") + config.wunderground_id = None + + try: + config.wunderground_key + except AttributeError: + warn_missing_config_setting("wunderground_key") + config.wunderground_key = None + + try: + config.sea_level_pressure + except AttributeError: + warn_missing_config_setting("sea_level_pressure") + config.sea_level_pressure = False + + try: + config.height_above_sea_level + except AttributeError: + warn_missing_config_setting("height_above_sea_level") + config.height_above_sea_level = 0 + + try: + config.uk_bst + except AttributeError: + warn_missing_config_setting("uk_bst") + config.uk_bst = DEFAULT_UK_BST + + try: + config.utc_offset + except AttributeError: + warn_missing_config_setting("utc_offset") + config.utc_offset = DEFAULT_UTC_OFFSET + def warn_missing_config_setting(setting): - logging.warn(f"> config setting '{setting}' missing, please add it to config.py") + logging.warn(f"> config setting '{setting}' missing, please add it to config.py") \ No newline at end of file diff --git a/enviro/config_template.py b/enviro/config_template.py index 11404a9..aa29b18 100644 --- a/enviro/config_template.py +++ b/enviro/config_template.py @@ -13,13 +13,20 @@ wifi_password = None wifi_country = "GB" +# Adjust daily rain day for UK BST +uk_bst = True + +# For local time corrections to daily rain logging other than BST +# Ignored if uk_bst = True +utc_offset = 0 + # how often to wake up and take a reading (in minutes) reading_frequency = 15 # how often to trigger a resync of the onboard RTC (in hours) resync_frequency = 168 -# where to upload to ("http", "mqtt", "adafruit_io", "influxdb") +# where to upload to ("http", "mqtt", "adafruit_io", "influxdb", "wunderground") destination = None # how often to upload data (number of cached readings) @@ -47,6 +54,10 @@ influxdb_token = None influxdb_bucket = None +# weather underground settings +wunderground_id = None +wunderground_key = None + # grow specific settings auto_water = False moisture_target_a = 50 @@ -55,3 +66,8 @@ # compensate for usb power usb_power_temperature_offset = 4.5 + +# sea level pressure conversion (adjusts measured pressure output for mean sea level value) +sea_level_pressure = False +# height in metres +height_above_sea_level = 0 \ No newline at end of file diff --git a/enviro/destinations/wunderground.py b/enviro/destinations/wunderground.py new file mode 100644 index 0000000..6634ddf --- /dev/null +++ b/enviro/destinations/wunderground.py @@ -0,0 +1,70 @@ +from enviro import logging +from enviro.constants import UPLOAD_SUCCESS, UPLOAD_FAILED +import urequests +import config +from enviro.helpers import celcius_to_fahrenheit, hpa_to_inches, metres_per_second_to_miles_per_hour, mm_to_inches + +def log_destination(): + logging.info(f"> uploading cached readings to Weather Underground device: {config.wunderground_id}") + +def get_wunderground_timestamp(enviro_timestamp): + year = enviro_timestamp[0:4] + month = enviro_timestamp[5:7] + day = enviro_timestamp[8:10] + hour = enviro_timestamp[11:13] + minute = enviro_timestamp[14:16] + second = enviro_timestamp[17:19] + timestamp = year + "-" + month+ "-" + day + "+" + hour + "%3A" + minute + "%3A" + second + return timestamp + +# API documentation https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_GB +def upload_reading(reading): + timestamp = get_wunderground_timestamp(reading["timestamp"]) + + url = "https://weatherstation.wunderground.com/weatherstation/updateweatherstation.php?ID=" + config.wunderground_id + "&PASSWORD=" + config.wunderground_key + "&dateutc=" + timestamp + "&softwaretype=EnviroWeather&action=updateraw" + + # convert and append applicable readings to URL + for key, value in reading["readings"].items(): + if key == "temperature": + url += "&tempf=" + str(celcius_to_fahrenheit(value)) + + if key == "humidity": + # Humidity can exceed 100% but API states 0-100 accepted values + if value > 100: + value = 100 + url += "&humidity=" + str(value) + + if key == "sea_level_pressure": + url += "&baromin=" + str(hpa_to_inches(value)) + + if key == "wind_speed": + url += "&windspeedmph=" + str(metres_per_second_to_miles_per_hour(value)) + + if key == "wind_direction": + url += "&winddir=" + str(value) + + if key == "rain_per_hour": + url += "&rainin=" + str(mm_to_inches(value)) + + if key == "rain_today": + url += "&dailyrainin=" + str(mm_to_inches(value)) + + if key == "dewpoint": + url += "&dewptf=" + str(celcius_to_fahrenheit(value)) + + logging.info(f"> upload url: {url}") + + try: + # send (GET) reading data to http endpoint + result = urequests.get(url) + + result.close() + + if result.status_code == 200: + return UPLOAD_SUCCESS + + logging.debug(f" - upload issue ({result.status_code} {result.reason})") + except: + logging.debug(f" - an exception occurred when uploading") + + return UPLOAD_FAILED \ No newline at end of file diff --git a/enviro/helpers.py b/enviro/helpers.py index 1503ad4..30b08cb 100644 --- a/enviro/helpers.py +++ b/enviro/helpers.py @@ -1,5 +1,6 @@ from enviro.constants import * -import machine, math, os, time +import machine, math, os, time, utime +from phew import logging # miscellany # =========================================================================== @@ -24,6 +25,41 @@ def timestamp(dt): second = int(dt[17:19]) return time.mktime((year, month, day, hour, minute, second, 0, 0)) +def uk_bst(): + # Return True if in UK BST - manually update bst_timestamps {} as needed + dt = datetime_string() + year = int(dt[0:4]) + ts = timestamp(dt) + bst = False + + bst_timestamps = { + 2023: {"start": 1679792400, "end": 1698541200}, + 2024: {"start": 1711846800, "end": 1729990800}, + 2025: {"start": 1743296400, "end": 1761440400}, + 2026: {"start": 1774746000, "end": 1792890000}, + 2027: {"start": 1806195600, "end": 1824944400}, + 2028: {"start": 1837645200, "end": 1856394000}, + 2029: {"start": 1869094800, "end": 1887843600}, + 2030: {"start": 1901149200, "end": 1919293200} + } + + if year in bst_timestamps: + if bst_timestamps[year]["start"] < ts and bst_timestamps[year]["end"] > ts: + bst = True + else: + logging.warn(f"> Current year is not in BST lookup dictionary: {year}") + return bst + + +# Return the day number of your timestamp string accommodating UTC offsets +def timestamp_day(dt, offset_hours): + # Bounce via timestamp to properly calculate hours change + time = timestamp(dt) + time = time + (offset_hours * 3600) + dt = utime.localtime(time) + day = int(dt[2]) + return day + def uid(): return "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}".format(*machine.unique_id()) @@ -74,9 +110,27 @@ def absolute_to_relative_humidity(absolute_humidity, temperature_in_c): return (WATER_VAPOR_SPECIFIC_GAS_CONSTANT * temperature_in_k * absolute_humidity) / saturation_vapor_pressure * 100 +# https://www.omnicalculator.com/physics/dew-point#how-to-calculate-dew-point-how-to-calculate-relative-humidity +def calculate_dewpoint(temperature_in_c, relative_humidity): + alphatrh = (math.log((relative_humidity / 100))) + ((17.625 * temperature_in_c) / (243.04 + temperature_in_c)) + dewpoint_in_c = (243.04 * alphatrh) / (17.625 - alphatrh) + return dewpoint_in_c + def celcius_to_kelvin(temperature_in_c): return temperature_in_c + 273.15 +def celcius_to_fahrenheit(temperature_in_c): + return temperature_in_c * 1.8 + 32 + +def hpa_to_inches(pressure_in_hpa): + return pressure_in_hpa * 0.02953 + +def metres_per_second_to_miles_per_hour(speed_in_mps): + return speed_in_mps * 2.2369362912 + +def mm_to_inches(distance_in_mm): + return distance_in_mm * 0.0393700787 + # https://www.calctool.org/atmospheric-thermodynamics/absolute-humidity#actual-vapor-pressure # http://cires1.colorado.edu/~voemel/vp.html def get_actual_vapor_pressure(relative_humidity, temperature_in_k): @@ -98,3 +152,10 @@ def get_saturation_vapor_pressure(temperature_in_k): temperature_in_k * (a1*v + a2*v**1.5 + a3*v**3 + a4*v**3.5 + a5*v**4 + a6*v**7.5) ) + +# Calculates mean sea level pressure (QNH) from observed pressure +# https://keisan.casio.com/exec/system/1224575267 +def get_sea_level_pressure(observed_pressure, temperature_in_c, altitude_in_m): +# def sea(pressure, temperature, height): + qnh = observed_pressure * ((1 - ((0.0065 * altitude_in_m) / (temperature_in_c + (0.0065 * altitude_in_m) + 273.15)))** -5.257) + return qnh \ No newline at end of file diff --git a/enviro/html/provision-step-4-destination.html b/enviro/html/provision-step-4-destination.html index afacdc4..c6832ac 100644 --- a/enviro/html/provision-step-4-destination.html +++ b/enviro/html/provision-step-4-destination.html @@ -28,6 +28,10 @@
A platform designed by our friends at Adafruit to store and display your data.
+A public website hosted by IBM to upload and publish private weather station data.
+