├── .gitignore ├── bme280_sensor.py ├── ds18b20_therm.py ├── wind_direction.py ├── weather_station.py └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Exclude logs 2 | /logs/*.txt 3 | 4 | # Exclude pycache 5 | __pycache__/ 6 | 7 | # Exclude testing.py script 8 | testing.py 9 | 10 | # Exclude .env 11 | *.env -------------------------------------------------------------------------------- /bme280_sensor.py: -------------------------------------------------------------------------------- 1 | import bme280 2 | import smbus2 3 | from time import sleep 4 | 5 | port = 1 6 | address = 0x77 # Adafruit BME280 address. Other BME280s may be different 7 | bus = smbus2.SMBus(port) 8 | 9 | bme280.load_calibration_params(bus,address) 10 | 11 | def read_all(): 12 | bme280_data = bme280.sample(bus,address) 13 | return bme280_data.humidity, bme280_data.pressure, bme280_data.temperature 14 | 15 | if __name__ == "__main__": 16 | while True: 17 | print(read_all()) 18 | sleep(1) 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /ds18b20_therm.py: -------------------------------------------------------------------------------- 1 | import os, glob, time 2 | 3 | # add the lines below to /etc/modules (reboot to take effect) 4 | # w1-gpio 5 | # w1-therm 6 | 7 | class DS18B20(object): 8 | def __init__(self): 9 | self.device_file = glob.glob("/sys/bus/w1/devices/28*")[0] + "/w1_slave" 10 | 11 | def read_temp_raw(self): 12 | f = open(self.device_file, "r") 13 | lines = f.readlines() 14 | f.close() 15 | return lines 16 | 17 | def crc_check(self, lines): 18 | return lines[0].strip()[-3:] == "YES" 19 | 20 | def read_temp(self): 21 | temp_c = -255 22 | attempts = 0 23 | 24 | lines = self.read_temp_raw() 25 | success = self.crc_check(lines) 26 | 27 | while not success and attempts < 3: 28 | time.sleep(.2) 29 | lines = self.read_temp_raw() 30 | success = self.crc_check(lines) 31 | attempts += 1 32 | 33 | if success: 34 | temp_line = lines[1] 35 | equal_pos = temp_line.find("t=") 36 | if equal_pos != -1: 37 | temp_string = temp_line[equal_pos+2:] 38 | temp_c = float(temp_string)/1000.0 39 | 40 | return temp_c 41 | 42 | if __name__ == "__main__": 43 | 44 | while True: 45 | obj = DS18B20() 46 | print("Temp: %s C" % obj.read_temp()) 47 | time.sleep(1) -------------------------------------------------------------------------------- /wind_direction.py: -------------------------------------------------------------------------------- 1 | from gpiozero import MCP3008 2 | import math 3 | import time 4 | 5 | adc = MCP3008(channel=0) 6 | 7 | volts = {0.4: 360.0, 8 | 1.4: 22.5, 9 | 1.2: 45.0, 10 | 2.8: 67.5, 11 | 2.7: 90.0, 12 | 2.9: 112.5, 13 | 2.2: 135.0, 14 | 2.3: 135.0, 15 | 2.5: 157.5, 16 | 1.8: 180.0, 17 | 2.0: 202.5, 18 | 0.7: 225.0, 19 | 0.8: 247.5, 20 | 0.1: 270.0, 21 | 0.3: 292.5, 22 | 0.2: 315.0, 23 | 0.6: 337.5} 24 | 25 | count = 0 26 | 27 | # Wizardry. No idea what is happening here. 28 | def get_average(angles): 29 | sin_sum = 0.0 30 | cos_sum = 0.0 31 | 32 | for angle in angles: 33 | r = math.radians(angle) 34 | sin_sum += math.sin(r) 35 | cos_sum += math.cos(r) 36 | 37 | flen = float(len(angles)) 38 | s = sin_sum / flen 39 | c = cos_sum / flen 40 | arc = math.degrees(math.atan(s / c)) 41 | average = 0.0 42 | 43 | if s > 0 and c > 0: 44 | average = arc 45 | elif c < 0: 46 | average = arc + 180 47 | elif s < 0 and c > 0: 48 | average = arc + 360 49 | 50 | return 0.0 if average == 360 else average 51 | 52 | def get_value(length = 5): 53 | data = [] 54 | #print("Measuring wind direction for %d seconds..." % length) 55 | start_time = time.time() 56 | 57 | while time.time() - start_time <= length: 58 | wind = round(adc.value * 3.3, 1) 59 | if wind in volts: 60 | data.append(volts[wind]) 61 | 62 | return get_average(data) 63 | 64 | # Testing 65 | if __name__ == "__main__": 66 | while True: 67 | wind = round(adc.value * 3.3, 1) 68 | direction = volts.get(wind) 69 | if direction: 70 | print("found " + str(wind) + " " + str(volts[wind])) 71 | else: 72 | print("unknown value: " + str(wind)) 73 | 74 | time.sleep(1) -------------------------------------------------------------------------------- /weather_station.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | from gpiozero import Button 3 | from gpiozero import CPUTemperature 4 | from os.path import join, dirname 5 | import time 6 | import math 7 | import statistics 8 | import bme280_sensor 9 | import wind_direction 10 | import ds18b20_therm 11 | import paho.mqtt.client as mqtt 12 | import json 13 | import os 14 | from datetime import datetime 15 | 16 | dotenv_path = join(dirname(__file__), '.env') 17 | load_dotenv(dotenv_path) 18 | 19 | # Load .env variables 20 | MQTT_USER = os.environ.get('MQTT_USER') 21 | MQTT_PASSWORD = os.environ.get('MQTT_PASSWORD') 22 | MQTT_HOST = os.environ.get('MQTT_HOST') 23 | MQTT_PORT = int(os.environ.get('MQTT_PORT')) 24 | 25 | # Global variable definition 26 | flag_connected = 0 # Loop flag for waiting to connect to MQTT broker 27 | 28 | # Constant variable definition 29 | MQTT_STATUS_TOPIC = "raspberry/ws/status" 30 | MQTT_SENSORS_TOPIC = "raspberry/ws/sensors" 31 | BUCKET_SIZE = 0.2794 # Volume of rain required to tip rain meter one time 32 | RAINFALL_METRIC = 1 # Measure rainfall in inches or mm. For inches change to 0. 33 | 34 | CM_IN_A_KM = 100000.0 35 | SECS_IN_AN_HOUR = 3600 36 | 37 | # Initialize ground temp probe 38 | temp_probe = ds18b20_therm.DS18B20() 39 | 40 | # Define wind speed and direction lists 41 | store_speeds = [] 42 | store_directions = [] 43 | 44 | # Define variables 45 | wind_count = 0 # Counts how many half-rotations 46 | radius_cm = 9.0 # Radius of anemometer 47 | wind_interval = 5 # How many secs to collect wind dir and speed 48 | interval = 5 # Data collection interval in secs. 5 mins = 5 * 60 = 300 49 | rain_count = 0 # Counts rain bucket tips 50 | 51 | # MQTT 52 | def on_connect(client, userdata, flags, rc): 53 | print("Connected with flags [%s] rtn code [%d]"% (flags, rc) ) 54 | global flag_connected 55 | flag_connected = 1 56 | 57 | def on_disconnect(client, userdata, rc): 58 | print("disconnected with rtn code [%d]"% (rc) ) 59 | global flag_connected 60 | flag_connected = 0 61 | 62 | client = mqtt.Client("WX") 63 | client.on_connect = on_connect 64 | client.on_disconnect = on_disconnect 65 | client.username_pw_set(MQTT_USER, MQTT_PASSWORD) 66 | client.connect(MQTT_HOST, MQTT_PORT) 67 | 68 | # System Uptime 69 | def uptime(): 70 | t = os.popen('uptime -p').read()[:-1] 71 | uptime = t.replace('up ', '') 72 | return uptime 73 | 74 | # Every half-rotation, add 1 to count 75 | def spin(): 76 | global wind_count 77 | wind_count = wind_count + 1 78 | 79 | # Calculate the wind speed 80 | def calculate_speed(time_sec): 81 | global wind_count 82 | circumference_cm = (2 * math.pi) * radius_cm 83 | rotations = wind_count / 2.0 84 | 85 | # Calculate distance travelled by a cup in cm 86 | dist_km = (circumference_cm * rotations) / CM_IN_A_KM 87 | 88 | km_per_sec = dist_km / time_sec 89 | km_per_hour = 1.18 * (km_per_sec * SECS_IN_AN_HOUR) # Multiply wind speed by 'anemometer factor' 90 | 91 | # Convert KMH to MPH 92 | mph = 0.6214 * km_per_hour 93 | 94 | return mph 95 | 96 | # Convert C to F 97 | def celsius_to_f(temp_c): 98 | f = (temp_c * 9/5) + 32 99 | return f 100 | 101 | # Convert mm to inches 102 | def mm2inches(mm): 103 | inches = mm * 0.0393701 104 | inches = round(inches,4) 105 | return inches 106 | 107 | # Reset functions 108 | def reset_wind(): 109 | global wind_count 110 | wind_count = 0 111 | 112 | def bucket_tipped(): 113 | global rain_count 114 | rain_count = rain_count + 1 115 | 116 | def reset_rainfall(): 117 | global rain_count 118 | rain_count = 0 119 | 120 | rain_sensor = Button(6) 121 | rain_sensor.when_activated = bucket_tipped 122 | 123 | wind_speed_sensor = Button(5) 124 | wind_speed_sensor.when_activated = spin 125 | 126 | # Read CPU temp for future fan logic 127 | cpu = CPUTemperature() 128 | 129 | # Main loop 130 | if __name__ == '__main__': 131 | 132 | client.loop_start() 133 | 134 | # Wait to receive the connected callback for MQTT 135 | while flag_connected == 0: 136 | print("Not connected. Waiting 1 second.") 137 | time.sleep(1) 138 | 139 | while True: 140 | 141 | start_time = time.time() 142 | while time.time() - start_time <= interval: 143 | wind_start_time = time.time() 144 | reset_wind() 145 | while time.time() - wind_start_time <= wind_interval: 146 | store_directions.append(wind_direction.get_value()) 147 | 148 | final_speed = calculate_speed(wind_interval) 149 | store_speeds.append(final_speed) 150 | 151 | wind_speed = round(statistics.mean(store_speeds), 1) 152 | rainfall = rain_count * BUCKET_SIZE 153 | wind_dir = wind_direction.get_average(store_directions) 154 | ground_temp = temp_probe.read_temp() 155 | 156 | humidity, pressure, ambient_temp = bme280_sensor.read_all() 157 | 158 | # Round wind_direction, humidity, pressure, ambient_temp, ground_temp, and rainfall to 1 decimals 159 | # and convert C readings to F 160 | wind_dir = round(wind_dir) 161 | humidity = round(humidity, 1) 162 | pressure = round(pressure, 1) 163 | ambient_temp = celsius_to_f(round(ambient_temp, 1)) 164 | ground_temp = celsius_to_f(round(ground_temp, 1)) 165 | 166 | if RAINFALL_METRIC == 0: 167 | rainfall = mm2inches(rainfall) 168 | 169 | cpu_temp = celsius_to_f(round(cpu.temperature, 1)) 170 | 171 | # Record current date and time for message timestamp 172 | now = datetime.now() 173 | 174 | # Format message timestamp to mm/dd/YY H:M:S 175 | last_message = now.strftime("%m/%d/%Y %H:%M:%S") 176 | 177 | # Get current system uptime 178 | sys_uptime = uptime() 179 | 180 | # Debugging (used when testing and need to print variables) 181 | #print(last_message, wind_speed, rainfall, wind_direction, humidity, pressure, ambient_temp, ground_temp, sys_uptime) 182 | 183 | # Create JSON dict for MQTT transmission 184 | send_msg = { 185 | 'wind_speed': wind_speed, 186 | 'rainfall': rainfall, 187 | 'wind_direction': wind_dir, 188 | 'humidity': humidity, 189 | 'pressure': pressure, 190 | 'ambient_temp': ambient_temp, 191 | 'ground_temp': ground_temp, 192 | 'last_message': last_message, 193 | 'cpu_temp': cpu_temp, 194 | 'system_uptime': sys_uptime 195 | } 196 | 197 | # Convert message to json 198 | payload_sensors = json.dumps(send_msg) 199 | 200 | # Set status payload 201 | payload_status = "Online" 202 | 203 | # Publish status to mqtt 204 | client.publish(MQTT_STATUS_TOPIC, payload_status, qos=0) 205 | 206 | # Publish sensor data to mqtt 207 | client.publish(MQTT_SENSORS_TOPIC, payload_sensors, qos=0) 208 | 209 | # Reset wind speed list, wind direction list, and rainfall max 210 | store_speeds = [] 211 | store_directions = [] 212 | reset_rainfall() 213 | 214 | client.loop_stop() 215 | print("Loop Stopped.") 216 | client.disconnect() 217 | print("MQTT Disconnected.") 218 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Raspberry Pi Weather Station with MQTT 2 | 3 | ## Table of Contents 4 | 5 | * [About](#about) 6 | * [Feature List](#feature-list) 7 | * [Planned Features](#planned-features) 8 | * [Hardware List](#hardware-list) 9 | * [MQTT Configuration](#mqtt-configuration) 10 | * [Running Script When Pi Starts](#running-script-when-pi-starts) 11 | * [Home Assistant Implementation](#home-assistant-implementation) 12 | 13 | ## About 14 | 15 | This project takes the [official Raspberry Pi Weather Station](https://projects.raspberrypi.org/en/projects/build-your-own-weather-station) and removes all the extra files dealing with Oracle, along with some new features. The raspi broadcasts the sensor data as a JSON dict over MQTT and is displayed in a [Home Assistant](https://www.home-assistant.io/) dashboard. 16 | 17 | **NOTE:** As of now I am broadcasting on MQTT every 5 seconds and taking wind measurements every 5 seconds. Because of this, I removed the wind gust measurements from the original project, as I'm calculating this from Home Assistant. This may be reimplemented in the future depending on long term performance. 18 | 19 | ## Feature List 20 | 21 | The following sensors are broadcast as a JSON dict over MQTT, and displayed in a Home Assistant dashboard: 22 | 23 | - Local pressure 24 | - Local humidity 25 | - Local temperature 26 | - Local rainfall (configurable inches or mm) 27 | - Local wind direction 28 | - Local wind speed 29 | - Local wind gust (calculated in Home Assistant) 30 | - Local hourly, daily, and weekly rainfall (calculated in Home Assistant) 31 | - System CPU temperature (for possible future cooling fan logic) 32 | - System uptime 33 | 34 | Home Assistant uses the [utitilty meter integration](https://www.home-assistant.io/integrations/utility_meter/) to track hourly, daily, and weekly rainfall. Node Red saves the max daily wind speed as wind gust to a local file so as to be persistent over Home Assistant restarts. Node Red resets the max daily wind gust every day at midnight. 35 | 36 | ## Planned Features 37 | 38 | - Lightning detection module support 39 | - Ability to choose F or C temperature read outs (this is coming soon-ish) 40 | - Cooling fan install/logic based on WX station cpu temperature 41 | 42 | ## Hardware List 43 | 44 | See the [offical Raspberry Pi Weather Station - What You Will Need](https://projects.raspberrypi.org/en/projects/build-your-own-weather-station/1) section for hardware needed. Here's what I used: 45 | 46 | - Raspberry Pi 4 8GB Model B 47 | - BME280 pressure, temperature, and humidity sensor 48 | - DS18B20 digital thermal probe 49 | - [Anemometer, wind vane, and rain gauge](https://www.argentdata.com/catalog/product_info.php?products_id=145) 50 | - 12' of 25 conductor cable for connection between sensor array and raspi box. [Something like this](https://www.amazon.com/gp/product/B00B88BFKC/ref=ppx_yo_dt_b_asin_title_o07_s00?ie=UTF8&psc=1) 51 | 52 | I also used this 3D printed [radiation shield](https://www.thingiverse.com/thing:1067700) for the BME280 sensor. 53 | 54 | ## MQTT Configuration 55 | 56 | The MQTT configuration requires an environment file for username, password, host ip, and port. You can hardcode these in if you really want by finding the MQTT constant variables in weather_station.py and defining them there. To use the environment variables, start by installing the dotenv library by SSHing into your raspi and typing the following: 57 | 58 | ``` 59 | sudo pip3 install -U python-dotenv 60 | ``` 61 | 62 | In your project folder create a new file ".env". This is a per-project file, so you should only need one. Hence, it doesn't need a name, simply the extension will be fine. In this file paste the following: 63 | 64 | ``` 65 | MQTT_USER="username" 66 | MQTT_PASSWORD="password" 67 | MQTT_HOST="host ip" 68 | MQTT_PORT=1883 69 | ``` 70 | 71 | Replace username and password with the credentials for your MQTT server. Add your MQTT host IP. The default port is 1883, so if yours is different change it here. 72 | 73 | ## Running Script When Pi Starts 74 | 75 | **NOTE:** I strongly advise you to run the main weather_station.py program in your IDE or through SSH before you start trying to get the program to launch when the raspi boots. You will be able to diagnose any problems or error messages much easier than taking guesses as to why the raspi isn't launching the main python file. Trust me: it will save you a lot of time and headache. 76 | 77 | These were the steps I had to take so the weather station script will run on boot. SSH into your raspberry pi and type the following to create a new system service: 78 | 79 | ``` 80 | sudo nano /etc/systemd/system/weatherstation.service 81 | ``` 82 | 83 | Paste this into the new file: 84 | 85 | ``` 86 | [Unit] 87 | Description=Weather Station Service 88 | Wants=systemd-networkd-wait-online.service 89 | After=systemd-networkd-wait-online.service 90 | 91 | [Service] 92 | Type=simple 93 | ExecStartPre=/bin/sh -c 'until ping -c1 google.com; do sleep 1; done;' 94 | ExecStart=/usr/bin/python3 /home/pi/weather-station/weather_station.py > /home/pi/weather-station/logs/log.txt 2>$1 95 | 96 | [Install] 97 | WantedBy=multi-user.target 98 | ``` 99 | 100 | **NOTICE:** This program uses python3, so it's explicitly called within the ExecStart command. Also note the absolute file path to the weather station main program, along with absolute path to any error log output. You need to update this path to the location of your main weather station python program if it's different from mine. 101 | 102 | TODO: The ExecStartPre command is executed because the service consistently started before the network services were active and made the program error out and fail. Having the service require a single ping out before startup ensures the pi is indeed connected to the internet before it attempts to connect via MQTT. This will most likely be changed in the future because the connection error needs to be handled at the program level, not the service level. I also don't want to rely on it connecting outside of the local network, so it should check MQTT connection status before moving to main program loop as opposed to dialing outside the network. 103 | 104 | Systemd needs to be made aware of the configuration change. Reload the systemd daemon with the following: 105 | 106 | ``` 107 | sudo systemctl daemon-reload 108 | ``` 109 | 110 | Enable the new weatherstation service: 111 | 112 | ``` 113 | sudo systemctl enable weatherstation.service 114 | ``` 115 | 116 | The systemd-networkd-wait-online service needs to be enabled. Type this next: 117 | 118 | ``` 119 | sudo systemctl enable systemd-networkd-wait-online.service 120 | ``` 121 | 122 | Restart the pi and once the network services are loaded, the script should run and start broadcasting sensor data over MQTT. If it doesn't, type in this command to see the status of the service and diagnose from there. 123 | 124 | ``` 125 | sudo systemctl status weatherstation.service 126 | ``` 127 | 128 | ## Home Assistant Implementation 129 | 130 | To get the sensor data into Home Assistant you need to create an MQTT sensor within your Home Assistant configuration file. The best way to do this (if you haven't already) is to create a new file in your Home Assistant config folder named 'mqtt.yaml'. In your configuration.yaml file add this line: 131 | 132 | ``` 133 | mqtt: !include mqtt.yaml 134 | ``` 135 | 136 | Next, create the new file named mqtt.yaml and paste the following into it to start listening on the MQTT topics that you defined in the main weatherstation.py program: 137 | 138 | ``` 139 | sensor: 140 | - name: "Weather Station" 141 | state_topic: "raspberry/ws/status" 142 | json_attributes_topic: "raspberry/ws/sensors" 143 | ``` 144 | 145 | This will create a new sensor in Home Assistant with the name "sensor.weather_station". 146 | 147 | **NOTE:** You need to make sure the state_topic and the json_attributes_topic in this sensor match the topics in the main weatherstation.py file on the raspberry pi. If they don't match, Home Assistant won't be able to 'hear' the broadcast because it's listening on the wrong topics. 148 | 149 | Next, you need to break out the main sensor.weather_station attributes into their own sensors so they can be displayed on a dashboard that you create. To do this, create a new file named "template.yaml" (if you don't already have one) and add the following line to your configuration.yaml: 150 | 151 | ``` 152 | template: !include template.yaml 153 | ``` 154 | 155 | In the new template.yaml file you've created paste the following: 156 | 157 | ``` 158 | # Weather Station 159 | 160 | - sensor: 161 | - name: "Local Ambient Temp" 162 | state: "{{ state_attr('sensor.weather_station', 'ambient_temp') | float | round(1) }}" 163 | icon: mdi:thermometer 164 | unit_of_measurement: °F 165 | 166 | - name: "Local Ground Temp" 167 | state: "{{ state_attr('sensor.weather_station', 'ground_temp') | float | round(1) }}" 168 | icon: mdi:thermometer 169 | unit_of_measurement: °F 170 | 171 | - name: "Local Wind Speed" 172 | state: "{{ state_attr('sensor.weather_station', 'wind_speed') | float }}" 173 | icon: mdi:weather-windy 174 | unit_of_measurement: mph 175 | 176 | - name: "Local Rainfall" 177 | state: "{{ state_attr('sensor.weather_station', 'rainfall') | float }}" 178 | icon: mdi:water 179 | unit_of_measurement: '"' 180 | 181 | - name: "Local Wind Direction" 182 | state: "{{ state_attr('sensor.weather_station', 'wind_direction') | float }}" 183 | icon: mdi:compass 184 | 185 | - name: "Local Humidity" 186 | state: "{{ state_attr('sensor.weather_station', 'humidity') | float | round(1) }}" 187 | icon: mdi:water-percent 188 | unit_of_measurement: "%" 189 | 190 | - name: "Local Pressure" 191 | state: "{{ state_attr('sensor.weather_station', 'pressure') | float }}" 192 | icon: mdi:gauge 193 | unit_of_measurement: "" 194 | 195 | - name: "Last Message" 196 | state: "{{ state_attr('sensor.weather_station', 'last_message') }}" 197 | icon: mdi:clock 198 | 199 | - name: "WX CPU Temp" 200 | state: "{{ state_attr('sensor.weather_station', 'cpu_temp') }}" 201 | icon: mdi:thermometer 202 | unit_of_measurement: °F 203 | 204 | - name: "WX Uptime" 205 | state: "{{ state_attr('sensor.weather_station', 'system_uptime') }}" 206 | icon: mdi:sort-clock-descending 207 | unit_of_measurement: "" 208 | ``` 209 | 210 | This is what gives you all the individual sensors that can be used as entities within a Home Assistant dashboard. The name of each is how you find the sensor name. For example, for the last sensor in the template, WX Uptime, this data will be in the "sensor.wx_uptime" sensor. For WX CPU Temp, you'll be able to display it with "sensor.wx_cpu_temp", etc etc. --------------------------------------------------------------------------------