├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── config.default.toml ├── contrib └── dusty.unit └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | /config.toml 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Julian Kornberger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "*" 8 | numpy = "*" 9 | adafruit-circuitpython-bme280 = "*" 10 | sds011 = "*" 11 | toml = "*" 12 | paho-mqtt = "*" 13 | 14 | [dev-packages] 15 | 16 | [requires] 17 | python_version = "3.7" 18 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "22a3743b3796eae2cac761757ffb3427152f1c125dc525b03dd5dabeaad148fd" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "adafruit-blinka": { 20 | "hashes": [ 21 | "sha256:7724bf44540dd1989689a4f7a3b9cbe7135826ef93ee7245c7c0fd5a52220cef" 22 | ], 23 | "version": "==6.4.2" 24 | }, 25 | "adafruit-circuitpython-bme280": { 26 | "hashes": [ 27 | "sha256:08a4ad0ae1737ca1589dde6bc8c170a4625d4b263278fc5b7a3b4047b7ec9dd4" 28 | ], 29 | "index": "pypi", 30 | "version": "==2.5.1" 31 | }, 32 | "adafruit-circuitpython-busdevice": { 33 | "hashes": [ 34 | "sha256:9bb2934e90a151da8b7d9646b25c2ce0b3fd4853f8ba73e8faffedfd8315eda7" 35 | ], 36 | "version": "==5.0.6" 37 | }, 38 | "adafruit-platformdetect": { 39 | "hashes": [ 40 | "sha256:40979bf7e8a24b8419ee9a0e069e68283e4ab9a8241ba71f4da95b351c2b2353" 41 | ], 42 | "version": "==3.5.0" 43 | }, 44 | "adafruit-pureio": { 45 | "hashes": [ 46 | "sha256:c6702589aa4bf6dc785e5f4e4ed7e68bef1d93d180633abe548fe3f39d36cad5" 47 | ], 48 | "version": "==1.1.8" 49 | }, 50 | "certifi": { 51 | "hashes": [ 52 | "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", 53 | "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" 54 | ], 55 | "version": "==2020.12.5" 56 | }, 57 | "chardet": { 58 | "hashes": [ 59 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 60 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 61 | ], 62 | "version": "==3.0.4" 63 | }, 64 | "idna": { 65 | "hashes": [ 66 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 67 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 68 | ], 69 | "version": "==2.10" 70 | }, 71 | "numpy": { 72 | "hashes": [ 73 | "sha256:08308c38e44cc926bdfce99498b21eec1f848d24c302519e64203a8da99a97db", 74 | "sha256:09c12096d843b90eafd01ea1b3307e78ddd47a55855ad402b157b6c4862197ce", 75 | "sha256:13d166f77d6dc02c0a73c1101dd87fdf01339febec1030bd810dcd53fff3b0f1", 76 | "sha256:141ec3a3300ab89c7f2b0775289954d193cc8edb621ea05f99db9cb181530512", 77 | "sha256:16c1b388cc31a9baa06d91a19366fb99ddbe1c7b205293ed072211ee5bac1ed2", 78 | "sha256:18bed2bcb39e3f758296584337966e68d2d5ba6aab7e038688ad53c8f889f757", 79 | "sha256:1aeef46a13e51931c0b1cf8ae1168b4a55ecd282e6688fdb0a948cc5a1d5afb9", 80 | "sha256:27d3f3b9e3406579a8af3a9f262f5339005dd25e0ecf3cf1559ff8a49ed5cbf2", 81 | "sha256:2a2740aa9733d2e5b2dfb33639d98a64c3b0f24765fed86b0fd2aec07f6a0a08", 82 | "sha256:4377e10b874e653fe96985c05feed2225c912e328c8a26541f7fc600fb9c637b", 83 | "sha256:448ebb1b3bf64c0267d6b09a7cba26b5ae61b6d2dbabff7c91b660c7eccf2bdb", 84 | "sha256:50e86c076611212ca62e5a59f518edafe0c0730f7d9195fec718da1a5c2bb1fc", 85 | "sha256:5734bdc0342aba9dfc6f04920988140fb41234db42381cf7ccba64169f9fe7ac", 86 | "sha256:64324f64f90a9e4ef732be0928be853eee378fd6a01be21a0a8469c4f2682c83", 87 | "sha256:6ae6c680f3ebf1cf7ad1d7748868b39d9f900836df774c453c11c5440bc15b36", 88 | "sha256:6d7593a705d662be5bfe24111af14763016765f43cb6923ed86223f965f52387", 89 | "sha256:8cac8790a6b1ddf88640a9267ee67b1aee7a57dfa2d2dd33999d080bc8ee3a0f", 90 | "sha256:8ece138c3a16db8c1ad38f52eb32be6086cc72f403150a79336eb2045723a1ad", 91 | "sha256:9eeb7d1d04b117ac0d38719915ae169aa6b61fca227b0b7d198d43728f0c879c", 92 | "sha256:a09f98011236a419ee3f49cedc9ef27d7a1651df07810ae430a6b06576e0b414", 93 | "sha256:a5d897c14513590a85774180be713f692df6fa8ecf6483e561a6d47309566f37", 94 | "sha256:ad6f2ff5b1989a4899bf89800a671d71b1612e5ff40866d1f4d8bcf48d4e5764", 95 | "sha256:c42c4b73121caf0ed6cd795512c9c09c52a7287b04d105d112068c1736d7c753", 96 | "sha256:cb1017eec5257e9ac6209ac172058c430e834d5d2bc21961dceeb79d111e5909", 97 | "sha256:d6c7bb82883680e168b55b49c70af29b84b84abb161cbac2800e8fcb6f2109b6", 98 | "sha256:e452dc66e08a4ce642a961f134814258a082832c78c90351b75c41ad16f79f63", 99 | "sha256:e5b6ed0f0b42317050c88022349d994fe72bfe35f5908617512cd8c8ef9da2a9", 100 | "sha256:e9b30d4bd69498fc0c3fe9db5f62fffbb06b8eb9321f92cc970f2969be5e3949", 101 | "sha256:ec149b90019852266fec2341ce1db513b843e496d5a8e8cdb5ced1923a92faab", 102 | "sha256:edb01671b3caae1ca00881686003d16c2209e07b7ef8b7639f1867852b948f7c", 103 | "sha256:f0d3929fe88ee1c155129ecd82f981b8856c5d97bcb0d5f23e9b4242e79d1de3", 104 | "sha256:f29454410db6ef8126c83bd3c968d143304633d45dc57b51252afbd79d700893", 105 | "sha256:fe45becb4c2f72a0907c1d0246ea6449fe7a9e2293bb0e11c4e9a32bb0930a15", 106 | "sha256:fedbd128668ead37f33917820b704784aff695e0019309ad446a6d0b065b57e4" 107 | ], 108 | "index": "pypi", 109 | "version": "==1.19.4" 110 | }, 111 | "paho-mqtt": { 112 | "hashes": [ 113 | "sha256:9feb068e822be7b3a116324e01fb6028eb1d66412bf98595ae72698965cb1cae" 114 | ], 115 | "index": "pypi", 116 | "version": "==1.5.1" 117 | }, 118 | "pyftdi": { 119 | "hashes": [ 120 | "sha256:21e84163a48057de9e4eba6efcb42e51994868b702e1eabdbd2a8cbddad66b66", 121 | "sha256:e8123dc3663ff3ac89eac962774e755b611e7f7202e9e36f6b6eb915fedc53f1" 122 | ], 123 | "version": "==0.52.9" 124 | }, 125 | "pyserial": { 126 | "hashes": [ 127 | "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", 128 | "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0" 129 | ], 130 | "version": "==3.5" 131 | }, 132 | "pyusb": { 133 | "hashes": [ 134 | "sha256:7d449ad916ce58aff60b89aae0b65ac130f289c24d6a5b7b317742eccffafc38", 135 | "sha256:f18eb813d3a1439918071234589162c2f209a19adbeffeb1377ce078a4aebc70" 136 | ], 137 | "version": "==1.1.1" 138 | }, 139 | "requests": { 140 | "hashes": [ 141 | "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", 142 | "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" 143 | ], 144 | "index": "pypi", 145 | "version": "==2.25.0" 146 | }, 147 | "sds011": { 148 | "hashes": [ 149 | "sha256:9db58f5d50dd815bf94a04573175407bb45e05861bf4e2f2b14f370f6e5e7ba8", 150 | "sha256:c380f4d2124b09bb9d9bf4337e7907da9d9397107b09ec88f49f52489d170237" 151 | ], 152 | "index": "pypi", 153 | "version": "==0.0.3" 154 | }, 155 | "toml": { 156 | "hashes": [ 157 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 158 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 159 | ], 160 | "index": "pypi", 161 | "version": "==0.10.2" 162 | }, 163 | "urllib3": { 164 | "hashes": [ 165 | "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", 166 | "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" 167 | ], 168 | "index": "pypi", 169 | "version": "==1.26.4" 170 | } 171 | }, 172 | "develop": {} 173 | } 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dusty-python 2 | 3 | Python 3 program for the [luftdaten.info](http://luftdaten.info/) sensor network. 4 | It has been written to run on a Raspberry Pi and to to send collected measurements to: 5 | 6 | * luftdaten.info 7 | * InfluxDB 8 | * MQTT broker 9 | 10 | 11 | ## Supported Hardware 12 | 13 | * [Nova Fitness SDS011](http://aqicn.org/sensor/sds011/) or compatible connected via `ttyUSB` for dust 14 | * [Bosch BME 2280](https://www.bosch-sensortec.com/bst/products/all_products/bme280) connected via I²C for temperature, humidity and pressure 15 | 16 | 17 | ## Dependencies 18 | 19 | apt install python3 pipenv 20 | 21 | 22 | ## Configuration 23 | 24 | Copy the `config.default.toml` to `config.toml` and adjust the settings. 25 | 26 | ## Running a systemd unit 27 | 28 | Take a look at the [dusty.unit](contrib/dusty.unit). 29 | 30 | ## Privileges 31 | 32 | On Raspbian the process needs privileges in the groups `i2c` and `dialout`. 33 | -------------------------------------------------------------------------------- /config.default.toml: -------------------------------------------------------------------------------- 1 | [luftdaten] 2 | enabled = true 3 | # Override the serial derived from the CPU 4 | sensor = "012345" 5 | 6 | [influxdb] 7 | enabled = false 8 | url = "http://example.com:8086/write?precision=s&db=sensors" 9 | username = "sensors" 10 | password = "secret" 11 | node = "esp8266-123456" 12 | 13 | [mqtt] 14 | enabled = false 15 | broker = "localhost" 16 | port = 1883 17 | topic = "/python/mqtt" 18 | client_id = "dusty" 19 | -------------------------------------------------------------------------------- /contrib/dusty.unit: -------------------------------------------------------------------------------- 1 | # /etc/systemd/system/dusty.service 2 | 3 | [Unit] 4 | Description=Dusty 5 | Wants=network-online.target 6 | After=network-online.target 7 | 8 | [Service] 9 | User=dusty 10 | WorkingDirectoy=/home/dusty/python 11 | ExecStart=/home/dusty/python/main.py 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import time 5 | import toml 6 | import requests 7 | import json 8 | import numpy as np 9 | import board 10 | import busio 11 | from sds011 import SDS011 12 | import adafruit_bme280 13 | from paho.mqtt import client as mqtt_client 14 | 15 | 16 | # Parse command line args 17 | parser = argparse.ArgumentParser(description='Luftdaten in Python') 18 | parser.add_argument('-c', '--config', default='config.toml', help='path to config file') 19 | args = parser.parse_args() 20 | 21 | # Read config file 22 | config = toml.load(args.config) 23 | 24 | # Configure Logging 25 | import logging 26 | logging.basicConfig(level=logging.DEBUG) 27 | 28 | # Configure BME280 29 | print("initialize BME280") 30 | i2c = busio.I2C(board.SCL, board.SDA) 31 | bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x76) 32 | 33 | # Configure SDS011 34 | print("initialize SDS011") 35 | dusty = SDS011(port='/dev/ttyUSB0') 36 | 37 | # Now we have some details about it 38 | print("SDS011 initialized: device_id={} firmware={}".format(dusty.devid, dusty.firmware)) 39 | 40 | # Configure MQTT 41 | mqtt_conn = None 42 | mqtt_cfg = config["mqtt"] 43 | if mqtt_cfg["enabled"]: 44 | mqtt_conn = mqtt_client.Client(mqtt_cfg["client_id"]) 45 | mqtt_conn.connect(mqtt_cfg["broker"], mqtt_cfg["port"]) 46 | 47 | class Measurement: 48 | def __init__(self): 49 | self.pm25_value = None 50 | self.pm10_value = None 51 | 52 | if dusty: 53 | pm25_values = [] 54 | pm10_values = [] 55 | dusty.wakeup() 56 | try: 57 | for a in range(8): 58 | values = dusty.read_measurement() 59 | if values is not None: 60 | pm10_values.append(values.get("pm10")) 61 | pm25_values.append(values.get("pm2.5")) 62 | finally: 63 | dusty.sleep() 64 | 65 | self.pm25_value = np.mean(pm25_values) 66 | self.pm10_value = np.mean(pm10_values) 67 | 68 | self.temperature = bme280.temperature 69 | self.humidity = bme280.relative_humidity 70 | self.pressure = bme280.pressure 71 | 72 | def sendMQTT(self): 73 | mqtt_conn.publish(mqtt_cfg["topic"], json.dumps({ 74 | "dust_pm10": self.pm10_value, 75 | "dust_pm25": self.pm25_value, 76 | "temperature": self.temperature, 77 | "pressure": self.pressure, 78 | "humidity": self.humidity, 79 | })) 80 | 81 | def sendInflux(self): 82 | cfg = config['influxdb'] 83 | 84 | if not cfg['enabled']: 85 | return 86 | 87 | data = "feinstaub,node={} SDS_P1={:0.2f},SDS_P2={:0.2f},BME280_temperature={:0.2f},BME280_pressure={:0.2f},BME280_humidity={:0.2f}".format( 88 | cfg['node'], 89 | self.pm10_value, 90 | self.pm25_value, 91 | self.temperature, 92 | self.pressure, 93 | self.humidity, 94 | ) 95 | 96 | requests.post(cfg['url'], 97 | auth=(cfg['username'], cfg['password']), 98 | data=data, 99 | ) 100 | 101 | def sendLuftdaten(self): 102 | if not config['luftdaten']['enabled']: 103 | return 104 | 105 | self.__pushLuftdaten('https://api-rrd.madavi.de/data.php', 0, { 106 | "SDS_P1": self.pm10_value, 107 | "SDS_P2": self.pm25_value, 108 | "BME280_temperature": self.temperature, 109 | "BME280_pressure": self.pressure, 110 | "BME280_humidity": self.humidity, 111 | }) 112 | self.__pushLuftdaten('https://api.luftdaten.info/v1/push-sensor-data/', 1, { 113 | "P1": self.pm10_value, 114 | "P2": self.pm25_value, 115 | }) 116 | self.__pushLuftdaten('https://api.luftdaten.info/v1/push-sensor-data/', 11, { 117 | "temperature": self.temperature, 118 | "pressure": self.pressure, 119 | "humidity": self.humidity, 120 | }) 121 | 122 | 123 | def __pushLuftdaten(self, url, pin, values): 124 | requests.post(url, 125 | json={ 126 | "software_version": "python-dusty 0.0.1", 127 | "sensordatavalues": [{"value_type": key, "value": val} for key, val in values.items()], 128 | }, 129 | headers={ 130 | "X-PIN": str(pin), 131 | "X-Sensor": sensorID, 132 | } 133 | ) 134 | 135 | # extracts serial from cpuinfo 136 | def getSerial(): 137 | with open('/proc/cpuinfo','r') as f: 138 | for line in f: 139 | if line[0:6]=='Serial': 140 | return(line[10:26]) 141 | raise Exception('CPU serial not found') 142 | 143 | def run(): 144 | m = Measurement() 145 | 146 | print('pm2.5 = {:f} '.format(m.pm25_value)) 147 | print('pm10 = {:f} '.format(m.pm10_value)) 148 | print('Temp = {:0.2f} deg C'.format(m.temperature)) 149 | print('Humidity = {:0.2f} %'.format(m.humidity)) 150 | print('Pressure = {:0.2f} hPa'.format(m.pressure/100)) 151 | 152 | if mqtt_conn: 153 | m.sendMQTT() 154 | 155 | m.sendLuftdaten() 156 | m.sendInflux() 157 | 158 | 159 | sensorID = config['luftdaten'].get('sensor') or ("raspi-" + getSerial()) 160 | starttime = time.time() 161 | 162 | if __name__ == "__main__": 163 | while True: 164 | print("running ...") 165 | run() 166 | time.sleep(60.0 - ((time.time() - starttime) % 60.0)) 167 | print("Stopped") 168 | --------------------------------------------------------------------------------