From ec60908b4be8968e634ec0dffb8acd0a5a6009c9 Mon Sep 17 00:00:00 2001 From: Kyle Gabriel Date: Tue, 12 Jan 2021 18:41:23 -0500 Subject: [PATCH] Add Input: Weather conditions from openweathermap.org for current and future conditions; add speed and direction measurements; add m/s, knots, and bearing units; add conversions for the new units --- CHANGELOG.md | 11 +- mycodo/config_devices_units.py | 32 ++ mycodo/inputs/weather_openweathermap_city.py | 175 ++++++++++ .../inputs/weather_openweathermap_latlon.py | 313 ++++++++++++++++++ 4 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 mycodo/inputs/weather_openweathermap_city.py create mode 100644 mycodo/inputs/weather_openweathermap_latlon.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f027e7c7..5eab21f50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## 8.9.0 (Unreleased) +This release contains bug fixes and several new types of Inputs and Outputs. These include stepper motors, digital-to-analog converters, a multi-channel PWM output, as well as an input to acquire current and future weather conditions. + +This new weather input acquires current and future weather conditions from openweathermap.org with either a city (200,000 to choose from) or latitude/longitude for a location and a time frame from the present up to 7 days in the future, with a resolution of days or hours. An API key to use the service is free and the measurements returned include temperature (including minimum and maximum if forecasting days in the future), humidity, dew point, pressure, wind speed, and wind direction. This can be useful for incorporating current or future weather conditions into your conditional controllers or other functions or calculations. For instance, you may prevent Mycodo from watering your outdoor plants if the forecasted temperature in the next 12 to 24 hours is below freezing. You may also want to be alerted by email if the forecasted weather conditions are extreme. Not everyone wants to set up a weather station, but might still want to have local outdoor measurements, so this input was made to bridge that gap. + ### Bugfixes - Fix broken Output API get/post calls @@ -32,8 +36,13 @@ - Add Input: Grove Pi DHT11/22 sensor - Add Input: HC-SR04 Ultrasonic Distance sensor - Add Input: SCD30 CO2/Humidity/Temperature sensor + - Add Input: Current Weather from OpenWeatherMap.org (Free API Key, Latitude/Longitude, 200,000 cities, Humidity/Temperature/Pressure/Dewpoint/Wind Speed/Wind Direction) + - Add Input: Forecast Hourly/Daily Weather from OpenWeatherMap.org (Free API Key, , Humidity/Temperature/Pressure/Dewpoint) + - Add Measurement and Unit: Speed, Meters/Second + - Add Measurement and Unit: Direction, Bearing + - Add Conversions: m/s <-> mph <-> knots, hour <-> minutes and seconds - Add LCD: Grove RGB LCD - - Add Function: bang-bang/hysteretic + - Add Function: Bang-bang/hysteretic - Add Function Action: Output Value - Add Function Action: Set LCD Backlight Color - Add configurable link for navbar brand link diff --git a/mycodo/config_devices_units.py b/mycodo/config_devices_units.py index 89448f685..8d7618944 100644 --- a/mycodo/config_devices_units.py +++ b/mycodo/config_devices_units.py @@ -85,6 +85,10 @@ 'name': lazy_gettext('Dewpoint'), 'meas': 'temperature', 'units': ['C', 'F', 'K']}, + 'direction': { + 'name': lazy_gettext('Direction'), + 'meas': 'direction', + 'units': ['bearing']}, 'disk_space': { 'name': lazy_gettext('Disk'), 'meas': 'disk_space', @@ -225,6 +229,10 @@ 'name': lazy_gettext('Specific Volume'), 'meas': 'specific_volume', 'units': ['m3_kg']}, + 'speed': { + 'name': lazy_gettext('Speed'), + 'meas': 'speed', + 'units': ['m_s', 'mph', 'kn']}, 'temperature': { 'name': lazy_gettext('Temperature'), 'meas': 'temperature', @@ -268,6 +276,9 @@ 'A': { 'name': lazy_gettext('Amp'), 'unit': 'A'}, + 'bearing': { + 'name': lazy_gettext('Bearing'), + 'unit': 'bearing'}, 'bool': { 'name': lazy_gettext('Boolean'), 'unit': 'bool'}, @@ -334,6 +345,9 @@ 'kJ_kg': { 'name': lazy_gettext('Kilojoule per kilogram'), 'unit': 'kJ/kg'}, + 'kn': { + 'name': lazy_gettext('Knot'), + 'unit': 'knot'}, 'kPa': { 'name': lazy_gettext('Kilopascal'), 'unit': 'kPa'}, @@ -355,6 +369,9 @@ 'm': { 'name': lazy_gettext('Meter'), 'unit': 'm'}, + 'm_s': { + 'name': lazy_gettext('Meters per second'), + 'unit': 'm/s'}, 'm_s_s': { 'name': lazy_gettext('Meters per second per second'), 'unit': 'm/s/s'}, @@ -373,6 +390,9 @@ 'mm': { 'name': lazy_gettext('Millimeter'), 'unit': 'mm'}, + 'mph': { + 'name': lazy_gettext('Miles per hour'), + 'unit': 'mph'}, 'mV': { 'name': lazy_gettext('Millivolt'), 'unit': 'mV'}, @@ -436,6 +456,14 @@ # These are added to the SQLite database when it's created # Users may add or delete after that UNIT_CONVERSIONS = [ + # Speed + ('m_s', 'mph', 'x*2.2369362920544'), + ('m_s', 'kn', 'x*1.9438444924406'), + ('mph', 'm_s', 'x/2.2369362920544'), + ('mph', 'kn', 'x/1.1507794480235'), + ('kn', 'm_s', 'x/1.9438444924406'), + ('kn', 'mph', 'x*1.1507794480235'), + # Acceleration ('g_force', 'm_s_s', 'x*9.80665'), ('m_s_s', 'g_force', 'x/9.80665'), @@ -503,7 +531,11 @@ # Time ('s', 'minute', 'x/60'), + ('s', 'h', 'x/60/60'), ('minute', 's', 'x*60'), + ('minute', 'h', 'x/60'), + ('h', 's', 'x*60*60'), + ('h', 'minute', 'x*60'), # Volt ('V', 'mV', 'x*1000'), diff --git a/mycodo/inputs/weather_openweathermap_city.py b/mycodo/inputs/weather_openweathermap_city.py new file mode 100644 index 000000000..db574d9db --- /dev/null +++ b/mycodo/inputs/weather_openweathermap_city.py @@ -0,0 +1,175 @@ +# coding=utf-8 +# +# Copyright 2014 Matt Heitzenroder +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Python wrapper exposes the capabilities of the AOSONG AM2315 humidity +# and temperature sensor. +# The datasheet for the device can be found here: +# http://www.adafruit.com/datasheets/AM2315.pdf +# +# Portions of this code were inspired by Joehrg Ehrsam's am2315-python-api +# code. http://code.google.com/p/am2315-python-api/ +# +# This library was originally authored by Sopwith: +# http://sopwith.ismellsmoke.net/?p=104 +import copy + +import requests +from flask_babel import lazy_gettext + +from mycodo.inputs.base_input import AbstractInput +from mycodo.inputs.sensorutils import calculate_dewpoint +from mycodo.inputs.sensorutils import convert_from_x_to_y_unit + +# Measurements +measurements_dict = { + 0: { + 'measurement': 'temperature', + 'unit': 'C' + }, + 1: { + 'measurement': 'humidity', + 'unit': 'percent' + }, + 2: { + 'measurement': 'pressure', + 'unit': 'Pa' + }, + 3: { + 'measurement': 'dewpoint', + 'unit': 'C' + }, + 4: { + 'measurement': 'speed', + 'unit': 'm_s', + 'name': 'Wind' + }, + 5: { + 'measurement': 'direction', + 'unit': 'bearing', + 'name': 'Wind' + } +} + +# Input information +INPUT_INFORMATION = { + 'input_name_unique': 'WEATHER_OPENWEATHERMAP_CITY', + 'input_manufacturer': 'Weather', + 'input_name': 'OpenWeatherMap.org (City, Current)', + 'measurements_name': 'Humidity/Temperature/Pressure/Wind', + 'measurements_dict': measurements_dict, + 'url_additional': 'openweathermap.org', + 'measurements_rescale': False, + + 'message': 'Obtain a free API key at openweathermap.org. ' + 'If the city you enter does not return measurements, try another city. ' + 'Note: the free API subscription is limited to 60 calls per minute', + + 'options_enabled': [ + 'measurements_select', + 'period', + 'pre_output' + ], + 'options_disabled': ['interface'], + + 'dependencies_module': [], + 'interfaces': ['Mycodo'], + + 'custom_options': [ + { + 'id': 'api_key', + 'type': 'text', + 'default_value': '', + 'required': True, + 'name': lazy_gettext('API Key'), + 'phrase': lazy_gettext("The API Key for this service's API") + }, + { + 'id': 'city', + 'type': 'text', + 'default_value': '', + 'required': True, + 'name': lazy_gettext('City'), + 'phrase': "The city to acquire the weather data" + } + ] +} + + +class InputModule(AbstractInput): + """A sensor support class that gets weather for a city""" + def __init__(self, input_dev, testing=False): + super(InputModule, self).__init__(input_dev, testing=testing, name=__name__) + + self.api_url = None + self.api_key = None + self.city = None + self.setup_custom_options( + INPUT_INFORMATION['custom_options'], input_dev) + + if not testing: + self.initialize_input() + + def initialize_input(self): + if self.api_key and self.city: + self.api_url = "http://api.openweathermap.org/data/2.5/weather?appid={key}&units=metric&q={city}".format( + key=self.api_key, city=self.city) + self.logger.debug("URL: {}".format(self.api_url)) + + def get_measurement(self): + """ Gets the weather data """ + if not self.api_url: + self.logger.error("API Key and City required") + return + + self.return_dict = copy.deepcopy(measurements_dict) + + try: + response = requests.get(self.api_url) + x = response.json() + self.logger.debug("Response: {}".format(x)) + + if x["cod"] != "404": + temperature = x["main"]["temp"] + pressure = x["main"]["pressure"] + humidity = x["main"]["humidity"] + wind_speed = x["wind"]["speed"] + wind_deg = x["wind"]["deg"] + else: + self.logger.error("City Not Found") + return + except Exception as e: + self.logger.error("Error acquiring weather information: {}".format(e)) + return + + self.logger.debug("Temp: {}, Hum: {}, Press: {}, Wind Speed: {}, Wind Direction: {}".format( + temperature, humidity, pressure, wind_speed, wind_deg)) + + if self.is_enabled(0): + self.value_set(0, temperature) + if self.is_enabled(1): + self.value_set(1, humidity) + if self.is_enabled(2): + self.value_set(2, pressure) + + if self.is_enabled(0) and self.is_enabled(1) and self.is_enabled(3): + self.value_set(3, calculate_dewpoint(temperature, humidity)) + + if self.is_enabled(4): + self.value_set(4, wind_speed) + if self.is_enabled(5): + self.value_set(5, wind_deg) + + return self.return_dict diff --git a/mycodo/inputs/weather_openweathermap_latlon.py b/mycodo/inputs/weather_openweathermap_latlon.py new file mode 100644 index 000000000..33d68452e --- /dev/null +++ b/mycodo/inputs/weather_openweathermap_latlon.py @@ -0,0 +1,313 @@ +# coding=utf-8 +# +# Copyright 2014 Matt Heitzenroder +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Python wrapper exposes the capabilities of the AOSONG AM2315 humidity +# and temperature sensor. +# The datasheet for the device can be found here: +# http://www.adafruit.com/datasheets/AM2315.pdf +# +# Portions of this code were inspired by Joehrg Ehrsam's am2315-python-api +# code. http://code.google.com/p/am2315-python-api/ +# +# This library was originally authored by Sopwith: +# http://sopwith.ismellsmoke.net/?p=104 +import copy + +import requests +from flask_babel import lazy_gettext + +from mycodo.inputs.base_input import AbstractInput +from mycodo.inputs.sensorutils import calculate_dewpoint +from mycodo.inputs.sensorutils import convert_from_x_to_y_unit + +# Measurements +measurements_dict = { + 0: { + 'measurement': 'temperature', + 'unit': 'C' + }, + 1: { + 'measurement': 'temperature', + 'unit': 'C', + 'name': 'Min' + }, + 2: { + 'measurement': 'temperature', + 'unit': 'C', + 'name': 'Max' + }, + 3: { + 'measurement': 'humidity', + 'unit': 'percent' + }, + 4: { + 'measurement': 'pressure', + 'unit': 'Pa' + }, + 5: { + 'measurement': 'dewpoint', + 'unit': 'C' + }, + 6: { + 'measurement': 'speed', + 'unit': 'm_s', + 'name': 'Wind' + }, + 7: { + 'measurement': 'direction', + 'unit': 'bearing', + 'name': 'Wind' + }, + 8: { + 'measurement': 'duration_time', + 'unit': 'h', + 'name': 'Hours in Future' + } +} + +# Input information +INPUT_INFORMATION = { + 'input_name_unique': 'WEATHER_OPENWEATHERMAP_LATLON', + 'input_manufacturer': 'Weather', + 'input_name': 'OpenWeatherMap.org (Lat/Lon, Current, Forecast)', + 'measurements_name': 'Humidity/Temperature/Pressure/Wind', + 'measurements_dict': measurements_dict, + 'url_additional': 'openweathermap.org', + 'measurements_rescale': False, + + 'message': 'Obtain a free API key at openweathermap.org. ' + 'Notes: The free API subscription is limited to 60 calls per minute. ' + 'If a Day (Future) time is selected, Minimum and Maximum temperatures are available as measurements.', + + 'options_enabled': [ + 'measurements_select', + 'period', + 'pre_output' + ], + 'options_disabled': ['interface'], + + 'dependencies_module': [], + 'interfaces': ['Mycodo'], + + 'custom_options': [ + { + 'id': 'api_key', + 'type': 'text', + 'default_value': '', + 'required': True, + 'name': lazy_gettext('API Key'), + 'phrase': lazy_gettext("The API Key for this service's API") + }, + { + 'id': 'latitude', + 'type': 'float', + 'default_value': 33.441792, + 'required': True, + 'name': lazy_gettext('Latitude (decimal)'), + 'phrase': "The latitude to acquire weather data" + }, + { + 'id': 'longitude', + 'type': 'float', + 'default_value': -94.037689, + 'required': True, + 'name': lazy_gettext('Longitude (decimal)'), + 'phrase': "The longitude to acquire weather data" + }, + { + 'id': 'weather_time', + 'type': 'select', + 'default_value': 'current', + 'options_select': [ + ('current', 'Current (Present)'), + ('day1', '1 Day (Future)'), + ('day2', '2 Day (Future)'), + ('day3', '3 Day (Future)'), + ('day4', '4 Day (Future)'), + ('day5', '5 Day (Future)'), + ('day6', '6 Day (Future)'), + ('day7', '7 Day (Future)'), + ('hour1', '1 Hour (Future)'), + ('hour2', '2 Hours (Future)'), + ('hour3', '3 Hours (Future)'), + ('hour4', '4 Hours (Future)'), + ('hour5', '5 Hours (Future)'), + ('hour6', '6 Hours (Future)'), + ('hour7', '7 Hours (Future)'), + ('hour8', '8 Hours (Future)'), + ('hour9', '9 Hours (Future)'), + ('hour10', '10 Hours (Future)'), + ('hour11', '11 Hours (Future)'), + ('hour12', '12 Hours (Future)'), + ('hour13', '13 Hours (Future)'), + ('hour14', '14 Hours (Future)'), + ('hour15', '15 Hours (Future)'), + ('hour16', '16 Hours (Future)'), + ('hour17', '17 Hours (Future)'), + ('hour18', '18 Hours (Future)'), + ('hour19', '19 Hours (Future)'), + ('hour20', '20 Hours (Future)'), + ('hour21', '21 Hours (Future)'), + ('hour22', '22 Hours (Future)'), + ('hour23', '23 Hours (Future)'), + ('hour24', '24 Hours (Future)'), + ('hour25', '25 Hours (Future)'), + ('hour26', '26 Hours (Future)'), + ('hour27', '27 Hours (Future)'), + ('hour28', '28 Hours (Future)'), + ('hour29', '29 Hours (Future)'), + ('hour30', '30 Hours (Future)'), + ('hour31', '31 Hours (Future)'), + ('hour32', '32 Hours (Future)'), + ('hour33', '33 Hours (Future)'), + ('hour34', '34 Hours (Future)'), + ('hour35', '35 Hours (Future)'), + ('hour36', '36 Hours (Future)'), + ('hour37', '37 Hours (Future)'), + ('hour38', '38 Hours (Future)'), + ('hour39', '39 Hours (Future)'), + ('hour40', '40 Hours (Future)'), + ('hour41', '41 Hours (Future)'), + ('hour42', '42 Hours (Future)'), + ('hour43', '43 Hours (Future)'), + ('hour44', '44 Hours (Future)'), + ('hour45', '45 Hours (Future)'), + ('hour46', '46 Hours (Future)'), + ('hour47', '47 Hours (Future)'), + ('hour48', '48 Hours (Future)') + ], + 'name': lazy_gettext('Time'), + 'phrase': 'Select the time for the current or forecast weather' + } + ] +} + + +class InputModule(AbstractInput): + """A sensor support class that gets weather for a city""" + def __init__(self, input_dev, testing=False): + super(InputModule, self).__init__(input_dev, testing=testing, name=__name__) + + self.api_url = None + self.api_key = None + self.latitude = None + self.longitude = None + self.weather_time = None + self.weather_time_dict = {} + self.setup_custom_options( + INPUT_INFORMATION['custom_options'], input_dev) + + if not testing: + self.initialize_input() + + def initialize_input(self): + if self.api_key and self.latitude and self.longitude and self.weather_time: + base_url = "https://api.openweathermap.org/data/2.5/onecall?lat={lat}&lon={lon}&units=metric&appid={key}".format( + lat=self.latitude, lon=self.longitude, key=self.api_key) + if self.weather_time == 'current': + self.weather_time_dict["time"] = "current" + self.api_url = "{base}&exclude=minutely,hourly,daily,alerts".format(base=base_url) + elif self.weather_time.startswith("day"): + self.weather_time_dict["time"] = "day" + self.weather_time_dict["amount"] = int(self.weather_time.split("day")[1]) + self.api_url = "{base}&exclude=current,minutely,hourly,alerts".format(base=base_url) + elif self.weather_time.startswith("hour"): + self.weather_time_dict["time"] = "hour" + self.weather_time_dict["amount"] = int(self.weather_time.split("hour")[1]) + self.api_url = "{base}&exclude=current,minutely,daily,alerts".format(base=base_url) + + self.logger.debug("URL: {}".format(self.api_url)) + self.logger.debug("Time Dict: {}".format(self.weather_time_dict)) + + def get_measurement(self): + """ Gets the weather data """ + if not self.api_url: + self.logger.error("API Key, Latitude, and Longitude required") + return + + self.return_dict = copy.deepcopy(measurements_dict) + + try: + response = requests.get(self.api_url) + x = response.json() + self.logger.debug("Response: {}".format(x)) + + if self.weather_time_dict["time"] == "current": + if 'current' not in x: + self.logger.error("No response. Check your configuration.") + return + temperature = x["current"]["temp"] + pressure = x["current"]["pressure"] + humidity = x["current"]["humidity"] + wind_speed = x["current"]["wind_speed"] + wind_deg = x["current"]["wind_deg"] + if self.is_enabled(8): + self.value_set(8, 0) + elif self.weather_time_dict["time"] == "hour": + if 'hourly' not in x: + self.logger.error("No response. Check your configuration.") + return + temperature = x["hourly"][self.weather_time_dict["amount"] - 1]["temp"] + pressure = x["hourly"][self.weather_time_dict["amount"] - 1]["pressure"] + humidity = x["hourly"][self.weather_time_dict["amount"] - 1]["humidity"] + wind_speed = x["hourly"][self.weather_time_dict["amount"] - 1]["wind_speed"] + wind_deg = x["hourly"][self.weather_time_dict["amount"] - 1]["wind_deg"] + if self.is_enabled(8): + self.value_set(8, self.weather_time_dict["amount"]) + elif self.weather_time_dict["time"] == "day": + if 'daily' not in x: + self.logger.error("No response. Check your configuration.") + return + temperature = x["daily"][self.weather_time_dict["amount"]]["temp"]["day"] + temperature_min = x["daily"][self.weather_time_dict["amount"]]["temp"]["min"] + temperature_max = x["daily"][self.weather_time_dict["amount"]]["temp"]["max"] + pressure = x["daily"][self.weather_time_dict["amount"]]["pressure"] + humidity = x["daily"][self.weather_time_dict["amount"]]["humidity"] + wind_speed = x["daily"][self.weather_time_dict["amount"]]["wind_speed"] + wind_deg = x["daily"][self.weather_time_dict["amount"]]["wind_deg"] + + if self.is_enabled(1): + self.value_set(1, temperature_min) + if self.is_enabled(2): + self.value_set(2, temperature_max) + if self.is_enabled(8): + self.value_set(8, self.weather_time_dict["amount"] * 24) + else: + self.logger.error("Invalid weather time") + return + except Exception as e: + self.logger.error("Error acquiring weather information: {}".format(e)) + return + + self.logger.debug("Temp: {}, Hum: {}, Press: {}, Wind Speed: {}, Wind Direction: {}".format( + temperature, humidity, pressure, wind_speed, wind_deg)) + + if self.is_enabled(0): + self.value_set(0, temperature) + if self.is_enabled(3): + self.value_set(3, humidity) + if self.is_enabled(4): + self.value_set(4, pressure) + + if self.is_enabled(0) and self.is_enabled(3) and self.is_enabled(5): + self.value_set(5, calculate_dewpoint(temperature, humidity)) + + if self.is_enabled(6): + self.value_set(6, wind_speed) + if self.is_enabled(7): + self.value_set(7, wind_deg) + + return self.return_dict