From 020b41e8ac8946cbe7083b1748c9ab40b02bf9e3 Mon Sep 17 00:00:00 2001 From: guillaumedelre Date: Sun, 8 Mar 2020 10:28:00 +0100 Subject: [PATCH 01/18] http api on port 5000 + mqtt publication --- bme280.py | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++ sensor-api.py | 43 ++++++++++ 2 files changed, 261 insertions(+) create mode 100644 bme280.py create mode 100644 sensor-api.py diff --git a/bme280.py b/bme280.py new file mode 100644 index 0000000..e36487a --- /dev/null +++ b/bme280.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/python +#-------------------------------------- +# ___ ___ _ ____ +# / _ \/ _ \(_) __/__ __ __ +# / , _/ ___/ /\ \/ _ \/ // / +# /_/|_/_/ /_/___/ .__/\_, / +# /_/ /___/ +# +# bme280.py +# Read data from a digital pressure sensor. +# +# Official datasheet available from : +# https://www.bosch-sensortec.com/bst/products/all_products/bme280 +# +# Author : Matt Hawkins +# Date : 21/01/2018 +# +# https://www.raspberrypi-spy.co.uk/ +# +#-------------------------------------- +import smbus +import time +from ctypes import c_short +from ctypes import c_byte +from ctypes import c_ubyte + +DEVICE = 0x77 # Default device I2C address + + +bus = smbus.SMBus(1) # Rev 2 Pi, Pi 2 & Pi 3 uses bus 1 + # Rev 1 Pi uses bus 0 + +def getShort(data, index): + # return two bytes from data as a signed 16-bit value + return c_short((data[index+1] << 8) + data[index]).value + +def getUShort(data, index): + # return two bytes from data as an unsigned 16-bit value + return (data[index+1] << 8) + data[index] + +def getChar(data,index): + # return one byte from data as a signed char + result = data[index] + if result > 127: + result -= 256 + return result + +def getUChar(data,index): + # return one byte from data as an unsigned char + result = data[index] & 0xFF + return result + +def readBME280ID(addr=DEVICE): + # Chip ID Register Address + REG_ID = 0xD0 + (chip_id, chip_version) = bus.read_i2c_block_data(addr, REG_ID, 2) + return (chip_id, chip_version) + +def readBME280All(addr=DEVICE): + # Register Addresses + REG_DATA = 0xF7 + REG_CONTROL = 0xF4 + REG_CONFIG = 0xF5 + + REG_CONTROL_HUM = 0xF2 + REG_HUM_MSB = 0xFD + REG_HUM_LSB = 0xFE + + # Oversample setting - page 27 + OVERSAMPLE_TEMP = 2 + OVERSAMPLE_PRES = 2 + MODE = 1 + + # Oversample setting for humidity register - page 26 + OVERSAMPLE_HUM = 2 + bus.write_byte_data(addr, REG_CONTROL_HUM, OVERSAMPLE_HUM) + + control = OVERSAMPLE_TEMP<<5 | OVERSAMPLE_PRES<<2 | MODE + bus.write_byte_data(addr, REG_CONTROL, control) + + # Read blocks of calibration data from EEPROM + # See Page 22 data sheet + cal1 = bus.read_i2c_block_data(addr, 0x88, 24) + cal2 = bus.read_i2c_block_data(addr, 0xA1, 1) + cal3 = bus.read_i2c_block_data(addr, 0xE1, 7) + + # Convert byte data to word values + dig_T1 = getUShort(cal1, 0) + dig_T2 = getShort(cal1, 2) + dig_T3 = getShort(cal1, 4) + + dig_P1 = getUShort(cal1, 6) + dig_P2 = getShort(cal1, 8) + dig_P3 = getShort(cal1, 10) + dig_P4 = getShort(cal1, 12) + dig_P5 = getShort(cal1, 14) + dig_P6 = getShort(cal1, 16) + dig_P7 = getShort(cal1, 18) + dig_P8 = getShort(cal1, 20) + dig_P9 = getShort(cal1, 22) + + dig_H1 = getUChar(cal2, 0) + dig_H2 = getShort(cal3, 0) + dig_H3 = getUChar(cal3, 2) + + dig_H4 = getChar(cal3, 3) + dig_H4 = (dig_H4 << 24) >> 20 + dig_H4 = dig_H4 | (getChar(cal3, 4) & 0x0F) + + dig_H5 = getChar(cal3, 5) + dig_H5 = (dig_H5 << 24) >> 20 + dig_H5 = dig_H5 | (getUChar(cal3, 4) >> 4 & 0x0F) + + dig_H6 = getChar(cal3, 6) + + # Wait in ms (Datasheet Appendix B: Measurement time and current calculation) + wait_time = 1.25 + (2.3 * OVERSAMPLE_TEMP) + ((2.3 * OVERSAMPLE_PRES) + 0.575) + ((2.3 * OVERSAMPLE_HUM)+0.575) + time.sleep(wait_time/1000) # Wait the required time + + # Read temperature/pressure/humidity + data = bus.read_i2c_block_data(addr, REG_DATA, 8) + pres_raw = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4) + temp_raw = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4) + hum_raw = (data[6] << 8) | data[7] + + #Refine temperature + var1 = ((((temp_raw>>3)-(dig_T1<<1)))*(dig_T2)) >> 11 + var2 = (((((temp_raw>>4) - (dig_T1)) * ((temp_raw>>4) - (dig_T1))) >> 12) * (dig_T3)) >> 14 + t_fine = var1+var2 + temperature = float(((t_fine * 5) + 128) >> 8); + + # Refine pressure and adjust for temperature + var1 = t_fine / 2.0 - 64000.0 + var2 = var1 * var1 * dig_P6 / 32768.0 + var2 = var2 + var1 * dig_P5 * 2.0 + var2 = var2 / 4.0 + dig_P4 * 65536.0 + var1 = (dig_P3 * var1 * var1 / 524288.0 + dig_P2 * var1) / 524288.0 + var1 = (1.0 + var1 / 32768.0) * dig_P1 + if var1 == 0: + pressure=0 + else: + pressure = 1048576.0 - pres_raw + pressure = ((pressure - var2 / 4096.0) * 6250.0) / var1 + var1 = dig_P9 * pressure * pressure / 2147483648.0 + var2 = pressure * dig_P8 / 32768.0 + pressure = pressure + (var1 + var2 + dig_P7) / 16.0 + + # Refine humidity + humidity = t_fine - 76800.0 + humidity = (hum_raw - (dig_H4 * 64.0 + dig_H5 / 16384.0 * humidity)) * (dig_H2 / 65536.0 * (1.0 + dig_H6 / 67108864.0 * humidity * (1.0 + dig_H3 / 67108864.0 * humidity))) + humidity = humidity * (1.0 - dig_H1 * humidity / 524288.0) + if humidity > 100: + humidity = 100 + elif humidity < 0: + humidity = 0 + + return temperature/100.0,pressure/100.0,humidity + +def sensor(): + (chip_id, chip_version) = readBME280ID() + temperature,pressure,humidity = readBME280All() + sensor = { + 'name': 'bme280', + 'brand': 'Waveshare', + 'part_number': 'BME280 Environmental Sensor', + 'sku': 15231, + 'upc': 614961952638, + 'chip': { + 'id': chip_id, + 'version': chip_version, + }, + 'capabilities': { + 'temperature': { + 'unit_of_measurement': '°C', + 'min': -40, + 'max': 85, + 'resolution': 0.01, + 'accuracy': 1, + }, + 'humidity': { + 'unit_of_measurement': '%RH', + 'min': 0, + 'max': 100, + 'resolution': 0.008, + 'accuracy': 3, + }, + 'pressure': { + 'unit_of_measurement': 'hPa', + 'min': 300, + 'max': 1100, + 'resolution': 0.008, + 'accuracy': 0.0018, + }, + }, + 'data': { + 'temperature': temperature, + 'humidity': humidity, + 'pressure': pressure, + }, + } + + return sensor + +def main(): + + (chip_id, chip_version) = readBME280ID() + print "Chip ID :", chip_id + print "Version :", chip_version + + temperature,pressure,humidity = readBME280All() + + print "Temperature : ", temperature, "C" + print "Pressure : ", pressure, "hPa" + print "Humidity : ", humidity, "%" + +if __name__=="__main__": + main() diff --git a/sensor-api.py b/sensor-api.py new file mode 100644 index 0000000..1eb0e33 --- /dev/null +++ b/sensor-api.py @@ -0,0 +1,43 @@ +import bme280 +import time +import paho.mqtt.client as mqtt +from flask import Flask, jsonify + +app = Flask(__name__) +app.config['JSON_SORT_KEYS'] = False + +username = 'homeassistant' +password = 'chi6pa9tiom3chahhohB7sienicae2aimimeefei4queol7eesuthohcai6maiph' +client_id = 'rpi3b-bme280' + str(int(time.time())) +broker_host = '192.168.86.35' +temperature_topic = 'sensor/bme280_temperature' +humidity_topic = 'sensor/bme280_humidity' +pressure_topic = 'sensor/bme280_pressure' + +@app.route('/') +def index(): + return jsonify({}) + +@app.route('/bme280') +def bme280_action(): + sensor = bme280.sensor() + + return jsonify(sensor) + +@app.route('/bme280/publish') +def bme280_publish_action(): + sensor = bme280.sensor() + + hass_mqtt = mqtt.Client(client_id) + hass_mqtt.username_pw_set(username, password) + hass_mqtt.connect(broker_host) + + hass_mqtt.publish(temperature_topic, sensor['data']['temperature']) + hass_mqtt.publish(humidity_topic, sensor['data']['humidity']) + hass_mqtt.publish(pressure_topic, sensor['data']['pressure']) + + return jsonify({}) + + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0') From 76b28613d678ddc17e64745556d33a8fdd399e1d Mon Sep 17 00:00:00 2001 From: guillaumedelre Date: Sun, 8 Mar 2020 10:33:01 +0100 Subject: [PATCH 02/18] up date readme --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++ bme280.py | 6 ++--- bme280.pyc | Bin 0 -> 4966 bytes 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 bme280.pyc diff --git a/README.md b/README.md index 72d237e..7a6992c 100644 --- a/README.md +++ b/README.md @@ -1 +1,76 @@ # bme280 + +## BME280 driver + +Run `python bme280.py` in a shell to get the sensor's data, it will output: + +``` +Chip ID : 96 +Version : 0 +Temperature : 21.04 °C +Pressure : 1001.86890002 hPa +Humidity : 52.7615936619 %RH +``` + +## Http API + +Run `python sensor-api.py` to expose the http api on port `5000`. + +### Endpoints + +| Path | Status | Data | Comment | +|---|---|---|---| +| `/` | 200 | `{}` | nc | +| `/bme280` | 200 | [Sensor Resource](#sensor-resource) | return the sensor resource with measure | +| `/bme280/publish` | 200 | `{}` | publish in mqtt the sensor measure | + +### Resource + +```json + +{ + "sku": 15231, + "name": "bme280", + "brand": "Waveshare", + "upc": 614961952638, + "capabilities": { + "pressure": { + "unit_of_measurement": "hPa", + "max": 1100, + "accuracy": 0.0018, + "resolution": 0.008, + "min": 300 + }, + "temperature": { + "unit_of_measurement": "\u00b0C", + "max": 85, + "accuracy": 1, + "resolution": 0.01, + "min": -40 + }, + "humidity": { + "unit_of_measurement": "%RH", + "max": 100, + "accuracy": 3, + "resolution": 0.008, + "min": 0 + } + }, + "part_number": "BME280 Environmental Sensor", + "data": { + "pressure": 1005.1562396432168, + "temperature": 21.55, + "humidity": 44.566495623372525 + }, + "chip": { + "version": 0, + "id": 96 + } +} +``` + +## Setup the crontab + +This is a cron job that actually sends the data via mqtt by calling the API every minute. + +`* * * * * curl 192.168.86.31:5000/bme280/publish` diff --git a/bme280.py b/bme280.py index e36487a..13623cf 100644 --- a/bme280.py +++ b/bme280.py @@ -210,9 +210,9 @@ def main(): temperature,pressure,humidity = readBME280All() - print "Temperature : ", temperature, "C" - print "Pressure : ", pressure, "hPa" - print "Humidity : ", humidity, "%" + print "Temperature :", temperature, "C" + print "Pressure :", pressure, "hPa" + print "Humidity :", humidity, "%RH" if __name__=="__main__": main() diff --git a/bme280.pyc b/bme280.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49139eacf4e44b2d722ddaf0c034196577a5f9f4 GIT binary patch literal 4966 zcmb7H&2Jmm5uYWg4@%TGDNpX(D>^om*~*|seXD6C4-~}=mj*#Nev1zL@F-GFsUIy66E%i8m1SJ9}zqu z_$a9n5f~#iD#$pw6QsuIafBMbA$Odf6InH}emgsko#@Z-*~(f#{Q-b-$vdbwe8cD$ z#_o~t_6jtap`Y?(o>peh@DszcipM(x;1dP-!6g=@XHCLdX}osB|jOEl;~ zpjlR7;~~Ef;E7Xdy2A5WuX21x_`0Utce5-ez?%Vf{h(fTuOI5`yH$7L){XYxtA|HE zE8chgZQjyZ;2tkRk&el1gP5EF@+J0u(Nfl7%w^MTHuu;T#UTuf?u$wDAbL$=bJ#*L z=^~J}&sq@*BJ9b>F>0I>JVLj?#YtiywD1{bXu_EoYgn7zvC!wkY`9*t;vd^s4hR^# zdf+rJ1NGRQ6dAG-)`%rO{G7p)7;GC1QEq*^gK%6iV&=8McH{ll=iwV`~b5wAAt!y-rsK1 z^QZ9Na2-|B3+2*orCxqq;)u-hY7(E0QVsF2y}nVpvu3W+fj%s0x5rhMWMUcG=0tO&o z39FS5Im1lwLQvQm>0yp=nquZC`8W*7jL~ygKE_UEazM!OkVN{z=tL+yA>?E@Iw|B- zNKOfv49TRBsgO(wIUSPILZ(A9Eo3GnGeXXUif{37LC}f>Z3JMU|m^OQ`m^Y*Ee9(hAsZUR>s!rr>mUj!becKilsEHEhx3eo z=19Fl>LlSLlWG1Rq@+4UX~Bouc$}-97(!nGp z%_{I4m*JmBfwV4D?Exv+!bV)C0Kbc*E>kL_u87(cb5HOThYEzw4VVet=}drln8*HMB!c6nVKa5IL#31HWRf*pk-f|^ zA{)(w+yyB{M=`ZPkgKIc6gx!9`az6$2qx;mB~q_pfy?Ia3hhNvu3ksN+S307)Soi= z2ZK)-{F}jl7;q9c-2vBE{XGU;rS(r3e8hnBp2kC(^RVX3D6NDZVvMt=9%UeX4p%3g zWH8NuGrJxG*vEshQoVd#b)~!VDz1Ad*j}aX_*t$l_6Un{<>lJ8w!E|K^D0cN z-rL%-@7?u9o1x#jvmxvE43`S`H#<{+mo~RoK1bgL%{JeAux@WJZx-*amv+`Si!bwv z_WHJ%yLoSI-RG0-nZRrC_R95|->6qauIyB9hR8yQye4H@>HX5q%`UpoMY{sUu0XLX zQ0x(Sy)&)&l`fj=qHlE3TV3?49kkG0qR?HU&|RX?U82xkqL4#w!y!A`FY)0?n*fR&ss^ENAo}hgahX66l`lUTxbLCigmfLXrX2b2M^g;q!w9@|3 zv6~o3(h(N}|ui*fWT4{l^k+JB! zH4#l&^Y~6jPXG>CndqoBDc@6o2`i4ybsE?xB!;XN;NS_r%+~O|;8Y`>R1Ezj>fdvc zUXi4Yw1h+zl-3a~6_O3QEwoxWG+OAga_F(pVC7H=QMz&{Jm`gT$Yk6V4O5QmXaf8Z zFr%a)EbT%Ll}t^5KS^o|e@Q|m%c*HnY5Zj%49OWTyEzm_k(@P>$m1-Dw9S&Ij3hEW zOCqzgPLy-j*_K3EX32AS&WjOffO&-g{UN5K7nZRLltZr=^dVKx!y{V~uIA-RqNe5` zUvL0^!+GL*=*3O;*ljp9<;{X`I@Y=M+7sQV*Q##KcPew+Zq37c7$=WIr{R}s&FZe( z(9gaGS0mAq_qbW%|L?wW?d{tUVKooSz7zr_m%H6So2!hD+GFwGzP~E{d9#MgP~R(6 zU5BHU7uA2l%dIG?>YBa}VM93Y?tdrwi;a)I{UR^93wNeoY5M5RrQ%4`JLPh-;gpZ; zud`{q&;`3-s*U8`fBo!lf85-CFE5qE&WE}WijKaD;L&$+&-Q=x`)7q;wcgI#i#*Ob zxa=G{ySk!%?Rs)+&5Ps&hU_JPtfXhohBlWiSzTtEC3w^mKOXl2+Wb!aW1jXF0O1Zz zT2X7D^JRQE67AtJ6^{4fV^PB&jg?sw-xYStDjx40fS2wIuEquef1`j4vVqXwDEMEv zLAW;?2*C|p9?12900UPD!~h6zagjg}B9Y^5%j0yiild*qvo^<{H-+=Syjk^dcDhMy z?oBL=St41)S#x;ET)POqunX<9W>mIM`{kqI9ot`aG{-#;3ErxsYk%Oixrz~TSRC1D zGBSeqY6-qli6u&q}Or&M*5>; U(Rd8sfp}k{KcV8+;)#UyU#qcgpa1{> literal 0 HcmV?d00001 From 05fa496ed811ff7956ecc27fd5167bc8aeb524b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 10:52:29 +0200 Subject: [PATCH 03/18] chore: add .gitignore and untrack compiled bytecode Ignore *.pyc, __pycache__/, .env and .pytest_cache/. Remove bme280.pyc which was mistakenly committed. Co-authored-by: agilicode --- .gitignore | 5 +++++ bme280.pyc | Bin 4966 -> 0 bytes 2 files changed, 5 insertions(+) create mode 100644 .gitignore delete mode 100644 bme280.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f8ef39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.pyo +.env +.pytest_cache/ diff --git a/bme280.pyc b/bme280.pyc deleted file mode 100644 index 49139eacf4e44b2d722ddaf0c034196577a5f9f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4966 zcmb7H&2Jmm5uYWg4@%TGDNpX(D>^om*~*|seXD6C4-~}=mj*#Nev1zL@F-GFsUIy66E%i8m1SJ9}zqu z_$a9n5f~#iD#$pw6QsuIafBMbA$Odf6InH}emgsko#@Z-*~(f#{Q-b-$vdbwe8cD$ z#_o~t_6jtap`Y?(o>peh@DszcipM(x;1dP-!6g=@XHCLdX}osB|jOEl;~ zpjlR7;~~Ef;E7Xdy2A5WuX21x_`0Utce5-ez?%Vf{h(fTuOI5`yH$7L){XYxtA|HE zE8chgZQjyZ;2tkRk&el1gP5EF@+J0u(Nfl7%w^MTHuu;T#UTuf?u$wDAbL$=bJ#*L z=^~J}&sq@*BJ9b>F>0I>JVLj?#YtiywD1{bXu_EoYgn7zvC!wkY`9*t;vd^s4hR^# zdf+rJ1NGRQ6dAG-)`%rO{G7p)7;GC1QEq*^gK%6iV&=8McH{ll=iwV`~b5wAAt!y-rsK1 z^QZ9Na2-|B3+2*orCxqq;)u-hY7(E0QVsF2y}nVpvu3W+fj%s0x5rhMWMUcG=0tO&o z39FS5Im1lwLQvQm>0yp=nquZC`8W*7jL~ygKE_UEazM!OkVN{z=tL+yA>?E@Iw|B- zNKOfv49TRBsgO(wIUSPILZ(A9Eo3GnGeXXUif{37LC}f>Z3JMU|m^OQ`m^Y*Ee9(hAsZUR>s!rr>mUj!becKilsEHEhx3eo z=19Fl>LlSLlWG1Rq@+4UX~Bouc$}-97(!nGp z%_{I4m*JmBfwV4D?Exv+!bV)C0Kbc*E>kL_u87(cb5HOThYEzw4VVet=}drln8*HMB!c6nVKa5IL#31HWRf*pk-f|^ zA{)(w+yyB{M=`ZPkgKIc6gx!9`az6$2qx;mB~q_pfy?Ia3hhNvu3ksN+S307)Soi= z2ZK)-{F}jl7;q9c-2vBE{XGU;rS(r3e8hnBp2kC(^RVX3D6NDZVvMt=9%UeX4p%3g zWH8NuGrJxG*vEshQoVd#b)~!VDz1Ad*j}aX_*t$l_6Un{<>lJ8w!E|K^D0cN z-rL%-@7?u9o1x#jvmxvE43`S`H#<{+mo~RoK1bgL%{JeAux@WJZx-*amv+`Si!bwv z_WHJ%yLoSI-RG0-nZRrC_R95|->6qauIyB9hR8yQye4H@>HX5q%`UpoMY{sUu0XLX zQ0x(Sy)&)&l`fj=qHlE3TV3?49kkG0qR?HU&|RX?U82xkqL4#w!y!A`FY)0?n*fR&ss^ENAo}hgahX66l`lUTxbLCigmfLXrX2b2M^g;q!w9@|3 zv6~o3(h(N}|ui*fWT4{l^k+JB! zH4#l&^Y~6jPXG>CndqoBDc@6o2`i4ybsE?xB!;XN;NS_r%+~O|;8Y`>R1Ezj>fdvc zUXi4Yw1h+zl-3a~6_O3QEwoxWG+OAga_F(pVC7H=QMz&{Jm`gT$Yk6V4O5QmXaf8Z zFr%a)EbT%Ll}t^5KS^o|e@Q|m%c*HnY5Zj%49OWTyEzm_k(@P>$m1-Dw9S&Ij3hEW zOCqzgPLy-j*_K3EX32AS&WjOffO&-g{UN5K7nZRLltZr=^dVKx!y{V~uIA-RqNe5` zUvL0^!+GL*=*3O;*ljp9<;{X`I@Y=M+7sQV*Q##KcPew+Zq37c7$=WIr{R}s&FZe( z(9gaGS0mAq_qbW%|L?wW?d{tUVKooSz7zr_m%H6So2!hD+GFwGzP~E{d9#MgP~R(6 zU5BHU7uA2l%dIG?>YBa}VM93Y?tdrwi;a)I{UR^93wNeoY5M5RrQ%4`JLPh-;gpZ; zud`{q&;`3-s*U8`fBo!lf85-CFE5qE&WE}WijKaD;L&$+&-Q=x`)7q;wcgI#i#*Ob zxa=G{ySk!%?Rs)+&5Ps&hU_JPtfXhohBlWiSzTtEC3w^mKOXl2+Wb!aW1jXF0O1Zz zT2X7D^JRQE67AtJ6^{4fV^PB&jg?sw-xYStDjx40fS2wIuEquef1`j4vVqXwDEMEv zLAW;?2*C|p9?12900UPD!~h6zagjg}B9Y^5%j0yiild*qvo^<{H-+=Syjk^dcDhMy z?oBL=St41)S#x;ET)POqunX<9W>mIM`{kqI9ot`aG{-#;3ErxsYk%Oixrz~TSRC1D zGBSeqY6-qli6u&q}Or&M*5>; U(Rd8sfp}k{KcV8+;)#UyU#qcgpa1{> From ea06f84cc216fa7cdd4303fcb0689bf843afe30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 10:55:12 +0200 Subject: [PATCH 04/18] feat: migrate driver to Python 3 with smbus2 and type hints Replace smbus with smbus2, rewrite print statements as f-strings, add type hints on all public functions. Move SMBus instantiation inside each function via context manager for testability. Add requirements.txt with pinned runtime dependencies. Co-authored-by: agilicode --- bme280.py | 358 +++++++++++++++++++---------------------------- requirements.txt | 4 + 2 files changed, 146 insertions(+), 216 deletions(-) create mode 100644 requirements.txt diff --git a/bme280.py b/bme280.py index 13623cf..497f1d3 100644 --- a/bme280.py +++ b/bme280.py @@ -1,218 +1,144 @@ -# -*- coding: utf-8 -*- -#!/usr/bin/python -#-------------------------------------- -# ___ ___ _ ____ -# / _ \/ _ \(_) __/__ __ __ -# / , _/ ___/ /\ \/ _ \/ // / -# /_/|_/_/ /_/___/ .__/\_, / -# /_/ /___/ -# -# bme280.py -# Read data from a digital pressure sensor. -# -# Official datasheet available from : -# https://www.bosch-sensortec.com/bst/products/all_products/bme280 -# -# Author : Matt Hawkins -# Date : 21/01/2018 -# -# https://www.raspberrypi-spy.co.uk/ -# -#-------------------------------------- -import smbus +import os import time from ctypes import c_short -from ctypes import c_byte -from ctypes import c_ubyte - -DEVICE = 0x77 # Default device I2C address - - -bus = smbus.SMBus(1) # Rev 2 Pi, Pi 2 & Pi 3 uses bus 1 - # Rev 1 Pi uses bus 0 - -def getShort(data, index): - # return two bytes from data as a signed 16-bit value - return c_short((data[index+1] << 8) + data[index]).value - -def getUShort(data, index): - # return two bytes from data as an unsigned 16-bit value - return (data[index+1] << 8) + data[index] - -def getChar(data,index): - # return one byte from data as a signed char - result = data[index] - if result > 127: - result -= 256 - return result - -def getUChar(data,index): - # return one byte from data as an unsigned char - result = data[index] & 0xFF - return result - -def readBME280ID(addr=DEVICE): - # Chip ID Register Address - REG_ID = 0xD0 - (chip_id, chip_version) = bus.read_i2c_block_data(addr, REG_ID, 2) - return (chip_id, chip_version) - -def readBME280All(addr=DEVICE): - # Register Addresses - REG_DATA = 0xF7 - REG_CONTROL = 0xF4 - REG_CONFIG = 0xF5 - - REG_CONTROL_HUM = 0xF2 - REG_HUM_MSB = 0xFD - REG_HUM_LSB = 0xFE - - # Oversample setting - page 27 - OVERSAMPLE_TEMP = 2 - OVERSAMPLE_PRES = 2 - MODE = 1 - - # Oversample setting for humidity register - page 26 - OVERSAMPLE_HUM = 2 - bus.write_byte_data(addr, REG_CONTROL_HUM, OVERSAMPLE_HUM) - - control = OVERSAMPLE_TEMP<<5 | OVERSAMPLE_PRES<<2 | MODE - bus.write_byte_data(addr, REG_CONTROL, control) - - # Read blocks of calibration data from EEPROM - # See Page 22 data sheet - cal1 = bus.read_i2c_block_data(addr, 0x88, 24) - cal2 = bus.read_i2c_block_data(addr, 0xA1, 1) - cal3 = bus.read_i2c_block_data(addr, 0xE1, 7) - - # Convert byte data to word values - dig_T1 = getUShort(cal1, 0) - dig_T2 = getShort(cal1, 2) - dig_T3 = getShort(cal1, 4) - - dig_P1 = getUShort(cal1, 6) - dig_P2 = getShort(cal1, 8) - dig_P3 = getShort(cal1, 10) - dig_P4 = getShort(cal1, 12) - dig_P5 = getShort(cal1, 14) - dig_P6 = getShort(cal1, 16) - dig_P7 = getShort(cal1, 18) - dig_P8 = getShort(cal1, 20) - dig_P9 = getShort(cal1, 22) - - dig_H1 = getUChar(cal2, 0) - dig_H2 = getShort(cal3, 0) - dig_H3 = getUChar(cal3, 2) - - dig_H4 = getChar(cal3, 3) - dig_H4 = (dig_H4 << 24) >> 20 - dig_H4 = dig_H4 | (getChar(cal3, 4) & 0x0F) - - dig_H5 = getChar(cal3, 5) - dig_H5 = (dig_H5 << 24) >> 20 - dig_H5 = dig_H5 | (getUChar(cal3, 4) >> 4 & 0x0F) - - dig_H6 = getChar(cal3, 6) - - # Wait in ms (Datasheet Appendix B: Measurement time and current calculation) - wait_time = 1.25 + (2.3 * OVERSAMPLE_TEMP) + ((2.3 * OVERSAMPLE_PRES) + 0.575) + ((2.3 * OVERSAMPLE_HUM)+0.575) - time.sleep(wait_time/1000) # Wait the required time - - # Read temperature/pressure/humidity - data = bus.read_i2c_block_data(addr, REG_DATA, 8) - pres_raw = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4) - temp_raw = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4) - hum_raw = (data[6] << 8) | data[7] - - #Refine temperature - var1 = ((((temp_raw>>3)-(dig_T1<<1)))*(dig_T2)) >> 11 - var2 = (((((temp_raw>>4) - (dig_T1)) * ((temp_raw>>4) - (dig_T1))) >> 12) * (dig_T3)) >> 14 - t_fine = var1+var2 - temperature = float(((t_fine * 5) + 128) >> 8); - - # Refine pressure and adjust for temperature - var1 = t_fine / 2.0 - 64000.0 - var2 = var1 * var1 * dig_P6 / 32768.0 - var2 = var2 + var1 * dig_P5 * 2.0 - var2 = var2 / 4.0 + dig_P4 * 65536.0 - var1 = (dig_P3 * var1 * var1 / 524288.0 + dig_P2 * var1) / 524288.0 - var1 = (1.0 + var1 / 32768.0) * dig_P1 - if var1 == 0: - pressure=0 - else: - pressure = 1048576.0 - pres_raw - pressure = ((pressure - var2 / 4096.0) * 6250.0) / var1 - var1 = dig_P9 * pressure * pressure / 2147483648.0 - var2 = pressure * dig_P8 / 32768.0 - pressure = pressure + (var1 + var2 + dig_P7) / 16.0 - - # Refine humidity - humidity = t_fine - 76800.0 - humidity = (hum_raw - (dig_H4 * 64.0 + dig_H5 / 16384.0 * humidity)) * (dig_H2 / 65536.0 * (1.0 + dig_H6 / 67108864.0 * humidity * (1.0 + dig_H3 / 67108864.0 * humidity))) - humidity = humidity * (1.0 - dig_H1 * humidity / 524288.0) - if humidity > 100: - humidity = 100 - elif humidity < 0: - humidity = 0 - - return temperature/100.0,pressure/100.0,humidity - -def sensor(): - (chip_id, chip_version) = readBME280ID() - temperature,pressure,humidity = readBME280All() - sensor = { - 'name': 'bme280', - 'brand': 'Waveshare', - 'part_number': 'BME280 Environmental Sensor', - 'sku': 15231, - 'upc': 614961952638, - 'chip': { - 'id': chip_id, - 'version': chip_version, - }, - 'capabilities': { - 'temperature': { - 'unit_of_measurement': '°C', - 'min': -40, - 'max': 85, - 'resolution': 0.01, - 'accuracy': 1, - }, - 'humidity': { - 'unit_of_measurement': '%RH', - 'min': 0, - 'max': 100, - 'resolution': 0.008, - 'accuracy': 3, - }, - 'pressure': { - 'unit_of_measurement': 'hPa', - 'min': 300, - 'max': 1100, - 'resolution': 0.008, - 'accuracy': 0.0018, - }, - }, - 'data': { - 'temperature': temperature, - 'humidity': humidity, - 'pressure': pressure, - }, - } - - return sensor - -def main(): - - (chip_id, chip_version) = readBME280ID() - print "Chip ID :", chip_id - print "Version :", chip_version - - temperature,pressure,humidity = readBME280All() - - print "Temperature :", temperature, "C" - print "Pressure :", pressure, "hPa" - print "Humidity :", humidity, "%RH" - -if __name__=="__main__": - main() + +import smbus2 + +I2C_BUS = int(os.environ.get('BME280_I2C_BUS', '1')) +DEVICE_ADDRESS = int(os.environ.get('BME280_I2C_ADDRESS', '0x77'), 16) + + +def _get_short(data: list[int], index: int) -> int: + return c_short((data[index + 1] << 8) + data[index]).value + + +def _get_ushort(data: list[int], index: int) -> int: + return (data[index + 1] << 8) + data[index] + + +def _get_char(data: list[int], index: int) -> int: + result = data[index] + if result > 127: + result -= 256 + return result + + +def _get_uchar(data: list[int], index: int) -> int: + return data[index] & 0xFF + + +def read_id(addr: int = DEVICE_ADDRESS) -> tuple[int, int]: + with smbus2.SMBus(I2C_BUS) as bus: + chip_id, chip_version = bus.read_i2c_block_data(addr, 0xD0, 2) + return chip_id, chip_version + + +def read_all(addr: int = DEVICE_ADDRESS) -> tuple[float, float, float]: + OVERSAMPLE_TEMP = 2 + OVERSAMPLE_PRES = 2 + OVERSAMPLE_HUM = 2 + MODE = 1 + + with smbus2.SMBus(I2C_BUS) as bus: + bus.write_byte_data(addr, 0xF2, OVERSAMPLE_HUM) + bus.write_byte_data(addr, 0xF4, OVERSAMPLE_TEMP << 5 | OVERSAMPLE_PRES << 2 | MODE) + + cal1 = bus.read_i2c_block_data(addr, 0x88, 24) + cal2 = bus.read_i2c_block_data(addr, 0xA1, 1) + cal3 = bus.read_i2c_block_data(addr, 0xE1, 7) + + # Datasheet Appendix B: measurement time formula + wait_ms = 1.25 + (2.3 * OVERSAMPLE_TEMP) + ((2.3 * OVERSAMPLE_PRES) + 0.575) + ((2.3 * OVERSAMPLE_HUM) + 0.575) + time.sleep(wait_ms / 1000) + + data = bus.read_i2c_block_data(addr, 0xF7, 8) + + dig_T1 = _get_ushort(cal1, 0) + dig_T2 = _get_short(cal1, 2) + dig_T3 = _get_short(cal1, 4) + + dig_P1 = _get_ushort(cal1, 6) + dig_P2 = _get_short(cal1, 8) + dig_P3 = _get_short(cal1, 10) + dig_P4 = _get_short(cal1, 12) + dig_P5 = _get_short(cal1, 14) + dig_P6 = _get_short(cal1, 16) + dig_P7 = _get_short(cal1, 18) + dig_P8 = _get_short(cal1, 20) + dig_P9 = _get_short(cal1, 22) + + dig_H1 = _get_uchar(cal2, 0) + dig_H2 = _get_short(cal3, 0) + dig_H3 = _get_uchar(cal3, 2) + + dig_H4 = (_get_char(cal3, 3) << 24) >> 20 | (_get_char(cal3, 4) & 0x0F) + dig_H5 = (_get_char(cal3, 5) << 24) >> 20 | (_get_uchar(cal3, 4) >> 4 & 0x0F) + dig_H6 = _get_char(cal3, 6) + + pres_raw = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4) + temp_raw = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4) + hum_raw = (data[6] << 8) | data[7] + + # Temperature compensation - Bosch datasheet page 22 + var1 = ((((temp_raw >> 3) - (dig_T1 << 1))) * dig_T2) >> 11 + var2 = (((((temp_raw >> 4) - dig_T1) * ((temp_raw >> 4) - dig_T1)) >> 12) * dig_T3) >> 14 + t_fine = var1 + var2 + temperature = float(((t_fine * 5) + 128) >> 8) + + # Pressure compensation + var1 = t_fine / 2.0 - 64000.0 + var2 = var1 * var1 * dig_P6 / 32768.0 + var2 = var2 + var1 * dig_P5 * 2.0 + var2 = var2 / 4.0 + dig_P4 * 65536.0 + var1 = (dig_P3 * var1 * var1 / 524288.0 + dig_P2 * var1) / 524288.0 + var1 = (1.0 + var1 / 32768.0) * dig_P1 + pressure = 0.0 + if var1 != 0: + pressure = 1048576.0 - pres_raw + pressure = ((pressure - var2 / 4096.0) * 6250.0) / var1 + var1 = dig_P9 * pressure * pressure / 2147483648.0 + var2 = pressure * dig_P8 / 32768.0 + pressure = pressure + (var1 + var2 + dig_P7) / 16.0 + + # Humidity compensation + humidity = t_fine - 76800.0 + humidity = (hum_raw - (dig_H4 * 64.0 + dig_H5 / 16384.0 * humidity)) * ( + dig_H2 / 65536.0 * (1.0 + dig_H6 / 67108864.0 * humidity * (1.0 + dig_H3 / 67108864.0 * humidity)) + ) + humidity = humidity * (1.0 - dig_H1 * humidity / 524288.0) + humidity = max(0.0, min(100.0, humidity)) + + return temperature / 100.0, pressure / 100.0, humidity + + +def sensor(addr: int = DEVICE_ADDRESS) -> dict: + chip_id, chip_version = read_id(addr) + temperature, pressure, humidity = read_all(addr) + return { + 'name': 'bme280', + 'brand': 'Waveshare', + 'part_number': 'BME280 Environmental Sensor', + 'sku': 15231, + 'upc': 614961952638, + 'chip': {'id': chip_id, 'version': chip_version}, + 'capabilities': { + 'temperature': {'unit_of_measurement': '°C', 'min': -40, 'max': 85, 'resolution': 0.01, 'accuracy': 1}, + 'humidity': {'unit_of_measurement': '%RH', 'min': 0, 'max': 100, 'resolution': 0.008, 'accuracy': 3}, + 'pressure': {'unit_of_measurement': 'hPa', 'min': 300, 'max': 1100, 'resolution': 0.008, 'accuracy': 0.0018}, + }, + 'data': { + 'temperature': temperature, + 'humidity': humidity, + 'pressure': pressure, + }, + } + + +if __name__ == '__main__': + chip_id, chip_version = read_id() + print(f"Chip ID : {chip_id}") + print(f"Version : {chip_version}") + temperature, pressure, humidity = read_all() + print(f"Temperature : {temperature:.2f} °C") + print(f"Pressure : {pressure:.2f} hPa") + print(f"Humidity : {humidity:.2f} %RH") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1700f6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +smbus2>=0.4.3 +flask>=3.0.0 +paho-mqtt>=1.6.1 +python-dotenv>=1.0.0 From 156e77783039c102a496c080e17492c41f4b5b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 10:56:24 +0200 Subject: [PATCH 05/18] feat(api): externalize config and add error handling Replace sensor-api.py with sensor_api.py (valid Python module name). Move MQTT credentials and broker config to environment variables via python-dotenv. Add .env.example as reference template. Add HTTP 503 on sensor failure and 502 on MQTT failure. Set Flask debug=False by default, driven by FLASK_DEBUG env var. Co-authored-by: agilicode --- .env.example | 13 ++++++++++ sensor-api.py | 43 ------------------------------ sensor_api.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 .env.example delete mode 100644 sensor-api.py create mode 100644 sensor_api.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..34b95a7 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# I2C configuration +BME280_I2C_BUS=1 +BME280_I2C_ADDRESS=0x77 + +# MQTT broker +MQTT_BROKER_HOST=192.168.1.x +MQTT_USERNAME=homeassistant +MQTT_PASSWORD= +MQTT_CLIENT_ID=rpi-bme280 + +# Flask +FLASK_PORT=5000 +FLASK_DEBUG=false diff --git a/sensor-api.py b/sensor-api.py deleted file mode 100644 index 1eb0e33..0000000 --- a/sensor-api.py +++ /dev/null @@ -1,43 +0,0 @@ -import bme280 -import time -import paho.mqtt.client as mqtt -from flask import Flask, jsonify - -app = Flask(__name__) -app.config['JSON_SORT_KEYS'] = False - -username = 'homeassistant' -password = 'chi6pa9tiom3chahhohB7sienicae2aimimeefei4queol7eesuthohcai6maiph' -client_id = 'rpi3b-bme280' + str(int(time.time())) -broker_host = '192.168.86.35' -temperature_topic = 'sensor/bme280_temperature' -humidity_topic = 'sensor/bme280_humidity' -pressure_topic = 'sensor/bme280_pressure' - -@app.route('/') -def index(): - return jsonify({}) - -@app.route('/bme280') -def bme280_action(): - sensor = bme280.sensor() - - return jsonify(sensor) - -@app.route('/bme280/publish') -def bme280_publish_action(): - sensor = bme280.sensor() - - hass_mqtt = mqtt.Client(client_id) - hass_mqtt.username_pw_set(username, password) - hass_mqtt.connect(broker_host) - - hass_mqtt.publish(temperature_topic, sensor['data']['temperature']) - hass_mqtt.publish(humidity_topic, sensor['data']['humidity']) - hass_mqtt.publish(pressure_topic, sensor['data']['pressure']) - - return jsonify({}) - - -if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0') diff --git a/sensor_api.py b/sensor_api.py new file mode 100644 index 0000000..f87add0 --- /dev/null +++ b/sensor_api.py @@ -0,0 +1,72 @@ +import os +import bme280 +from flask import Flask, jsonify +from dotenv import load_dotenv +from paho.mqtt import publish as mqtt_publish + +load_dotenv() + +app = Flask(__name__) +app.json.sort_keys = False + +MQTT_BROKER_HOST = os.environ.get('MQTT_BROKER_HOST', 'localhost') +MQTT_USERNAME = os.environ.get('MQTT_USERNAME', '') +MQTT_PASSWORD = os.environ.get('MQTT_PASSWORD', '') +MQTT_CLIENT_ID = os.environ.get('MQTT_CLIENT_ID', 'rpi-bme280') + +TEMPERATURE_TOPIC = 'sensor/bme280_temperature' +HUMIDITY_TOPIC = 'sensor/bme280_humidity' +PRESSURE_TOPIC = 'sensor/bme280_pressure' + + +@app.route('/health') +def health(): + return jsonify({'status': 'ok'}) + + +@app.route('/') +def index(): + return jsonify({}) + + +@app.route('/bme280') +def bme280_action(): + try: + return jsonify(bme280.sensor()) + except OSError as e: + return jsonify({'error': 'Sensor unavailable', 'detail': str(e)}), 503 + + +@app.route('/bme280/publish') +def bme280_publish_action(): + try: + data = bme280.sensor() + except OSError as e: + return jsonify({'error': 'Sensor unavailable', 'detail': str(e)}), 503 + + auth = {'username': MQTT_USERNAME, 'password': MQTT_PASSWORD} if MQTT_USERNAME else None + + try: + mqtt_publish.multiple( + [ + {'topic': TEMPERATURE_TOPIC, 'payload': str(data['data']['temperature'])}, + {'topic': HUMIDITY_TOPIC, 'payload': str(data['data']['humidity'])}, + {'topic': PRESSURE_TOPIC, 'payload': str(data['data']['pressure'])}, + ], + hostname=MQTT_BROKER_HOST, + auth=auth, + client_id=MQTT_CLIENT_ID, + ) + except Exception as e: + return jsonify({'error': 'MQTT publish failed', 'detail': str(e)}), 502 + + return jsonify({ + 'published': True, + 'topics': [TEMPERATURE_TOPIC, HUMIDITY_TOPIC, PRESSURE_TOPIC], + }) + + +if __name__ == '__main__': + port = int(os.environ.get('FLASK_PORT', '5000')) + debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true' + app.run(host='0.0.0.0', port=port, debug=debug) From cad3f500c51b87371ac4759a363c45ae254d10d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 10:57:58 +0200 Subject: [PATCH 06/18] test: add unit tests for driver and API Add pytest suite with 19 tests covering: - Pure helper functions (_get_short, _get_ushort, _get_char, _get_uchar) - read_id and sensor() with smbus2 mocked via unittest.mock - All Flask routes including 503/502 error cases Add pytest.ini (testpaths, pythonpath) and requirements-dev.txt. Co-authored-by: agilicode --- pytest.ini | 3 ++ requirements-dev.txt | 3 ++ tests/test_api.py | 82 ++++++++++++++++++++++++++++++++ tests/test_bme280.py | 109 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/test_api.py create mode 100644 tests/test_bme280.py diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2693ad0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0.0 +pytest-flask>=1.3.0 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b7a00c1 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,82 @@ +from unittest.mock import patch + +import pytest + + +MOCK_SENSOR = { + 'name': 'bme280', + 'brand': 'Waveshare', + 'part_number': 'BME280 Environmental Sensor', + 'sku': 15231, + 'upc': 614961952638, + 'chip': {'id': 96, 'version': 0}, + 'capabilities': { + 'temperature': {'unit_of_measurement': '°C', 'min': -40, 'max': 85, 'resolution': 0.01, 'accuracy': 1}, + 'humidity': {'unit_of_measurement': '%RH', 'min': 0, 'max': 100, 'resolution': 0.008, 'accuracy': 3}, + 'pressure': {'unit_of_measurement': 'hPa', 'min': 300, 'max': 1100, 'resolution': 0.008, 'accuracy': 0.0018}, + }, + 'data': {'temperature': 21.55, 'humidity': 44.57, 'pressure': 1005.16}, +} + + +@pytest.fixture +def client(): + import sensor_api + sensor_api.app.config['TESTING'] = True + return sensor_api.app.test_client() + + +# --- Health / index --- + +def test_health_returns_ok(client): + resp = client.get('/health') + assert resp.status_code == 200 + assert resp.json['status'] == 'ok' + + +def test_index_returns_empty_json(client): + resp = client.get('/') + assert resp.status_code == 200 + assert resp.json == {} + + +# --- /bme280 --- + +def test_bme280_returns_sensor_data(client): + with patch('sensor_api.bme280.sensor', return_value=MOCK_SENSOR): + resp = client.get('/bme280') + assert resp.status_code == 200 + assert resp.json['name'] == 'bme280' + assert resp.json['data']['temperature'] == 21.55 + + +def test_bme280_returns_503_when_sensor_unavailable(client): + with patch('sensor_api.bme280.sensor', side_effect=OSError('No such file: /dev/i2c-1')): + resp = client.get('/bme280') + assert resp.status_code == 503 + assert resp.json['error'] == 'Sensor unavailable' + + +# --- /bme280/publish --- + +def test_publish_returns_200_with_topics(client): + with patch('sensor_api.bme280.sensor', return_value=MOCK_SENSOR): + with patch('sensor_api.mqtt_publish.multiple'): + resp = client.get('/bme280/publish') + assert resp.status_code == 200 + assert resp.json['published'] is True + assert len(resp.json['topics']) == 3 + + +def test_publish_returns_503_when_sensor_unavailable(client): + with patch('sensor_api.bme280.sensor', side_effect=OSError('I2C error')): + resp = client.get('/bme280/publish') + assert resp.status_code == 503 + + +def test_publish_returns_502_when_mqtt_fails(client): + with patch('sensor_api.bme280.sensor', return_value=MOCK_SENSOR): + with patch('sensor_api.mqtt_publish.multiple', side_effect=Exception('Connection refused')): + resp = client.get('/bme280/publish') + assert resp.status_code == 502 + assert resp.json['error'] == 'MQTT publish failed' diff --git a/tests/test_bme280.py b/tests/test_bme280.py new file mode 100644 index 0000000..0224e1d --- /dev/null +++ b/tests/test_bme280.py @@ -0,0 +1,109 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +def make_mock_bus(chip_id: int = 96, chip_version: int = 0) -> MagicMock: + bus = MagicMock() + bus.read_i2c_block_data.side_effect = [ + [chip_id, chip_version], # read_id + [0] * 24, # cal1 - T and P calibration + [0], # cal2 - H1 + [0] * 7, # cal3 - H2-H6 + [0] * 8, # raw sensor data + ] + return bus + + +@pytest.fixture +def patched_smbus(): + mock_bus = make_mock_bus() + with patch('bme280.smbus2.SMBus') as MockSMBus: + instance = MockSMBus.return_value + instance.__enter__ = MagicMock(return_value=mock_bus) + instance.__exit__ = MagicMock(return_value=False) + yield mock_bus + + +# --- Helper functions (pure, no hardware) --- + +def test_get_short_positive(): + from bme280 import _get_short + assert _get_short([0x78, 0x6C], 0) == 27768 # 0x6C78 + + +def test_get_short_negative(): + from bme280 import _get_short + assert _get_short([0x00, 0xFF], 0) == -256 # 0xFF00 as signed + + +def test_get_ushort(): + from bme280 import _get_ushort + assert _get_ushort([0x48, 0x67], 0) == 26440 # 0x6748 + + +def test_get_char_positive(): + from bme280 import _get_char + assert _get_char([50], 0) == 50 + + +def test_get_char_negative(): + from bme280 import _get_char + assert _get_char([200], 0) == -56 # 200 - 256 + + +def test_get_uchar(): + from bme280 import _get_uchar + assert _get_uchar([0xAB], 0) == 0xAB + + +# --- read_id --- + +def test_read_id_returns_chip_id_and_version(patched_smbus): + import bme280 + chip_id, chip_version = bme280.read_id() + assert chip_id == 96 + assert chip_version == 0 + + +def test_read_id_custom_chip_id(): + bus = make_mock_bus(chip_id=96, chip_version=0) + with patch('bme280.smbus2.SMBus') as MockSMBus: + instance = MockSMBus.return_value + instance.__enter__ = MagicMock(return_value=bus) + instance.__exit__ = MagicMock(return_value=False) + import bme280 + chip_id, _ = bme280.read_id() + assert chip_id == 96 + + +# --- sensor() structure --- + +def test_sensor_returns_required_keys(patched_smbus): + import bme280 + result = bme280.sensor() + assert result['name'] == 'bme280' + assert 'data' in result + assert 'capabilities' in result + assert 'chip' in result + + +def test_sensor_data_has_three_measurements(patched_smbus): + import bme280 + data = bme280.sensor()['data'] + assert 'temperature' in data + assert 'humidity' in data + assert 'pressure' in data + + +def test_humidity_clamped_within_range(patched_smbus): + import bme280 + result = bme280.sensor() + assert 0.0 <= result['data']['humidity'] <= 100.0 + + +def test_pressure_zero_when_p1_calibration_is_zero(patched_smbus): + # P1=0 triggers the zero-division guard in the compensation formula + import bme280 + result = bme280.sensor() + assert result['data']['pressure'] == 0.0 From 603d8c4da354651da9062b1bd9fa01189a366eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 10:59:21 +0200 Subject: [PATCH 07/18] feat: add multi-stage Dockerfile and docker-compose Add multi-stage Dockerfile with a test target (pytest) and an app target (Flask on port 5000). Add docker-compose.yml with a test service and an app service ready for Raspberry Pi deployment (I2C device passthrough commented out). Co-authored-by: agilicode --- Dockerfile | 25 +++++++++++++++++++++++++ docker-compose.yml | 17 +++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4273c32 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim AS base + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + + +FROM base AS test + +COPY requirements-dev.txt . +RUN pip install --no-cache-dir -r requirements-dev.txt + +COPY . . + +CMD ["pytest", "tests/", "-v", "--tb=short"] + + +FROM base AS app + +COPY bme280.py sensor_api.py ./ + +EXPOSE 5000 + +CMD ["python", "sensor_api.py"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..307db24 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + test: + build: + context: . + target: test + + app: + build: + context: . + target: app + ports: + - "5000:5000" + env_file: + - .env + # Uncomment on Raspberry Pi to pass through the I2C bus + # devices: + # - /dev/i2c-1:/dev/i2c-1 From 3ae78be954537357345fd85fba2dd63ea5a88e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:00:27 +0200 Subject: [PATCH 08/18] docs: add AUDIT.md and CLAUDE.md AUDIT.md documents all findings (security, Python 2, error handling) with severity levels, a modernisation roadmap, usage examples and a CI pipeline proposal. CLAUDE.md provides project context for future Claude Code sessions: architecture, hardware constraints, algorithm notes, env var reference and testing approach. Co-authored-by: agilicode --- AUDIT.md | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 121 +++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 AUDIT.md create mode 100644 CLAUDE.md diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..61cc57c --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,266 @@ +# Audit - BME280 Sensor Project + +**Scope:** `bme280.py`, `sensor-api.py` | **Files:** 2 | **Findings:** 2 ❌ 3 ⚠️ 4 💡 + +--- + +## TL;DR + +Projet fonctionnel mais en Python 2 avec des credentials en clair. La priorité absolue est la migration Python 3 et la sécurisation de la config. Une fois ça fait, le code est structurellement sain et prêt pour un pipeline CI. + +--- + +## Findings + +❌ **Credentials MQTT hardcodés dans le source** - `sensor-api.py:9-14` + Mot de passe, IP du broker, username directement dans le code versionné. + **Fix:** variables d'environnement via `os.environ.get()` + fichier `.env` exclu du git. + +```python +# Avant +password = 'chi6pa9tiom3chahhohB7sienicae2aimimeefei4queol7eesuthohcai6maiph' +broker_host = '192.168.86.35' + +# Après +import os +password = os.environ.get('MQTT_PASSWORD', '') +broker_host = os.environ.get('MQTT_BROKER_HOST', 'localhost') +``` + +❌ **Python 2** - `bme280.py:208-215` + Syntaxe `print "..."` sans parenthèses, EOL depuis janvier 2020. Plus de patches de sécurité, incompatible avec les outils modernes (pytest, mypy, etc.). + **Fix:** migration vers Python 3 + remplacement de `smbus` par `smbus2`. + +```python +# Avant +print "Temperature :", temperature, "C" + +# Après +print(f"Temperature : {temperature:.2f} °C") +``` + +⚠️ **Aucune gestion d'erreur** - `sensor-api.py`, `bme280.py` + Si le capteur est absent, l'I2C plante ou le broker MQTT est injoignable, l'API retourne une 500 brute sans message utile. + **Fix:** try/except avec réponse JSON structurée et code HTTP approprié. + +```python +@app.route('/bme280') +def bme280_action(): + try: + return jsonify(bme280.sensor()) + except OSError as e: + return jsonify({'error': 'Sensor unavailable', 'detail': str(e)}), 503 +``` + +⚠️ **Flask en mode debug en production** - `sensor-api.py:43` + `debug=True` active le debugger Werkzeug interactif, exécutable à distance sur le réseau. + **Fix:** `debug=False` ou piloter via variable d'environnement `FLASK_DEBUG`. + +⚠️ **`bme280.pyc` tracké dans git** + Bytecode compilé qui ne devrait pas être versionné. Il peut diverger silencieusement de la source. + **Fix:** ajouter `.gitignore` avec `*.pyc` et `__pycache__/`. + +💡 **Pas de `requirements.txt`** - dépendances implicites non documentées + `smbus2`, `flask`, `paho-mqtt` doivent être devinées à la lecture du code. + **Fix:** `requirements.txt` minimal avec les versions fixées. + +💡 **Adresse I2C hardcodée à 0x77** - `bme280.py:28` + Le BME280 supporte aussi l'adresse 0x76 (selon câblage SDO). Rendre configurable couvre les deux variantes. + +💡 **Bus SMBus instancié au niveau module** - `bme280.py:31` + `bus = smbus.SMBus(1)` s'exécute à l'import, ce qui fait crasher tout test unitaire sur une machine sans hardware I2C. + **Fix:** instancier le bus dans les fonctions qui en ont besoin, ou l'injecter en paramètre. + +💡 **`feature/v2` non mergée + commit "try POO way" orphelin** + Code potentiellement utile qui dort dans une branche non intégrée. + +--- + +## Roadmap modernisation + +### 1. Python 3 + dépendances propres + +- Remplacer `smbus` par `smbus2` (compatible Python 3, même API) +- Réécrire `bme280.py` en Python 3 avec type hints +- Créer `requirements.txt` + +``` +smbus2==0.4.3 +flask==3.1.0 +paho-mqtt==2.1.0 +python-dotenv==1.0.1 +``` + +### 2. Configuration externalisée + +Créer `.env.example` (versionné) et `.env` (ignoré) : + +```ini +# .env.example +MQTT_BROKER_HOST=192.168.1.x +MQTT_USERNAME=homeassistant +MQTT_PASSWORD= +MQTT_CLIENT_ID=rpi-bme280 +BME280_I2C_ADDRESS=0x77 +BME280_I2C_BUS=1 +FLASK_PORT=5000 +FLASK_DEBUG=false +``` + +### 3. Gestion d'erreurs et HTTP sémantique + +| Cas | Status actuel | Status cible | +|-----|--------------|--------------| +| Capteur absent | 500 (crash) | 503 Service Unavailable | +| Lecture I2C timeout | 500 (crash) | 503 + retry header | +| MQTT broker injoignable | 500 (crash) | 502 Bad Gateway | +| Succès publish | 200 `{}` | 200 `{"published": true, "topics": [...]}` | + +### 4. Qualité de code + +- Type hints sur toutes les fonctions publiques +- Dataclass ou TypedDict pour la structure sensor +- `bus` instancié dans les fonctions (testabilité) +- `snake_case` cohérent (renommer `sensor-api.py` en `sensor_api.py`) + +### 5. Tests unitaires + +Le projet est très bien adapté aux tests car le hardware est isolable par mock : + +```python +# tests/test_bme280.py +from unittest.mock import patch, MagicMock + +def test_sensor_returns_expected_structure(): + mock_bus = MagicMock() + mock_bus.read_i2c_block_data.return_value = [0] * 24 + with patch('bme280.smbus2.SMBus', return_value=mock_bus): + result = bme280.sensor() + assert 'data' in result + assert 'temperature' in result['data'] + +# tests/test_api.py +def test_bme280_endpoint(client, mock_sensor): + resp = client.get('/bme280') + assert resp.status_code == 200 + assert resp.json['name'] == 'bme280' + +def test_sensor_unavailable_returns_503(client): + with patch('bme280.sensor', side_effect=OSError('I2C error')): + resp = client.get('/bme280') + assert resp.status_code == 503 +``` + +### 6. GitHub Actions CI + +Ce que la pipeline peut valider sans hardware réel : + +| Job | Outil | Ce que ça couvre | +|-----|-------|-----------------| +| Lint | `flake8` | Style, erreurs évidentes | +| Types | `mypy` | Cohérence des type hints | +| Tests | `pytest` + mocks | Logique métier et routes Flask | +| Sécurité | `pip-audit` | CVE dans les dépendances | + +```yaml +# .github/workflows/ci.yml +name: CI + +on: [push, pull_request] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: flake8 bme280.py sensor_api.py + - run: mypy bme280.py sensor_api.py + - run: pytest tests/ -v --tb=short + - run: pip-audit +``` + +### 7. Documentation + +- README complet avec badges CI, exemples curl, schéma d'architecture +- Exemples d'intégration Home Assistant (MQTT discovery) +- Docstrings sur les fonctions publiques +- Schéma de câblage I2C Raspberry Pi / BME280 + +--- + +## Exemples d'utilisation cibles + +### CLI + +```bash +# Lecture directe +python bme280.py +# Temperature : 21.55 °C | Pressure : 1005.16 hPa | Humidity : 44.57 %RH + +# Avec adresse I2C alternative +BME280_I2C_ADDRESS=0x76 python bme280.py +``` + +### API HTTP + +```bash +# Démarrer l'API +python sensor_api.py + +# Lire les données capteur +curl http://rpi.local:5000/bme280 | python -m json.tool + +# Publier vers MQTT +curl -X POST http://rpi.local:5000/bme280/publish +# {"published": true, "topics": ["sensor/bme280_temperature", ...]} + +# Healthcheck +curl http://rpi.local:5000/health +# {"status": "ok", "sensor": "connected"} +``` + +### Intégration Home Assistant (MQTT Discovery) + +```yaml +# configuration.yaml +mqtt: + sensor: + - name: "BME280 Temperature" + state_topic: "sensor/bme280_temperature" + unit_of_measurement: "°C" + device_class: temperature + - name: "BME280 Humidity" + state_topic: "sensor/bme280_humidity" + unit_of_measurement: "%" + device_class: humidity + - name: "BME280 Pressure" + state_topic: "sensor/bme280_pressure" + unit_of_measurement: "hPa" + device_class: atmospheric_pressure +``` + +### Cron (après migration) + +```bash +# Publier toutes les minutes +* * * * * curl -s -X POST http://localhost:5000/bme280/publish >> /var/log/bme280.log 2>&1 +``` + +--- + +## Résumé + +| Priorité | Action | Effort | +|----------|--------|--------| +| ❌ Immédiat | Retirer les credentials du code | 30 min | +| ❌ Court terme | Migration Python 3 | 2h | +| ⚠️ Court terme | Gestion d'erreurs + `.gitignore` | 1h | +| 💡 Moyen terme | Tests unitaires + CI GitHub Actions | 3h | +| 💡 Moyen terme | Docker + `.env.example` | 1h | +| 💡 Long terme | README complet + exemples | 2h | + +**Verdict:** Le coeur algorithmique (calibration BME280) est correct et bien isolable. La dette principale est Python 2 + sécurité. Une fois ces deux points traités, le projet peut servir de base solide avec pipeline CI complète. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..91e525f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md - BME280 Sensor Project + +## Contexte du projet + +Driver Python pour le capteur environnemental Bosch BME280 (température, pression, humidité) sur Raspberry Pi, exposé via une API HTTP Flask et une publication MQTT vers Home Assistant. + +**Matériel cible:** Raspberry Pi (Rev 2+, bus I2C n°1) + capteur Waveshare BME280 (SKU 15231) +**Adresse I2C par défaut:** 0x77 (alternatif 0x76 selon câblage SDO) + +--- + +## Architecture + +``` +bme280.py Driver bas niveau I2C + calculs de calibration (datasheet Bosch) +sensor_api.py API Flask HTTP + publication MQTT vers Home Assistant +``` + +Le driver lit les registres EEPROM du BME280, applique les algorithmes de compensation du datasheet officiel (page 22+), et retourne température/pression/humidité calibrées. + +--- + +## Etat actuel du code + +**Attention:** le code est en Python 2. Voir `AUDIT.md` pour la roadmap de modernisation complète. + +Dépendances implicites (pas de requirements.txt) : +- `smbus` (Python 2) - à remplacer par `smbus2` lors de la migration Python 3 +- `flask` +- `paho-mqtt` + +--- + +## Contraintes hardware + +Le code `bme280.py` ne peut pas tourner sans un vrai bus I2C. Sur une machine de dev (CI, laptop), **toujours mocker `smbus`** : + +```python +from unittest.mock import patch, MagicMock + +mock_bus = MagicMock() +mock_bus.read_i2c_block_data.return_value = [0] * 24 +with patch('bme280.smbus.SMBus', return_value=mock_bus): + # test ici +``` + +Le `bus = smbus.SMBus(1)` est instancié au niveau module dans l'état actuel, ce qui fait crasher l'import sans hardware. Lors de la refonte, il faut déplacer cette instanciation dans les fonctions. + +--- + +## Algorithmes critiques + +Les fonctions de compensation dans `readBME280All()` sont tirées directement du datasheet Bosch (Appendix). **Ne pas modifier sans vérifier contre la spec officielle.** Les constantes magiques (32768, 524288, 67108864...) sont des puissances de 2 issues du datasheet, pas des valeurs arbitraires. + +--- + +## Configuration sensible + +`sensor-api.py` contient actuellement des credentials MQTT hardcodés. Ne pas les modifier directement dans le source, la cible est de les externaliser en variables d'environnement. Voir `AUDIT.md` pour le plan. + +Variables d'environnement cibles (après migration) : + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `MQTT_BROKER_HOST` | `localhost` | IP ou hostname du broker | +| `MQTT_USERNAME` | - | Username MQTT | +| `MQTT_PASSWORD` | - | Password MQTT | +| `MQTT_CLIENT_ID` | `rpi-bme280` | Client ID MQTT | +| `BME280_I2C_ADDRESS` | `0x77` | Adresse I2C du capteur | +| `BME280_I2C_BUS` | `1` | Numéro de bus I2C | +| `FLASK_PORT` | `5000` | Port HTTP | + +--- + +## Lancer le projet + +```bash +# CLI directe (sur le Pi uniquement) +python bme280.py + +# API HTTP (sur le Pi uniquement) +python sensor-api.py +# Écoute sur 0.0.0.0:5000 +``` + +--- + +## Branches + +| Branche | Etat | +|---------|------| +| `develop` | Branche principale active | +| `master` | Remote uniquement | +| `feature/v2` | Remote non mergée - tentative POO abandonnée | + +--- + +## Roadmap + +Le détail complet est dans `AUDIT.md`. Les grandes étapes : + +1. Externaliser les credentials (sécurité, immédiat) +2. Migration Python 3 + `smbus2` +3. `requirements.txt` + `.gitignore` +4. Gestion d'erreurs dans l'API +5. Tests unitaires avec mocks hardware +6. Pipeline GitHub Actions CI + +--- + +## Tests + +Pas de tests actuellement. La cible est `pytest` avec mocks smbus et Flask test client. Voir la section "Tests unitaires" dans `AUDIT.md` pour les exemples. + +Structure cible : + +``` +tests/ +├── test_bme280.py # Calibration, parsing registres +└── test_api.py # Routes Flask, gestion d'erreurs +``` From 161bf256f6639b405dbc25d2bb371f99a0f35a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:01:53 +0200 Subject: [PATCH 09/18] ci: add GitHub Actions pipeline Add two jobs triggered on push and PR to develop/master: - test: builds the Docker test image with layer caching (GHA cache) and runs the full pytest suite inside the container - security: runs pip-audit against requirements.txt to catch CVEs in runtime dependencies Co-authored-by: agilicode --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..58db596 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [develop, master] + pull_request: + branches: [develop, master] + +jobs: + test: + name: Build & test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build test image + uses: docker/build-push-action@v6 + with: + context: . + target: test + load: true + tags: bme280-test:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run tests + run: docker run --rm bme280-test:ci + + security: + name: Dependency audit + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install pip-audit + run: pip install pip-audit --quiet + + - name: Audit dependencies + run: pip-audit -r requirements.txt From a5a764dca405394f81035aace571bcd23c0016fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:04:26 +0200 Subject: [PATCH 10/18] chore: untrack AUDIT.md and CLAUDE.md, keep local only Remove both files from git history and add them to .gitignore. They remain on disk for local use but will never be committed. Co-authored-by: agilicode --- .gitignore | 2 + AUDIT.md | 266 ----------------------------------------------------- CLAUDE.md | 121 ------------------------ 3 files changed, 2 insertions(+), 387 deletions(-) delete mode 100644 AUDIT.md delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 7f8ef39..56cb6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ *.pyo .env .pytest_cache/ +AUDIT.md +CLAUDE.md diff --git a/AUDIT.md b/AUDIT.md deleted file mode 100644 index 61cc57c..0000000 --- a/AUDIT.md +++ /dev/null @@ -1,266 +0,0 @@ -# Audit - BME280 Sensor Project - -**Scope:** `bme280.py`, `sensor-api.py` | **Files:** 2 | **Findings:** 2 ❌ 3 ⚠️ 4 💡 - ---- - -## TL;DR - -Projet fonctionnel mais en Python 2 avec des credentials en clair. La priorité absolue est la migration Python 3 et la sécurisation de la config. Une fois ça fait, le code est structurellement sain et prêt pour un pipeline CI. - ---- - -## Findings - -❌ **Credentials MQTT hardcodés dans le source** - `sensor-api.py:9-14` - Mot de passe, IP du broker, username directement dans le code versionné. - **Fix:** variables d'environnement via `os.environ.get()` + fichier `.env` exclu du git. - -```python -# Avant -password = 'chi6pa9tiom3chahhohB7sienicae2aimimeefei4queol7eesuthohcai6maiph' -broker_host = '192.168.86.35' - -# Après -import os -password = os.environ.get('MQTT_PASSWORD', '') -broker_host = os.environ.get('MQTT_BROKER_HOST', 'localhost') -``` - -❌ **Python 2** - `bme280.py:208-215` - Syntaxe `print "..."` sans parenthèses, EOL depuis janvier 2020. Plus de patches de sécurité, incompatible avec les outils modernes (pytest, mypy, etc.). - **Fix:** migration vers Python 3 + remplacement de `smbus` par `smbus2`. - -```python -# Avant -print "Temperature :", temperature, "C" - -# Après -print(f"Temperature : {temperature:.2f} °C") -``` - -⚠️ **Aucune gestion d'erreur** - `sensor-api.py`, `bme280.py` - Si le capteur est absent, l'I2C plante ou le broker MQTT est injoignable, l'API retourne une 500 brute sans message utile. - **Fix:** try/except avec réponse JSON structurée et code HTTP approprié. - -```python -@app.route('/bme280') -def bme280_action(): - try: - return jsonify(bme280.sensor()) - except OSError as e: - return jsonify({'error': 'Sensor unavailable', 'detail': str(e)}), 503 -``` - -⚠️ **Flask en mode debug en production** - `sensor-api.py:43` - `debug=True` active le debugger Werkzeug interactif, exécutable à distance sur le réseau. - **Fix:** `debug=False` ou piloter via variable d'environnement `FLASK_DEBUG`. - -⚠️ **`bme280.pyc` tracké dans git** - Bytecode compilé qui ne devrait pas être versionné. Il peut diverger silencieusement de la source. - **Fix:** ajouter `.gitignore` avec `*.pyc` et `__pycache__/`. - -💡 **Pas de `requirements.txt`** - dépendances implicites non documentées - `smbus2`, `flask`, `paho-mqtt` doivent être devinées à la lecture du code. - **Fix:** `requirements.txt` minimal avec les versions fixées. - -💡 **Adresse I2C hardcodée à 0x77** - `bme280.py:28` - Le BME280 supporte aussi l'adresse 0x76 (selon câblage SDO). Rendre configurable couvre les deux variantes. - -💡 **Bus SMBus instancié au niveau module** - `bme280.py:31` - `bus = smbus.SMBus(1)` s'exécute à l'import, ce qui fait crasher tout test unitaire sur une machine sans hardware I2C. - **Fix:** instancier le bus dans les fonctions qui en ont besoin, ou l'injecter en paramètre. - -💡 **`feature/v2` non mergée + commit "try POO way" orphelin** - Code potentiellement utile qui dort dans une branche non intégrée. - ---- - -## Roadmap modernisation - -### 1. Python 3 + dépendances propres - -- Remplacer `smbus` par `smbus2` (compatible Python 3, même API) -- Réécrire `bme280.py` en Python 3 avec type hints -- Créer `requirements.txt` - -``` -smbus2==0.4.3 -flask==3.1.0 -paho-mqtt==2.1.0 -python-dotenv==1.0.1 -``` - -### 2. Configuration externalisée - -Créer `.env.example` (versionné) et `.env` (ignoré) : - -```ini -# .env.example -MQTT_BROKER_HOST=192.168.1.x -MQTT_USERNAME=homeassistant -MQTT_PASSWORD= -MQTT_CLIENT_ID=rpi-bme280 -BME280_I2C_ADDRESS=0x77 -BME280_I2C_BUS=1 -FLASK_PORT=5000 -FLASK_DEBUG=false -``` - -### 3. Gestion d'erreurs et HTTP sémantique - -| Cas | Status actuel | Status cible | -|-----|--------------|--------------| -| Capteur absent | 500 (crash) | 503 Service Unavailable | -| Lecture I2C timeout | 500 (crash) | 503 + retry header | -| MQTT broker injoignable | 500 (crash) | 502 Bad Gateway | -| Succès publish | 200 `{}` | 200 `{"published": true, "topics": [...]}` | - -### 4. Qualité de code - -- Type hints sur toutes les fonctions publiques -- Dataclass ou TypedDict pour la structure sensor -- `bus` instancié dans les fonctions (testabilité) -- `snake_case` cohérent (renommer `sensor-api.py` en `sensor_api.py`) - -### 5. Tests unitaires - -Le projet est très bien adapté aux tests car le hardware est isolable par mock : - -```python -# tests/test_bme280.py -from unittest.mock import patch, MagicMock - -def test_sensor_returns_expected_structure(): - mock_bus = MagicMock() - mock_bus.read_i2c_block_data.return_value = [0] * 24 - with patch('bme280.smbus2.SMBus', return_value=mock_bus): - result = bme280.sensor() - assert 'data' in result - assert 'temperature' in result['data'] - -# tests/test_api.py -def test_bme280_endpoint(client, mock_sensor): - resp = client.get('/bme280') - assert resp.status_code == 200 - assert resp.json['name'] == 'bme280' - -def test_sensor_unavailable_returns_503(client): - with patch('bme280.sensor', side_effect=OSError('I2C error')): - resp = client.get('/bme280') - assert resp.status_code == 503 -``` - -### 6. GitHub Actions CI - -Ce que la pipeline peut valider sans hardware réel : - -| Job | Outil | Ce que ça couvre | -|-----|-------|-----------------| -| Lint | `flake8` | Style, erreurs évidentes | -| Types | `mypy` | Cohérence des type hints | -| Tests | `pytest` + mocks | Logique métier et routes Flask | -| Sécurité | `pip-audit` | CVE dans les dépendances | - -```yaml -# .github/workflows/ci.yml -name: CI - -on: [push, pull_request] - -jobs: - quality: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - run: pip install -r requirements.txt -r requirements-dev.txt - - run: flake8 bme280.py sensor_api.py - - run: mypy bme280.py sensor_api.py - - run: pytest tests/ -v --tb=short - - run: pip-audit -``` - -### 7. Documentation - -- README complet avec badges CI, exemples curl, schéma d'architecture -- Exemples d'intégration Home Assistant (MQTT discovery) -- Docstrings sur les fonctions publiques -- Schéma de câblage I2C Raspberry Pi / BME280 - ---- - -## Exemples d'utilisation cibles - -### CLI - -```bash -# Lecture directe -python bme280.py -# Temperature : 21.55 °C | Pressure : 1005.16 hPa | Humidity : 44.57 %RH - -# Avec adresse I2C alternative -BME280_I2C_ADDRESS=0x76 python bme280.py -``` - -### API HTTP - -```bash -# Démarrer l'API -python sensor_api.py - -# Lire les données capteur -curl http://rpi.local:5000/bme280 | python -m json.tool - -# Publier vers MQTT -curl -X POST http://rpi.local:5000/bme280/publish -# {"published": true, "topics": ["sensor/bme280_temperature", ...]} - -# Healthcheck -curl http://rpi.local:5000/health -# {"status": "ok", "sensor": "connected"} -``` - -### Intégration Home Assistant (MQTT Discovery) - -```yaml -# configuration.yaml -mqtt: - sensor: - - name: "BME280 Temperature" - state_topic: "sensor/bme280_temperature" - unit_of_measurement: "°C" - device_class: temperature - - name: "BME280 Humidity" - state_topic: "sensor/bme280_humidity" - unit_of_measurement: "%" - device_class: humidity - - name: "BME280 Pressure" - state_topic: "sensor/bme280_pressure" - unit_of_measurement: "hPa" - device_class: atmospheric_pressure -``` - -### Cron (après migration) - -```bash -# Publier toutes les minutes -* * * * * curl -s -X POST http://localhost:5000/bme280/publish >> /var/log/bme280.log 2>&1 -``` - ---- - -## Résumé - -| Priorité | Action | Effort | -|----------|--------|--------| -| ❌ Immédiat | Retirer les credentials du code | 30 min | -| ❌ Court terme | Migration Python 3 | 2h | -| ⚠️ Court terme | Gestion d'erreurs + `.gitignore` | 1h | -| 💡 Moyen terme | Tests unitaires + CI GitHub Actions | 3h | -| 💡 Moyen terme | Docker + `.env.example` | 1h | -| 💡 Long terme | README complet + exemples | 2h | - -**Verdict:** Le coeur algorithmique (calibration BME280) est correct et bien isolable. La dette principale est Python 2 + sécurité. Une fois ces deux points traités, le projet peut servir de base solide avec pipeline CI complète. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 91e525f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,121 +0,0 @@ -# CLAUDE.md - BME280 Sensor Project - -## Contexte du projet - -Driver Python pour le capteur environnemental Bosch BME280 (température, pression, humidité) sur Raspberry Pi, exposé via une API HTTP Flask et une publication MQTT vers Home Assistant. - -**Matériel cible:** Raspberry Pi (Rev 2+, bus I2C n°1) + capteur Waveshare BME280 (SKU 15231) -**Adresse I2C par défaut:** 0x77 (alternatif 0x76 selon câblage SDO) - ---- - -## Architecture - -``` -bme280.py Driver bas niveau I2C + calculs de calibration (datasheet Bosch) -sensor_api.py API Flask HTTP + publication MQTT vers Home Assistant -``` - -Le driver lit les registres EEPROM du BME280, applique les algorithmes de compensation du datasheet officiel (page 22+), et retourne température/pression/humidité calibrées. - ---- - -## Etat actuel du code - -**Attention:** le code est en Python 2. Voir `AUDIT.md` pour la roadmap de modernisation complète. - -Dépendances implicites (pas de requirements.txt) : -- `smbus` (Python 2) - à remplacer par `smbus2` lors de la migration Python 3 -- `flask` -- `paho-mqtt` - ---- - -## Contraintes hardware - -Le code `bme280.py` ne peut pas tourner sans un vrai bus I2C. Sur une machine de dev (CI, laptop), **toujours mocker `smbus`** : - -```python -from unittest.mock import patch, MagicMock - -mock_bus = MagicMock() -mock_bus.read_i2c_block_data.return_value = [0] * 24 -with patch('bme280.smbus.SMBus', return_value=mock_bus): - # test ici -``` - -Le `bus = smbus.SMBus(1)` est instancié au niveau module dans l'état actuel, ce qui fait crasher l'import sans hardware. Lors de la refonte, il faut déplacer cette instanciation dans les fonctions. - ---- - -## Algorithmes critiques - -Les fonctions de compensation dans `readBME280All()` sont tirées directement du datasheet Bosch (Appendix). **Ne pas modifier sans vérifier contre la spec officielle.** Les constantes magiques (32768, 524288, 67108864...) sont des puissances de 2 issues du datasheet, pas des valeurs arbitraires. - ---- - -## Configuration sensible - -`sensor-api.py` contient actuellement des credentials MQTT hardcodés. Ne pas les modifier directement dans le source, la cible est de les externaliser en variables d'environnement. Voir `AUDIT.md` pour le plan. - -Variables d'environnement cibles (après migration) : - -| Variable | Défaut | Description | -|----------|--------|-------------| -| `MQTT_BROKER_HOST` | `localhost` | IP ou hostname du broker | -| `MQTT_USERNAME` | - | Username MQTT | -| `MQTT_PASSWORD` | - | Password MQTT | -| `MQTT_CLIENT_ID` | `rpi-bme280` | Client ID MQTT | -| `BME280_I2C_ADDRESS` | `0x77` | Adresse I2C du capteur | -| `BME280_I2C_BUS` | `1` | Numéro de bus I2C | -| `FLASK_PORT` | `5000` | Port HTTP | - ---- - -## Lancer le projet - -```bash -# CLI directe (sur le Pi uniquement) -python bme280.py - -# API HTTP (sur le Pi uniquement) -python sensor-api.py -# Écoute sur 0.0.0.0:5000 -``` - ---- - -## Branches - -| Branche | Etat | -|---------|------| -| `develop` | Branche principale active | -| `master` | Remote uniquement | -| `feature/v2` | Remote non mergée - tentative POO abandonnée | - ---- - -## Roadmap - -Le détail complet est dans `AUDIT.md`. Les grandes étapes : - -1. Externaliser les credentials (sécurité, immédiat) -2. Migration Python 3 + `smbus2` -3. `requirements.txt` + `.gitignore` -4. Gestion d'erreurs dans l'API -5. Tests unitaires avec mocks hardware -6. Pipeline GitHub Actions CI - ---- - -## Tests - -Pas de tests actuellement. La cible est `pytest` avec mocks smbus et Flask test client. Voir la section "Tests unitaires" dans `AUDIT.md` pour les exemples. - -Structure cible : - -``` -tests/ -├── test_bme280.py # Calibration, parsing registres -└── test_api.py # Routes Flask, gestion d'erreurs -``` From efa862565e37f7f5bb47544a4785988adc24c467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:15:30 +0200 Subject: [PATCH 11/18] docs(readme): rewrite with full reference, examples and badges Complete rewrite covering: hardware wiring, architecture diagram, installation, all env vars, CLI/API/Docker usage with examples, full API reference with request/response samples, Home Assistant MQTT integration, development setup, test output, project structure and sensor specifications table. Add CI badge. Co-authored-by: agilicode --- README.md | 496 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 456 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 7a6992c..cc64217 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,492 @@ -# bme280 +# 🌡️ BME280 Environmental Sensor -## BME280 driver +[![CI](https://github.com/guillaumedelre/bme280/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/guillaumedelre/bme280/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/) +[![Flask](https://img.shields.io/badge/flask-3.x-lightgrey.svg)](https://flask.palletsprojects.com/) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -Run `python bme280.py` in a shell to get the sensor's data, it will output: +> Python 3 driver for the Bosch BME280 environmental sensor — reads temperature, pressure and humidity over I²C on a Raspberry Pi, and exposes them via a REST API with MQTT publishing for Home Assistant integration. +--- + +## 📋 Table of Contents + +- [Hardware](#-hardware) +- [Architecture](#-architecture) +- [Installation](#-installation) +- [Configuration](#️-configuration) +- [Usage](#-usage) + - [CLI](#cli) + - [HTTP API](#http-api) + - [Docker](#docker) +- [API Reference](#-api-reference) +- [Home Assistant Integration](#-home-assistant-integration) +- [Development](#-development) +- [Project Structure](#-project-structure) + +--- + +## 🔌 Hardware + +| Component | Details | +|-----------|---------| +| **Sensor** | Bosch BME280 — Waveshare Environmental Sensor (SKU 15231) | +| **Board** | Raspberry Pi Rev 2+ (I²C bus 1) | +| **Interface** | I²C — default address `0x77`, alternate `0x76` (SDO pin) | +| **Protocol** | SMBus via `/dev/i2c-1` | + +### Wiring (Raspberry Pi GPIO) + +``` +BME280 Raspberry Pi +────── ──────────── +VCC ──────► Pin 1 (3.3V) +GND ──────► Pin 6 (GND) +SDA ──────► Pin 3 (GPIO2 / SDA1) +SCL ──────► Pin 5 (GPIO3 / SCL1) +``` + +> 💡 Make sure I²C is enabled: `sudo raspi-config` → Interface Options → I2C → Enable + +--- + +## 🏗️ Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Raspberry Pi │ +│ │ +│ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ bme280.py │ │ sensor_api.py │ │ +│ │ │ │ (Flask) │ │ +│ │ I²C driver │◄────│ GET /bme280 │ │ +│ │ Calibration │ │ GET /bme280/publish │ │ +│ │ algorithms │ │ GET /health │ │ +│ └──────┬───────┘ └───────────┬──────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ BME280 chip │ │ MQTT Broker │ │ +│ │ (I²C 0x77) │ │ (Home Assistant) │ │ +│ └──────────────┘ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 📦 Installation + +### Prerequisites + +```bash +# Enable I²C on Raspberry Pi +sudo raspi-config # Interface Options → I2C → Enable +sudo reboot +``` + +### Clone & install + +```bash +git clone https://github.com/guillaumedelre/bme280.git +cd bme280 + +pip install -r requirements.txt +``` + +### Configure + +```bash +cp .env.example .env +# Edit .env with your MQTT broker details +nano .env +``` + +--- + +## ⚙️ Configuration + +All settings are driven by environment variables. Copy `.env.example` to `.env` and fill in your values. + +| Variable | Default | Description | +|----------|---------|-------------| +| `BME280_I2C_BUS` | `1` | I²C bus number (`1` for Pi Rev 2+, `0` for Rev 1) | +| `BME280_I2C_ADDRESS` | `0x77` | Sensor I²C address (`0x77` or `0x76` depending on SDO pin) | +| `MQTT_BROKER_HOST` | `localhost` | IP or hostname of your MQTT broker | +| `MQTT_USERNAME` | _(empty)_ | MQTT username (leave empty for anonymous) | +| `MQTT_PASSWORD` | _(empty)_ | MQTT password | +| `MQTT_CLIENT_ID` | `rpi-bme280` | MQTT client identifier | +| `FLASK_PORT` | `5000` | HTTP port for the API | +| `FLASK_DEBUG` | `false` | Enable Flask debug mode (`true` / `false`) | + +**Example `.env`:** + +```ini +BME280_I2C_BUS=1 +BME280_I2C_ADDRESS=0x77 + +MQTT_BROKER_HOST=192.168.1.10 +MQTT_USERNAME=homeassistant +MQTT_PASSWORD=your_password_here +MQTT_CLIENT_ID=rpi-bme280 + +FLASK_PORT=5000 +FLASK_DEBUG=false +``` + +--- + +## 🚀 Usage + +### CLI + +Read sensor data directly from the terminal (requires hardware): + +```bash +python bme280.py ``` -Chip ID : 96 -Version : 0 -Temperature : 21.04 °C -Pressure : 1001.86890002 hPa -Humidity : 52.7615936619 %RH + +``` +Chip ID : 96 +Version : 0 +Temperature : 21.55 °C +Pressure : 1005.16 hPa +Humidity : 44.57 %RH +``` + +Use an alternate I²C address: + +```bash +BME280_I2C_ADDRESS=0x76 python bme280.py ``` -## Http API +### HTTP API -Run `python sensor-api.py` to expose the http api on port `5000`. +Start the API server: -### Endpoints +```bash +python sensor_api.py +# Listening on http://0.0.0.0:5000 +``` -| Path | Status | Data | Comment | -|---|---|---|---| -| `/` | 200 | `{}` | nc | -| `/bme280` | 200 | [Sensor Resource](#sensor-resource) | return the sensor resource with measure | -| `/bme280/publish` | 200 | `{}` | publish in mqtt the sensor measure | +```bash +# Health check +curl http://rpi.local:5000/health -### Resource +# Read sensor data +curl http://rpi.local:5000/bme280 | python -m json.tool +# Publish to MQTT +curl http://rpi.local:5000/bme280/publish +``` + +#### Automate with cron + +Publish sensor data every minute via cron: + +```bash +crontab -e +``` + +```cron +* * * * * curl -s http://localhost:5000/bme280/publish >> /var/log/bme280.log 2>&1 +``` + +### Docker + +#### Run the API in Docker (Raspberry Pi) + +```bash +# Build the app image +docker build --target app -t bme280-app . + +# Run with I²C device passthrough and env file +docker run -d \ + --name bme280 \ + --device /dev/i2c-1:/dev/i2c-1 \ + --env-file .env \ + -p 5000:5000 \ + bme280-app +``` + +Or with docker compose: + +```bash +# Edit docker-compose.yml and uncomment the devices section +docker compose up app +``` + +#### Run tests in Docker (no hardware required) + +```bash +docker compose run --rm test +# or +docker build --target test -t bme280-test . && docker run --rm bme280-test +``` + +--- + +## 📡 API Reference + +### `GET /health` + +Service health check. + +**Response `200`:** +```json +{ + "status": "ok" +} +``` + +--- + +### `GET /` + +Root endpoint. + +**Response `200`:** ```json +{} +``` + +--- + +### `GET /bme280` + +Returns current sensor readings with full device metadata. +**Response `200`:** +```json { - "sku": 15231, "name": "bme280", "brand": "Waveshare", + "part_number": "BME280 Environmental Sensor", + "sku": 15231, "upc": 614961952638, + "chip": { + "id": 96, + "version": 0 + }, "capabilities": { - "pressure": { - "unit_of_measurement": "hPa", - "max": 1100, - "accuracy": 0.0018, - "resolution": 0.008, - "min": 300 - }, "temperature": { - "unit_of_measurement": "\u00b0C", + "unit_of_measurement": "°C", + "min": -40, "max": 85, - "accuracy": 1, "resolution": 0.01, - "min": -40 + "accuracy": 1 }, "humidity": { "unit_of_measurement": "%RH", + "min": 0, "max": 100, - "accuracy": 3, "resolution": 0.008, - "min": 0 + "accuracy": 3 + }, + "pressure": { + "unit_of_measurement": "hPa", + "min": 300, + "max": 1100, + "resolution": 0.008, + "accuracy": 0.0018 } }, - "part_number": "BME280 Environmental Sensor", "data": { - "pressure": 1005.1562396432168, "temperature": 21.55, - "humidity": 44.566495623372525 - }, - "chip": { - "version": 0, - "id": 96 + "humidity": 44.57, + "pressure": 1005.16 } } ``` -## Setup the crontab +**Response `503`** — sensor unavailable (I²C error): +```json +{ + "error": "Sensor unavailable", + "detail": "[Errno 2] No such file or directory: '/dev/i2c-1'" +} +``` + +--- + +### `GET /bme280/publish` + +Reads sensor data and publishes each measurement to the configured MQTT broker. + +**MQTT topics published:** + +| Topic | Payload | Example | +|-------|---------|---------| +| `sensor/bme280_temperature` | float string | `21.55` | +| `sensor/bme280_humidity` | float string | `44.57` | +| `sensor/bme280_pressure` | float string | `1005.16` | + +**Response `200`:** +```json +{ + "published": true, + "topics": [ + "sensor/bme280_temperature", + "sensor/bme280_humidity", + "sensor/bme280_pressure" + ] +} +``` + +**Response `503`** — sensor unavailable: +```json +{ + "error": "Sensor unavailable", + "detail": "..." +} +``` + +**Response `502`** — MQTT broker unreachable: +```json +{ + "error": "MQTT publish failed", + "detail": "Connection refused" +} +``` + +--- + +## 🏠 Home Assistant Integration + +### Manual MQTT sensors + +Add to your `configuration.yaml`: + +```yaml +mqtt: + sensor: + - name: "BME280 Temperature" + state_topic: "sensor/bme280_temperature" + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + + - name: "BME280 Humidity" + state_topic: "sensor/bme280_humidity" + unit_of_measurement: "%" + device_class: humidity + state_class: measurement + + - name: "BME280 Pressure" + state_topic: "sensor/bme280_pressure" + unit_of_measurement: "hPa" + device_class: atmospheric_pressure + state_class: measurement +``` + +### Automation example + +Trigger an alert when humidity exceeds 70%: + +```yaml +automation: + - alias: "High humidity alert" + trigger: + - platform: numeric_state + entity_id: sensor.bme280_humidity + above: 70 + action: + - service: notify.mobile_app + data: + message: "⚠️ Humidity is {{ states('sensor.bme280_humidity') }}%" +``` + +--- + +## 🛠️ Development + +### Install dev dependencies + +```bash +pip install -r requirements-dev.txt +``` + +### Run the test suite + +```bash +pytest tests/ -v +``` + +``` +tests/test_api.py::test_health_returns_ok PASSED +tests/test_api.py::test_index_returns_empty_json PASSED +tests/test_api.py::test_bme280_returns_sensor_data PASSED +tests/test_api.py::test_bme280_returns_503_when_sensor_unavailable PASSED +tests/test_api.py::test_publish_returns_200_with_topics PASSED +tests/test_api.py::test_publish_returns_503_when_sensor_unavailable PASSED +tests/test_api.py::test_publish_returns_502_when_mqtt_fails PASSED +tests/test_bme280.py::test_get_short_positive PASSED +tests/test_bme280.py::test_get_short_negative PASSED +tests/test_bme280.py::test_get_ushort PASSED +tests/test_bme280.py::test_get_char_positive PASSED +tests/test_bme280.py::test_get_char_negative PASSED +tests/test_bme280.py::test_get_uchar PASSED +tests/test_bme280.py::test_read_id_returns_chip_id_and_version PASSED +tests/test_bme280.py::test_read_id_custom_chip_id PASSED +tests/test_bme280.py::test_sensor_returns_required_keys PASSED +tests/test_bme280.py::test_sensor_data_has_three_measurements PASSED +tests/test_bme280.py::test_humidity_clamped_within_range PASSED +tests/test_bme280.py::test_pressure_zero_when_p1_calibration_is_zero PASSED + +19 passed in 0.22s +``` + +> 💡 All tests run without physical hardware — `smbus2` is fully mocked via `unittest.mock`. + +### Run tests in Docker + +```bash +docker compose run --rm test +``` + +--- + +## 📁 Project Structure + +``` +bme280/ +├── 📄 bme280.py # I²C driver — Bosch calibration algorithms +├── 📄 sensor_api.py # Flask HTTP API + MQTT publisher +│ +├── 🧪 tests/ +│ ├── test_bme280.py # Driver unit tests (hardware mocked) +│ └── test_api.py # API route tests (Flask test client) +│ +├── 🐳 Dockerfile # Multi-stage: test / app +├── 🐳 docker-compose.yml # test + app services +│ +├── ⚙️ .env.example # Configuration template +├── 📋 requirements.txt # Runtime dependencies +├── 📋 requirements-dev.txt # Dev/test dependencies +├── 🔧 pytest.ini # Pytest configuration +└── 🔒 .gitignore +``` + +### Key dependencies + +| Package | Version | Role | +|---------|---------|------| +| `smbus2` | ≥ 0.4.3 | I²C communication | +| `flask` | ≥ 3.0.0 | HTTP API framework | +| `paho-mqtt` | ≥ 1.6.1 | MQTT client | +| `python-dotenv` | ≥ 1.0.0 | `.env` file loading | + +--- + +## 📊 Sensor Specifications + +| Measurement | Range | Resolution | Accuracy | +|-------------|-------|------------|----------| +| 🌡️ Temperature | -40 °C → +85 °C | 0.01 °C | ± 1 °C | +| 💧 Humidity | 0 → 100 %RH | 0.008 %RH | ± 3 %RH | +| 🔵 Pressure | 300 → 1100 hPa | 0.008 hPa | ± 0.0018 hPa | + +--- -This is a cron job that actually sends the data via mqtt by calling the API every minute. +*Bosch BME280 datasheet: [BST-BME280-DS002][bme280-datasheet]* -`* * * * * curl 192.168.86.31:5000/bme280/publish` +[bme280-datasheet]: https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf From cd16bbdd56bf93072e4214670f4eb148092c8a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:17:31 +0200 Subject: [PATCH 12/18] ci: add flake8 and mypy lint jobs Add a lint job to the CI pipeline running flake8 (max-line-length=120) and mypy (--ignore-missing-imports) on bme280.py and sensor_api.py. Fix the one pre-existing E501 violation in bme280.py (line 127). Add flake8 and mypy to requirements-dev.txt. Co-authored-by: agilicode --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ bme280.py | 4 +++- requirements-dev.txt | 2 ++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58db596..de1a63c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,26 @@ on: branches: [develop, master] jobs: + lint: + name: Lint & type check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install lint dependencies + run: pip install flake8 mypy --quiet + + - name: flake8 + run: flake8 bme280.py sensor_api.py --max-line-length=120 + + - name: mypy + run: mypy bme280.py sensor_api.py --ignore-missing-imports + test: name: Build & test runs-on: ubuntu-latest diff --git a/bme280.py b/bme280.py index 497f1d3..1a29a1c 100644 --- a/bme280.py +++ b/bme280.py @@ -124,7 +124,9 @@ def sensor(addr: int = DEVICE_ADDRESS) -> dict: 'capabilities': { 'temperature': {'unit_of_measurement': '°C', 'min': -40, 'max': 85, 'resolution': 0.01, 'accuracy': 1}, 'humidity': {'unit_of_measurement': '%RH', 'min': 0, 'max': 100, 'resolution': 0.008, 'accuracy': 3}, - 'pressure': {'unit_of_measurement': 'hPa', 'min': 300, 'max': 1100, 'resolution': 0.008, 'accuracy': 0.0018}, + 'pressure': { + 'unit_of_measurement': 'hPa', 'min': 300, 'max': 1100, 'resolution': 0.008, 'accuracy': 0.0018, + }, }, 'data': { 'temperature': temperature, diff --git a/requirements-dev.txt b/requirements-dev.txt index 2693ad0..e832700 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,5 @@ -r requirements.txt pytest>=8.0.0 pytest-flask>=1.3.0 +flake8>=7.0.0 +mypy>=1.0.0 From 08134a4210303336d928eb8afe89d949b337534d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:48:58 +0200 Subject: [PATCH 13/18] fix: add soft reset and NVM copy wait on sensor init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Bosch BME280 SensorAPI spec (section 4.2), perform a soft reset (0xB6 → 0xE0) before each measurement to ensure a known state, then poll the status register (0xF3 bit 0) until the NVM copy completes. Raises OSError if NVM copy does not finish within 5 attempts. Add test covering the NVM timeout path. Co-authored-by: agilicode --- bme280.py | 14 ++++++++++++++ tests/test_bme280.py | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/bme280.py b/bme280.py index 1a29a1c..642468a 100644 --- a/bme280.py +++ b/bme280.py @@ -33,6 +33,15 @@ def read_id(addr: int = DEVICE_ADDRESS) -> tuple[int, int]: return chip_id, chip_version +def _wait_nvm_copy(bus: smbus2.SMBus, addr: int) -> None: + """Poll status register until NVM copy is complete after soft reset.""" + for _ in range(5): + if not (bus.read_byte_data(addr, 0xF3) & 0x01): + return + time.sleep(0.002) + raise OSError("BME280 NVM copy did not complete after soft reset") + + def read_all(addr: int = DEVICE_ADDRESS) -> tuple[float, float, float]: OVERSAMPLE_TEMP = 2 OVERSAMPLE_PRES = 2 @@ -40,6 +49,11 @@ def read_all(addr: int = DEVICE_ADDRESS) -> tuple[float, float, float]: MODE = 1 with smbus2.SMBus(I2C_BUS) as bus: + # Soft reset to ensure the sensor starts from a known state + bus.write_byte_data(addr, 0xE0, 0xB6) + time.sleep(0.002) # 2ms startup delay (datasheet section 4.2) + _wait_nvm_copy(bus, addr) + bus.write_byte_data(addr, 0xF2, OVERSAMPLE_HUM) bus.write_byte_data(addr, 0xF4, OVERSAMPLE_TEMP << 5 | OVERSAMPLE_PRES << 2 | MODE) diff --git a/tests/test_bme280.py b/tests/test_bme280.py index 0224e1d..6ec2bc9 100644 --- a/tests/test_bme280.py +++ b/tests/test_bme280.py @@ -5,6 +5,7 @@ def make_mock_bus(chip_id: int = 96, chip_version: int = 0) -> MagicMock: bus = MagicMock() + bus.read_byte_data.return_value = 0 # 0xF3 status: NVM copy done, not measuring bus.read_i2c_block_data.side_effect = [ [chip_id, chip_version], # read_id [0] * 24, # cal1 - T and P calibration @@ -107,3 +108,15 @@ def test_pressure_zero_when_p1_calibration_is_zero(patched_smbus): import bme280 result = bme280.sensor() assert result['data']['pressure'] == 0.0 + + +def test_nvm_copy_timeout_raises_oserror(): + bus = MagicMock() + bus.read_byte_data.return_value = 0x01 # NVM copy never completes + with patch('bme280.smbus2.SMBus') as MockSMBus: + instance = MockSMBus.return_value + instance.__enter__ = MagicMock(return_value=bus) + instance.__exit__ = MagicMock(return_value=False) + import bme280 + with pytest.raises(OSError, match="NVM copy"): + bme280.read_all() From 3eac2430cf1fd15a4ac02d9f6196acaa3f160356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:50:53 +0200 Subject: [PATCH 14/18] fix: poll measuring status register instead of fixed sleep only After the Appendix B minimum wait, poll 0xF3 bit 3 (measuring) until the sensor confirms measurement completion, with up to 20 retries at 1ms intervals. Add test verifying the poll loop is exercised when the measuring bit is initially active. Co-authored-by: agilicode --- bme280.py | 8 +++++++- tests/test_bme280.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/bme280.py b/bme280.py index 642468a..dc2374f 100644 --- a/bme280.py +++ b/bme280.py @@ -61,10 +61,16 @@ def read_all(addr: int = DEVICE_ADDRESS) -> tuple[float, float, float]: cal2 = bus.read_i2c_block_data(addr, 0xA1, 1) cal3 = bus.read_i2c_block_data(addr, 0xE1, 7) - # Datasheet Appendix B: measurement time formula + # Datasheet Appendix B: minimum wait before first status check wait_ms = 1.25 + (2.3 * OVERSAMPLE_TEMP) + ((2.3 * OVERSAMPLE_PRES) + 0.575) + ((2.3 * OVERSAMPLE_HUM) + 0.575) time.sleep(wait_ms / 1000) + # Poll measuring bit (0xF3 bit 3) until measurement is complete + for _ in range(20): + if not (bus.read_byte_data(addr, 0xF3) & 0x08): + break + time.sleep(0.001) + data = bus.read_i2c_block_data(addr, 0xF7, 8) dig_T1 = _get_ushort(cal1, 0) diff --git a/tests/test_bme280.py b/tests/test_bme280.py index 6ec2bc9..cc7ce23 100644 --- a/tests/test_bme280.py +++ b/tests/test_bme280.py @@ -120,3 +120,22 @@ def test_nvm_copy_timeout_raises_oserror(): import bme280 with pytest.raises(OSError, match="NVM copy"): bme280.read_all() + + +def test_read_all_polls_measuring_bit(): + bus = MagicMock() + # First call: NVM done (bit 0 = 0), then measuring active (bit 3 = 1), then done (0) + bus.read_byte_data.side_effect = [0x00, 0x08, 0x00] + bus.read_i2c_block_data.side_effect = [ + [0] * 24, + [0], + [0] * 7, + [0] * 8, + ] + with patch('bme280.smbus2.SMBus') as MockSMBus: + instance = MockSMBus.return_value + instance.__enter__ = MagicMock(return_value=bus) + instance.__exit__ = MagicMock(return_value=False) + import bme280 + temperature, pressure, humidity = bme280.read_all() + assert bus.read_byte_data.call_count == 3 From 290f6965028206715c43d7a936c3b97919563df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:52:44 +0200 Subject: [PATCH 15/18] feat: add IIR filter configuration via BME280_IIR_FILTER env var Write config register 0xF5 (bits [4:2]) before each measurement. Defaults to 0 (filter off) to preserve existing behavior. Recommended value for indoor use is 2 (4x coefficient). Document the new variable in .env.example and README. Co-authored-by: agilicode --- .env.example | 2 ++ README.md | 2 ++ bme280.py | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 34b95a7..389bd2d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # I2C configuration BME280_I2C_BUS=1 BME280_I2C_ADDRESS=0x77 +# IIR filter coefficient: 0=off 1=2x 2=4x 3=8x 4=16x (recommended: 2 for indoor use) +BME280_IIR_FILTER=0 # MQTT broker MQTT_BROKER_HOST=192.168.1.x diff --git a/README.md b/README.md index cc64217..771dc7b 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ All settings are driven by environment variables. Copy `.env.example` to `.env` |----------|---------|-------------| | `BME280_I2C_BUS` | `1` | I²C bus number (`1` for Pi Rev 2+, `0` for Rev 1) | | `BME280_I2C_ADDRESS` | `0x77` | Sensor I²C address (`0x77` or `0x76` depending on SDO pin) | +| `BME280_IIR_FILTER` | `0` | IIR filter coefficient: `0`=off, `1`=2×, `2`=4×, `3`=8×, `4`=16× (recommended: `2` for indoor use) | | `MQTT_BROKER_HOST` | `localhost` | IP or hostname of your MQTT broker | | `MQTT_USERNAME` | _(empty)_ | MQTT username (leave empty for anonymous) | | `MQTT_PASSWORD` | _(empty)_ | MQTT password | @@ -123,6 +124,7 @@ All settings are driven by environment variables. Copy `.env.example` to `.env` ```ini BME280_I2C_BUS=1 BME280_I2C_ADDRESS=0x77 +BME280_IIR_FILTER=0 MQTT_BROKER_HOST=192.168.1.10 MQTT_USERNAME=homeassistant diff --git a/bme280.py b/bme280.py index dc2374f..20dc1bc 100644 --- a/bme280.py +++ b/bme280.py @@ -7,6 +7,9 @@ I2C_BUS = int(os.environ.get('BME280_I2C_BUS', '1')) DEVICE_ADDRESS = int(os.environ.get('BME280_I2C_ADDRESS', '0x77'), 16) +# IIR filter coefficient: 0=off, 1=2, 2=4, 3=8, 4=16 (see datasheet table 28) +IIR_FILTER = int(os.environ.get('BME280_IIR_FILTER', '0')) + def _get_short(data: list[int], index: int) -> int: return c_short((data[index + 1] << 8) + data[index]).value @@ -54,6 +57,7 @@ def read_all(addr: int = DEVICE_ADDRESS) -> tuple[float, float, float]: time.sleep(0.002) # 2ms startup delay (datasheet section 4.2) _wait_nvm_copy(bus, addr) + bus.write_byte_data(addr, 0xF5, IIR_FILTER << 2) # config: filter bits [4:2] bus.write_byte_data(addr, 0xF2, OVERSAMPLE_HUM) bus.write_byte_data(addr, 0xF4, OVERSAMPLE_TEMP << 5 | OVERSAMPLE_PRES << 2 | MODE) From 6d924f74a37f85c49bdf04cda7c76fdf75a0613a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:54:36 +0200 Subject: [PATCH 16/18] fix: read chip ID with single byte read from 0xD0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The official Bosch SensorAPI reads only 1 byte from register 0xD0. Register 0xD1 is undocumented — reading it was a leftover from the original Matt Hawkins driver. Return (chip_id, 0) to preserve the existing tuple API without breaking callers. Update tests to assert read_byte_data is used and called with 0xD0. Co-authored-by: agilicode --- bme280.py | 4 ++-- tests/test_bme280.py | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bme280.py b/bme280.py index 20dc1bc..af2c7cb 100644 --- a/bme280.py +++ b/bme280.py @@ -32,8 +32,8 @@ def _get_uchar(data: list[int], index: int) -> int: def read_id(addr: int = DEVICE_ADDRESS) -> tuple[int, int]: with smbus2.SMBus(I2C_BUS) as bus: - chip_id, chip_version = bus.read_i2c_block_data(addr, 0xD0, 2) - return chip_id, chip_version + chip_id = bus.read_byte_data(addr, 0xD0) + return chip_id, 0 def _wait_nvm_copy(bus: smbus2.SMBus, addr: int) -> None: diff --git a/tests/test_bme280.py b/tests/test_bme280.py index cc7ce23..fbffb2c 100644 --- a/tests/test_bme280.py +++ b/tests/test_bme280.py @@ -3,15 +3,14 @@ import pytest -def make_mock_bus(chip_id: int = 96, chip_version: int = 0) -> MagicMock: +def make_mock_bus(chip_id: int = 96) -> MagicMock: bus = MagicMock() - bus.read_byte_data.return_value = 0 # 0xF3 status: NVM copy done, not measuring + bus.read_byte_data.side_effect = [chip_id, 0, 0] # read_id, NVM done, not measuring bus.read_i2c_block_data.side_effect = [ - [chip_id, chip_version], # read_id - [0] * 24, # cal1 - T and P calibration - [0], # cal2 - H1 - [0] * 7, # cal3 - H2-H6 - [0] * 8, # raw sensor data + [0] * 24, # cal1 - T and P calibration + [0], # cal2 - H1 + [0] * 7, # cal3 - H2-H6 + [0] * 8, # raw sensor data ] return bus @@ -64,11 +63,11 @@ def test_read_id_returns_chip_id_and_version(patched_smbus): import bme280 chip_id, chip_version = bme280.read_id() assert chip_id == 96 - assert chip_version == 0 + assert chip_version == 0 # always 0 — register 0xD1 is undocumented -def test_read_id_custom_chip_id(): - bus = make_mock_bus(chip_id=96, chip_version=0) +def test_read_id_uses_single_byte_read(): + bus = make_mock_bus(chip_id=96) with patch('bme280.smbus2.SMBus') as MockSMBus: instance = MockSMBus.return_value instance.__enter__ = MagicMock(return_value=bus) @@ -76,6 +75,7 @@ def test_read_id_custom_chip_id(): import bme280 chip_id, _ = bme280.read_id() assert chip_id == 96 + bus.read_byte_data.assert_called_with(bme280.DEVICE_ADDRESS, 0xD0) # --- sensor() structure --- @@ -112,7 +112,7 @@ def test_pressure_zero_when_p1_calibration_is_zero(patched_smbus): def test_nvm_copy_timeout_raises_oserror(): bus = MagicMock() - bus.read_byte_data.return_value = 0x01 # NVM copy never completes + bus.read_byte_data.side_effect = [0x01] * 10 # NVM copy never completes with patch('bme280.smbus2.SMBus') as MockSMBus: instance = MockSMBus.return_value instance.__enter__ = MagicMock(return_value=bus) From bba5f4f3e758265356445681d108f5ebb218d543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 11:58:59 +0200 Subject: [PATCH 17/18] docs: expand Home Assistant MQTT integration section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add end-to-end flow diagram (sensor → driver → API → broker → HA), prerequisites (Mosquitto add-on, user creation, MQTT integration setup), MQTT auto-discovery commands (Option A) as the recommended approach alongside the existing manual configuration.yaml config (Option B), and a troubleshooting table. Add ha-mqtt-discovery reference link. Co-authored-by: agilicode --- README.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 771dc7b..2518764 100644 --- a/README.md +++ b/README.md @@ -354,9 +354,89 @@ Reads sensor data and publishes each measurement to the configured MQTT broker. ## 🏠 Home Assistant Integration -### Manual MQTT sensors +### How it works + +``` +BME280 sensor + │ I²C + ▼ +bme280.py (driver) + │ temperature / pressure / humidity + ▼ +sensor_api.py (Flask) + │ GET /bme280/publish + ▼ +MQTT broker (Mosquitto) ◄── Home Assistant polls topics + │ + ├── sensor/bme280_temperature → 21.55 + ├── sensor/bme280_humidity → 44.57 + └── sensor/bme280_pressure → 1005.16 + │ + ▼ + Home Assistant dashboard +``` + +Every call to `/bme280/publish` (manually or via cron) pushes the three values to the broker. Home Assistant reads them in real time and updates the entity states. -Add to your `configuration.yaml`: +--- + +### Prerequisites + +#### 1. Install Mosquitto in Home Assistant + +In HA: **Settings → Add-ons → Mosquitto broker** → Install → Start. + +> 💡 Enable "Start on boot" to survive reboots. + +#### 2. Create a dedicated MQTT user + +**Settings → People → Users** → Add user (e.g. `rpi-bme280`). This user will be used by the Raspberry Pi to publish. + +#### 3. Enable the MQTT integration + +**Settings → Devices & Services → Add integration → MQTT** → point to `localhost:1883` with the user created above. + +#### 4. Get your credentials + +Fill your `.env` on the Pi with the values from the steps above: + +```ini +MQTT_BROKER_HOST=192.168.1.10 # HA host IP +MQTT_USERNAME=rpi-bme280 +MQTT_PASSWORD=your_password +``` + +> 🔒 Never commit `.env` — it is listed in `.gitignore`. + +--- + +### Option A — MQTT auto-discovery (recommended) + +Home Assistant supports [MQTT discovery][ha-mqtt-discovery]: publish a JSON config payload once and the entity appears automatically in the UI — no `configuration.yaml` edit needed. + +Run this once from the Pi (replace `` and credentials): + +```bash +mosquitto_pub -h -u rpi-bme280 -P \ + -t "homeassistant/sensor/bme280_temperature/config" \ + -m '{"name":"BME280 Temperature","state_topic":"sensor/bme280_temperature","unit_of_measurement":"°C","device_class":"temperature","state_class":"measurement","unique_id":"bme280_temperature"}' + +mosquitto_pub -h -u rpi-bme280 -P \ + -t "homeassistant/sensor/bme280_humidity/config" \ + -m '{"name":"BME280 Humidity","state_topic":"sensor/bme280_humidity","unit_of_measurement":"%","device_class":"humidity","state_class":"measurement","unique_id":"bme280_humidity"}' + +mosquitto_pub -h -u rpi-bme280 -P \ + -t "homeassistant/sensor/bme280_pressure/config" \ + -m '{"name":"BME280 Pressure","state_topic":"sensor/bme280_pressure","unit_of_measurement":"hPa","device_class":"atmospheric_pressure","state_class":"measurement","unique_id":"bme280_pressure"}' +``` + +The three entities appear under **Settings → Devices & Services → MQTT** within seconds. + +--- + +### Option B — Manual MQTT sensors + +If you prefer explicit config, add to your `configuration.yaml`: ```yaml mqtt: @@ -380,6 +460,10 @@ mqtt: state_class: measurement ``` +Then: **Developer Tools → YAML → Check configuration → Restart**. + +--- + ### Automation example Trigger an alert when humidity exceeds 70%: @@ -399,6 +483,17 @@ automation: --- +### Troubleshooting + +| Symptom | Check | +|---------|-------| +| Entity stuck at `unavailable` | Verify cron is running: `crontab -l` | +| `502` on `/bme280/publish` | Broker unreachable — check `MQTT_BROKER_HOST` and broker is up | +| No entity in HA after discovery | Check discovery is enabled in MQTT integration settings | +| Wrong values | Confirm `BME280_I2C_ADDRESS` matches your wiring (`0x76` vs `0x77`) | + +--- + ## 🛠️ Development ### Install dev dependencies @@ -492,3 +587,4 @@ bme280/ *Bosch BME280 datasheet: [BST-BME280-DS002][bme280-datasheet]* [bme280-datasheet]: https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf +[ha-mqtt-discovery]: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery From c2f98d1436e8aac1ef3a8c265fa6ae80ec794bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Thu, 14 May 2026 12:09:33 +0200 Subject: [PATCH 18/18] docs: split README into individual files under docs/ Move each major section (Hardware, Architecture, Installation, Configuration, Usage, API Reference, Home Assistant, Development, Project Structure) into its own markdown file under docs/. README.md is now a lightweight entry point: badges, quick start, and a navigation table linking to all docs/ files. Co-authored-by: agilicode --- README.md | 573 ++------------------------------------ docs/api-reference.md | 136 +++++++++ docs/architecture.md | 62 +++++ docs/configuration.md | 56 ++++ docs/development.md | 104 +++++++ docs/hardware.md | 59 ++++ docs/home-assistant.md | 139 +++++++++ docs/installation.md | 60 ++++ docs/project-structure.md | 65 +++++ docs/usage.md | 88 ++++++ 10 files changed, 785 insertions(+), 557 deletions(-) create mode 100644 docs/api-reference.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 docs/hardware.md create mode 100644 docs/home-assistant.md create mode 100644 docs/installation.md create mode 100644 docs/project-structure.md create mode 100644 docs/usage.md diff --git a/README.md b/README.md index 2518764..3f54589 100644 --- a/README.md +++ b/README.md @@ -9,582 +9,41 @@ --- -## 📋 Table of Contents - -- [Hardware](#-hardware) -- [Architecture](#-architecture) -- [Installation](#-installation) -- [Configuration](#️-configuration) -- [Usage](#-usage) - - [CLI](#cli) - - [HTTP API](#http-api) - - [Docker](#docker) -- [API Reference](#-api-reference) -- [Home Assistant Integration](#-home-assistant-integration) -- [Development](#-development) -- [Project Structure](#-project-structure) - ---- - -## 🔌 Hardware - -| Component | Details | -|-----------|---------| -| **Sensor** | Bosch BME280 — Waveshare Environmental Sensor (SKU 15231) | -| **Board** | Raspberry Pi Rev 2+ (I²C bus 1) | -| **Interface** | I²C — default address `0x77`, alternate `0x76` (SDO pin) | -| **Protocol** | SMBus via `/dev/i2c-1` | - -### Wiring (Raspberry Pi GPIO) - -``` -BME280 Raspberry Pi -────── ──────────── -VCC ──────► Pin 1 (3.3V) -GND ──────► Pin 6 (GND) -SDA ──────► Pin 3 (GPIO2 / SDA1) -SCL ──────► Pin 5 (GPIO3 / SCL1) -``` - -> 💡 Make sure I²C is enabled: `sudo raspi-config` → Interface Options → I2C → Enable - ---- - -## 🏗️ Architecture - -``` -┌─────────────────────────────────────────────────────┐ -│ Raspberry Pi │ -│ │ -│ ┌──────────────┐ ┌──────────────────────────┐ │ -│ │ bme280.py │ │ sensor_api.py │ │ -│ │ │ │ (Flask) │ │ -│ │ I²C driver │◄────│ GET /bme280 │ │ -│ │ Calibration │ │ GET /bme280/publish │ │ -│ │ algorithms │ │ GET /health │ │ -│ └──────┬───────┘ └───────────┬──────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌──────────────┐ ┌──────────────────────────┐ │ -│ │ BME280 chip │ │ MQTT Broker │ │ -│ │ (I²C 0x77) │ │ (Home Assistant) │ │ -│ └──────────────┘ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ -``` - ---- - -## 📦 Installation - -### Prerequisites - -```bash -# Enable I²C on Raspberry Pi -sudo raspi-config # Interface Options → I2C → Enable -sudo reboot -``` - -### Clone & install +## Quick start ```bash git clone https://github.com/guillaumedelre/bme280.git cd bme280 pip install -r requirements.txt -``` +cp .env.example .env # fill in your MQTT broker details -### Configure - -```bash -cp .env.example .env -# Edit .env with your MQTT broker details -nano .env -``` - ---- - -## ⚙️ Configuration - -All settings are driven by environment variables. Copy `.env.example` to `.env` and fill in your values. - -| Variable | Default | Description | -|----------|---------|-------------| -| `BME280_I2C_BUS` | `1` | I²C bus number (`1` for Pi Rev 2+, `0` for Rev 1) | -| `BME280_I2C_ADDRESS` | `0x77` | Sensor I²C address (`0x77` or `0x76` depending on SDO pin) | -| `BME280_IIR_FILTER` | `0` | IIR filter coefficient: `0`=off, `1`=2×, `2`=4×, `3`=8×, `4`=16× (recommended: `2` for indoor use) | -| `MQTT_BROKER_HOST` | `localhost` | IP or hostname of your MQTT broker | -| `MQTT_USERNAME` | _(empty)_ | MQTT username (leave empty for anonymous) | -| `MQTT_PASSWORD` | _(empty)_ | MQTT password | -| `MQTT_CLIENT_ID` | `rpi-bme280` | MQTT client identifier | -| `FLASK_PORT` | `5000` | HTTP port for the API | -| `FLASK_DEBUG` | `false` | Enable Flask debug mode (`true` / `false`) | - -**Example `.env`:** - -```ini -BME280_I2C_BUS=1 -BME280_I2C_ADDRESS=0x77 -BME280_IIR_FILTER=0 - -MQTT_BROKER_HOST=192.168.1.10 -MQTT_USERNAME=homeassistant -MQTT_PASSWORD=your_password_here -MQTT_CLIENT_ID=rpi-bme280 - -FLASK_PORT=5000 -FLASK_DEBUG=false +python sensor_api.py # API available at http://0.0.0.0:5000 ``` ---- - -## 🚀 Usage - -### CLI - -Read sensor data directly from the terminal (requires hardware): - -```bash -python bme280.py -``` - -``` -Chip ID : 96 -Version : 0 -Temperature : 21.55 °C -Pressure : 1005.16 hPa -Humidity : 44.57 %RH -``` - -Use an alternate I²C address: - ```bash -BME280_I2C_ADDRESS=0x76 python bme280.py -``` - -### HTTP API - -Start the API server: - -```bash -python sensor_api.py -# Listening on http://0.0.0.0:5000 -``` - -```bash -# Health check -curl http://rpi.local:5000/health - -# Read sensor data -curl http://rpi.local:5000/bme280 | python -m json.tool - -# Publish to MQTT +curl http://rpi.local:5000/bme280 curl http://rpi.local:5000/bme280/publish ``` -#### Automate with cron - -Publish sensor data every minute via cron: - -```bash -crontab -e -``` - -```cron -* * * * * curl -s http://localhost:5000/bme280/publish >> /var/log/bme280.log 2>&1 -``` - -### Docker - -#### Run the API in Docker (Raspberry Pi) - -```bash -# Build the app image -docker build --target app -t bme280-app . - -# Run with I²C device passthrough and env file -docker run -d \ - --name bme280 \ - --device /dev/i2c-1:/dev/i2c-1 \ - --env-file .env \ - -p 5000:5000 \ - bme280-app -``` - -Or with docker compose: - -```bash -# Edit docker-compose.yml and uncomment the devices section -docker compose up app -``` - -#### Run tests in Docker (no hardware required) - -```bash -docker compose run --rm test -# or -docker build --target test -t bme280-test . && docker run --rm bme280-test -``` - ---- - -## 📡 API Reference - -### `GET /health` - -Service health check. - -**Response `200`:** -```json -{ - "status": "ok" -} -``` - ---- - -### `GET /` - -Root endpoint. - -**Response `200`:** -```json -{} -``` - ---- - -### `GET /bme280` - -Returns current sensor readings with full device metadata. - -**Response `200`:** -```json -{ - "name": "bme280", - "brand": "Waveshare", - "part_number": "BME280 Environmental Sensor", - "sku": 15231, - "upc": 614961952638, - "chip": { - "id": 96, - "version": 0 - }, - "capabilities": { - "temperature": { - "unit_of_measurement": "°C", - "min": -40, - "max": 85, - "resolution": 0.01, - "accuracy": 1 - }, - "humidity": { - "unit_of_measurement": "%RH", - "min": 0, - "max": 100, - "resolution": 0.008, - "accuracy": 3 - }, - "pressure": { - "unit_of_measurement": "hPa", - "min": 300, - "max": 1100, - "resolution": 0.008, - "accuracy": 0.0018 - } - }, - "data": { - "temperature": 21.55, - "humidity": 44.57, - "pressure": 1005.16 - } -} -``` - -**Response `503`** — sensor unavailable (I²C error): -```json -{ - "error": "Sensor unavailable", - "detail": "[Errno 2] No such file or directory: '/dev/i2c-1'" -} -``` - ---- - -### `GET /bme280/publish` - -Reads sensor data and publishes each measurement to the configured MQTT broker. - -**MQTT topics published:** - -| Topic | Payload | Example | -|-------|---------|---------| -| `sensor/bme280_temperature` | float string | `21.55` | -| `sensor/bme280_humidity` | float string | `44.57` | -| `sensor/bme280_pressure` | float string | `1005.16` | - -**Response `200`:** -```json -{ - "published": true, - "topics": [ - "sensor/bme280_temperature", - "sensor/bme280_humidity", - "sensor/bme280_pressure" - ] -} -``` - -**Response `503`** — sensor unavailable: -```json -{ - "error": "Sensor unavailable", - "detail": "..." -} -``` - -**Response `502`** — MQTT broker unreachable: -```json -{ - "error": "MQTT publish failed", - "detail": "Connection refused" -} -``` - ---- - -## 🏠 Home Assistant Integration - -### How it works - -``` -BME280 sensor - │ I²C - ▼ -bme280.py (driver) - │ temperature / pressure / humidity - ▼ -sensor_api.py (Flask) - │ GET /bme280/publish - ▼ -MQTT broker (Mosquitto) ◄── Home Assistant polls topics - │ - ├── sensor/bme280_temperature → 21.55 - ├── sensor/bme280_humidity → 44.57 - └── sensor/bme280_pressure → 1005.16 - │ - ▼ - Home Assistant dashboard -``` - -Every call to `/bme280/publish` (manually or via cron) pushes the three values to the broker. Home Assistant reads them in real time and updates the entity states. - ---- - -### Prerequisites - -#### 1. Install Mosquitto in Home Assistant - -In HA: **Settings → Add-ons → Mosquitto broker** → Install → Start. - -> 💡 Enable "Start on boot" to survive reboots. - -#### 2. Create a dedicated MQTT user - -**Settings → People → Users** → Add user (e.g. `rpi-bme280`). This user will be used by the Raspberry Pi to publish. - -#### 3. Enable the MQTT integration - -**Settings → Devices & Services → Add integration → MQTT** → point to `localhost:1883` with the user created above. - -#### 4. Get your credentials - -Fill your `.env` on the Pi with the values from the steps above: - -```ini -MQTT_BROKER_HOST=192.168.1.10 # HA host IP -MQTT_USERNAME=rpi-bme280 -MQTT_PASSWORD=your_password -``` - -> 🔒 Never commit `.env` — it is listed in `.gitignore`. - ---- - -### Option A — MQTT auto-discovery (recommended) - -Home Assistant supports [MQTT discovery][ha-mqtt-discovery]: publish a JSON config payload once and the entity appears automatically in the UI — no `configuration.yaml` edit needed. - -Run this once from the Pi (replace `` and credentials): - -```bash -mosquitto_pub -h -u rpi-bme280 -P \ - -t "homeassistant/sensor/bme280_temperature/config" \ - -m '{"name":"BME280 Temperature","state_topic":"sensor/bme280_temperature","unit_of_measurement":"°C","device_class":"temperature","state_class":"measurement","unique_id":"bme280_temperature"}' - -mosquitto_pub -h -u rpi-bme280 -P \ - -t "homeassistant/sensor/bme280_humidity/config" \ - -m '{"name":"BME280 Humidity","state_topic":"sensor/bme280_humidity","unit_of_measurement":"%","device_class":"humidity","state_class":"measurement","unique_id":"bme280_humidity"}' - -mosquitto_pub -h -u rpi-bme280 -P \ - -t "homeassistant/sensor/bme280_pressure/config" \ - -m '{"name":"BME280 Pressure","state_topic":"sensor/bme280_pressure","unit_of_measurement":"hPa","device_class":"atmospheric_pressure","state_class":"measurement","unique_id":"bme280_pressure"}' -``` - -The three entities appear under **Settings → Devices & Services → MQTT** within seconds. - ---- - -### Option B — Manual MQTT sensors - -If you prefer explicit config, add to your `configuration.yaml`: - -```yaml -mqtt: - sensor: - - name: "BME280 Temperature" - state_topic: "sensor/bme280_temperature" - unit_of_measurement: "°C" - device_class: temperature - state_class: measurement - - - name: "BME280 Humidity" - state_topic: "sensor/bme280_humidity" - unit_of_measurement: "%" - device_class: humidity - state_class: measurement - - - name: "BME280 Pressure" - state_topic: "sensor/bme280_pressure" - unit_of_measurement: "hPa" - device_class: atmospheric_pressure - state_class: measurement -``` - -Then: **Developer Tools → YAML → Check configuration → Restart**. - ---- - -### Automation example - -Trigger an alert when humidity exceeds 70%: - -```yaml -automation: - - alias: "High humidity alert" - trigger: - - platform: numeric_state - entity_id: sensor.bme280_humidity - above: 70 - action: - - service: notify.mobile_app - data: - message: "⚠️ Humidity is {{ states('sensor.bme280_humidity') }}%" -``` - ---- - -### Troubleshooting - -| Symptom | Check | -|---------|-------| -| Entity stuck at `unavailable` | Verify cron is running: `crontab -l` | -| `502` on `/bme280/publish` | Broker unreachable — check `MQTT_BROKER_HOST` and broker is up | -| No entity in HA after discovery | Check discovery is enabled in MQTT integration settings | -| Wrong values | Confirm `BME280_I2C_ADDRESS` matches your wiring (`0x76` vs `0x77`) | - ---- - -## 🛠️ Development - -### Install dev dependencies - -```bash -pip install -r requirements-dev.txt -``` - -### Run the test suite - -```bash -pytest tests/ -v -``` - -``` -tests/test_api.py::test_health_returns_ok PASSED -tests/test_api.py::test_index_returns_empty_json PASSED -tests/test_api.py::test_bme280_returns_sensor_data PASSED -tests/test_api.py::test_bme280_returns_503_when_sensor_unavailable PASSED -tests/test_api.py::test_publish_returns_200_with_topics PASSED -tests/test_api.py::test_publish_returns_503_when_sensor_unavailable PASSED -tests/test_api.py::test_publish_returns_502_when_mqtt_fails PASSED -tests/test_bme280.py::test_get_short_positive PASSED -tests/test_bme280.py::test_get_short_negative PASSED -tests/test_bme280.py::test_get_ushort PASSED -tests/test_bme280.py::test_get_char_positive PASSED -tests/test_bme280.py::test_get_char_negative PASSED -tests/test_bme280.py::test_get_uchar PASSED -tests/test_bme280.py::test_read_id_returns_chip_id_and_version PASSED -tests/test_bme280.py::test_read_id_custom_chip_id PASSED -tests/test_bme280.py::test_sensor_returns_required_keys PASSED -tests/test_bme280.py::test_sensor_data_has_three_measurements PASSED -tests/test_bme280.py::test_humidity_clamped_within_range PASSED -tests/test_bme280.py::test_pressure_zero_when_p1_calibration_is_zero PASSED - -19 passed in 0.22s -``` - -> 💡 All tests run without physical hardware — `smbus2` is fully mocked via `unittest.mock`. - -### Run tests in Docker - -```bash -docker compose run --rm test -``` - ---- - -## 📁 Project Structure - -``` -bme280/ -├── 📄 bme280.py # I²C driver — Bosch calibration algorithms -├── 📄 sensor_api.py # Flask HTTP API + MQTT publisher -│ -├── 🧪 tests/ -│ ├── test_bme280.py # Driver unit tests (hardware mocked) -│ └── test_api.py # API route tests (Flask test client) -│ -├── 🐳 Dockerfile # Multi-stage: test / app -├── 🐳 docker-compose.yml # test + app services -│ -├── ⚙️ .env.example # Configuration template -├── 📋 requirements.txt # Runtime dependencies -├── 📋 requirements-dev.txt # Dev/test dependencies -├── 🔧 pytest.ini # Pytest configuration -└── 🔒 .gitignore -``` - -### Key dependencies - -| Package | Version | Role | -|---------|---------|------| -| `smbus2` | ≥ 0.4.3 | I²C communication | -| `flask` | ≥ 3.0.0 | HTTP API framework | -| `paho-mqtt` | ≥ 1.6.1 | MQTT client | -| `python-dotenv` | ≥ 1.0.0 | `.env` file loading | - --- -## 📊 Sensor Specifications +## Documentation -| Measurement | Range | Resolution | Accuracy | -|-------------|-------|------------|----------| -| 🌡️ Temperature | -40 °C → +85 °C | 0.01 °C | ± 1 °C | -| 💧 Humidity | 0 → 100 %RH | 0.008 %RH | ± 3 %RH | -| 🔵 Pressure | 300 → 1100 hPa | 0.008 hPa | ± 0.0018 hPa | +| Topic | File | +|-------|------| +| Hardware wiring and I²C setup | [docs/hardware.md](docs/hardware.md) | +| System architecture and data flow | [docs/architecture.md](docs/architecture.md) | +| Installation and prerequisites | [docs/installation.md](docs/installation.md) | +| Environment variable reference | [docs/configuration.md](docs/configuration.md) | +| CLI, HTTP API, cron, Docker usage | [docs/usage.md](docs/usage.md) | +| REST API endpoint reference | [docs/api-reference.md](docs/api-reference.md) | +| Home Assistant + MQTT integration | [docs/home-assistant.md](docs/home-assistant.md) | +| Test suite, lint, CI pipeline | [docs/development.md](docs/development.md) | +| Project structure and dependencies | [docs/project-structure.md](docs/project-structure.md) | --- *Bosch BME280 datasheet: [BST-BME280-DS002][bme280-datasheet]* [bme280-datasheet]: https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf -[ha-mqtt-discovery]: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..e357111 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,136 @@ +# 📡 API Reference + +Base URL: `http://:5000` + +--- + +## `GET /health` + +Service liveness check. + +**Response `200`:** +```json +{ + "status": "ok" +} +``` + +--- + +## `GET /` + +Root endpoint. + +**Response `200`:** +```json +{} +``` + +--- + +## `GET /bme280` + +Returns current sensor readings with full device metadata. + +**Response `200`:** +```json +{ + "name": "bme280", + "brand": "Waveshare", + "part_number": "BME280 Environmental Sensor", + "sku": 15231, + "upc": 614961952638, + "chip": { + "id": 96, + "version": 0 + }, + "capabilities": { + "temperature": { + "unit_of_measurement": "°C", + "min": -40, + "max": 85, + "resolution": 0.01, + "accuracy": 1 + }, + "humidity": { + "unit_of_measurement": "%RH", + "min": 0, + "max": 100, + "resolution": 0.008, + "accuracy": 3 + }, + "pressure": { + "unit_of_measurement": "hPa", + "min": 300, + "max": 1100, + "resolution": 0.008, + "accuracy": 0.0018 + } + }, + "data": { + "temperature": 21.55, + "humidity": 44.57, + "pressure": 1005.16 + } +} +``` + +**Response `503`** — sensor unavailable (I²C error): +```json +{ + "error": "Sensor unavailable", + "detail": "[Errno 2] No such file or directory: '/dev/i2c-1'" +} +``` + +--- + +## `GET /bme280/publish` + +Reads sensor data and publishes each measurement to the configured MQTT broker. + +**MQTT topics published:** + +| Topic | Payload | Example | +|-------|---------|---------| +| `sensor/bme280_temperature` | float string | `21.55` | +| `sensor/bme280_humidity` | float string | `44.57` | +| `sensor/bme280_pressure` | float string | `1005.16` | + +**Response `200`:** +```json +{ + "published": true, + "topics": [ + "sensor/bme280_temperature", + "sensor/bme280_humidity", + "sensor/bme280_pressure" + ] +} +``` + +**Response `503`** — sensor unavailable: +```json +{ + "error": "Sensor unavailable", + "detail": "..." +} +``` + +**Response `502`** — MQTT broker unreachable: +```json +{ + "error": "MQTT publish failed", + "detail": "Connection refused" +} +``` + +--- + +## Error handling summary + +| HTTP Status | Meaning | Trigger | +|-------------|---------|---------| +| `200` | Success | Normal operation | +| `503` | Sensor unavailable | I²C bus error, sensor disconnected | +| `502` | Bad gateway | MQTT broker unreachable or refused connection | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0ea235d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,62 @@ +# 🏗️ Architecture + +## System Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ Raspberry Pi │ +│ │ +│ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ bme280.py │ │ sensor_api.py │ │ +│ │ │ │ (Flask) │ │ +│ │ I²C driver │◄────│ GET /bme280 │ │ +│ │ Calibration │ │ GET /bme280/publish │ │ +│ │ algorithms │ │ GET /health │ │ +│ └──────┬───────┘ └───────────┬──────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ BME280 chip │ │ MQTT Broker │ │ +│ │ (I²C 0x77) │ │ (Home Assistant) │ │ +│ └──────────────┘ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Components + +### `bme280.py` — I²C Driver + +Low-level sensor driver. Handles: + +- Soft reset and NVM copy wait on each read (Bosch SensorAPI spec) +- Calibration data extraction from EEPROM registers (`0x88`, `0xA1`, `0xE1`) +- Raw data reading from `0xF7` (8 bytes: pressure, temperature, humidity) +- Bosch compensation algorithms (double-precision floating point) +- IIR filter configuration via register `0xF5` +- Measurement completion polling via status register `0xF3` + +### `sensor_api.py` — HTTP API + +Flask application exposing three routes: + +| Route | Role | +|-------|------| +| `GET /health` | Service liveness check | +| `GET /bme280` | Returns full sensor reading as JSON | +| `GET /bme280/publish` | Reads sensor and publishes to MQTT broker | + +All configuration (MQTT credentials, I²C address, Flask port) is loaded from environment variables via `python-dotenv`. + +## Data Flow + +``` +BME280 chip ──I²C──► bme280.py ──► sensor_api.py ──HTTP──► client (curl / cron) + │ + MQTT publish + │ + ▼ + MQTT Broker + │ + ▼ + Home Assistant +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..9ebe318 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,56 @@ +# ⚙️ Configuration + +All settings are driven by environment variables. Copy `.env.example` to `.env` and fill in your values. + +```bash +cp .env.example .env +``` + +> 🔒 `.env` is listed in `.gitignore` — it will never be committed. + +## Reference + +### Sensor (I²C) + +| Variable | Default | Description | +|----------|---------|-------------| +| `BME280_I2C_BUS` | `1` | I²C bus number (`1` for Pi Rev 2+, `0` for Rev 1) | +| `BME280_I2C_ADDRESS` | `0x77` | Sensor I²C address (`0x77` or `0x76` depending on SDO pin wiring) | +| `BME280_IIR_FILTER` | `0` | IIR filter coefficient: `0`=off, `1`=2×, `2`=4×, `3`=8×, `4`=16× | + +> 💡 For indoor use (stable environment), `BME280_IIR_FILTER=2` (4× coefficient) smooths out pressure spikes from door slams or air currents. + +### MQTT + +| Variable | Default | Description | +|----------|---------|-------------| +| `MQTT_BROKER_HOST` | `localhost` | IP or hostname of your MQTT broker | +| `MQTT_USERNAME` | _(empty)_ | MQTT username (leave empty for anonymous connections) | +| `MQTT_PASSWORD` | _(empty)_ | MQTT password | +| `MQTT_CLIENT_ID` | `rpi-bme280` | MQTT client identifier — must be unique per broker | + +### Flask API + +| Variable | Default | Description | +|----------|---------|-------------| +| `FLASK_PORT` | `5000` | HTTP port for the API server | +| `FLASK_DEBUG` | `false` | Enable Flask debug mode (`true` / `false`) — keep `false` in production | + +## Example `.env` + +```ini +# Sensor +BME280_I2C_BUS=1 +BME280_I2C_ADDRESS=0x77 +BME280_IIR_FILTER=0 + +# MQTT +MQTT_BROKER_HOST=192.168.1.10 +MQTT_USERNAME=rpi-bme280 +MQTT_PASSWORD=your_password_here +MQTT_CLIENT_ID=rpi-bme280 + +# Flask +FLASK_PORT=5000 +FLASK_DEBUG=false +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..3cbdd56 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,104 @@ +# 🛠️ Development + +## Install dev dependencies + +```bash +pip install -r requirements-dev.txt +``` + +## Run the test suite + +```bash +pytest tests/ -v +``` + +Expected output: + +``` +tests/test_api.py::test_health_returns_ok PASSED +tests/test_api.py::test_index_returns_empty_json PASSED +tests/test_api.py::test_bme280_returns_sensor_data PASSED +tests/test_api.py::test_bme280_returns_503_when_sensor_unavailable PASSED +tests/test_api.py::test_publish_returns_200_with_topics PASSED +tests/test_api.py::test_publish_returns_503_when_sensor_unavailable PASSED +tests/test_api.py::test_publish_returns_502_when_mqtt_fails PASSED +tests/test_bme280.py::test_get_short_positive PASSED +tests/test_bme280.py::test_get_short_negative PASSED +tests/test_bme280.py::test_get_ushort PASSED +tests/test_bme280.py::test_get_char_positive PASSED +tests/test_bme280.py::test_get_char_negative PASSED +tests/test_bme280.py::test_get_uchar PASSED +tests/test_bme280.py::test_read_id_returns_chip_id_and_version PASSED +tests/test_bme280.py::test_read_id_custom_chip_id PASSED +tests/test_bme280.py::test_nvm_copy_timeout_raises_oserror PASSED +tests/test_bme280.py::test_sensor_returns_required_keys PASSED +tests/test_bme280.py::test_sensor_data_has_three_measurements PASSED +tests/test_bme280.py::test_humidity_clamped_within_range PASSED +tests/test_bme280.py::test_pressure_zero_when_p1_calibration_is_zero PASSED + +20 passed in 0.22s +``` + +> All tests run without physical hardware — `smbus2` is fully mocked via `unittest.mock`. + +## Run tests in Docker + +No hardware required. The `test` target in the Dockerfile installs dev dependencies and runs pytest. + +```bash +docker compose run --rm test +# or directly: +docker build --target test -t bme280-test . && docker run --rm bme280-test +``` + +## Lint and type-check + +```bash +# Style (PEP 8, max line length 120) +flake8 bme280.py sensor_api.py tests/ + +# Static type checking +mypy bme280.py sensor_api.py +``` + +Both tools are configured via `setup.cfg` / `pytest.ini` and run automatically in the GitHub Actions CI pipeline on every push to `develop`. + +## CI pipeline + +Three jobs run in parallel on each push: + +| Job | Tool | What it checks | +|-----|------|----------------| +| `lint` | flake8 + mypy | Style and type correctness | +| `test` | Docker + pytest | All 20 unit tests (no hardware) | +| `security` | pip-audit | Known CVEs in dependencies | + +See `.github/workflows/ci.yml` for the full configuration. + +## Test architecture + +### Driver tests (`tests/test_bme280.py`) + +The I²C bus (`smbus2.SMBus`) is patched at the class level so no hardware is required: + +```python +@patch("bme280.smbus2.SMBus") +def test_sensor_returns_required_keys(mock_smbus_cls: MagicMock) -> None: + mock_smbus_cls.return_value.__enter__.return_value = make_mock_bus() + result = sensor() + assert "data" in result +``` + +The `make_mock_bus()` helper configures realistic `side_effect` sequences that mirror actual I²C traffic: chip ID read, NVM copy status, calibration registers, raw measurement bytes. + +### API tests (`tests/test_api.py`) + +The Flask test client is used with `sensor` and `mqtt_publish.multiple` patched: + +```python +@patch("sensor_api.sensor") +def test_bme280_returns_sensor_data(mock_sensor: MagicMock, client: FlaskClient) -> None: + mock_sensor.return_value = {...} + response = client.get("/bme280") + assert response.status_code == 200 +``` diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 0000000..bb795e7 --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,59 @@ +# 🔌 Hardware + +## Components + +| Component | Details | +|-----------|---------| +| **Sensor** | Bosch BME280 — Waveshare Environmental Sensor (SKU 15231) | +| **Board** | Raspberry Pi Rev 2+ (I²C bus 1) | +| **Interface** | I²C — default address `0x77`, alternate `0x76` (SDO pin) | +| **Protocol** | SMBus via `/dev/i2c-1` | + +## Wiring (Raspberry Pi GPIO) + +``` +BME280 Raspberry Pi +────── ──────────── +VCC ──────► Pin 1 (3.3V) +GND ──────► Pin 6 (GND) +SDA ──────► Pin 3 (GPIO2 / SDA1) +SCL ──────► Pin 5 (GPIO3 / SCL1) +``` + +> 💡 Make sure I²C is enabled: `sudo raspi-config` → Interface Options → I2C → Enable + +## I²C Address + +The BME280 supports two I²C addresses depending on the SDO pin wiring: + +| SDO pin | Address | +|---------|---------| +| GND | `0x76` | +| VCC | `0x77` (default) | + +Override via environment variable: `BME280_I2C_ADDRESS=0x76` (see [Configuration](configuration.md)). + +## Sensor Specifications + +| Measurement | Range | Resolution | Accuracy | +|-------------|-------|------------|----------| +| 🌡️ Temperature | -40 °C → +85 °C | 0.01 °C | ± 1 °C | +| 💧 Humidity | 0 → 100 %RH | 0.008 %RH | ± 3 %RH | +| 🔵 Pressure | 300 → 1100 hPa | 0.008 hPa | ± 0.0018 hPa | + +## Enabling I²C on Raspberry Pi + +```bash +sudo raspi-config # Interface Options → I2C → Enable +sudo reboot + +# Verify the sensor is detected +i2cdetect -y 1 +# Expected: address 0x77 (or 0x76) appears in the grid +``` + +--- + +*Bosch BME280 datasheet: [BST-BME280-DS002][bme280-datasheet]* + +[bme280-datasheet]: https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf diff --git a/docs/home-assistant.md b/docs/home-assistant.md new file mode 100644 index 0000000..a4012b8 --- /dev/null +++ b/docs/home-assistant.md @@ -0,0 +1,139 @@ +# 🏠 Home Assistant Integration + +## How it works + +``` +BME280 sensor + │ I²C + ▼ +bme280.py (driver) + │ temperature / pressure / humidity + ▼ +sensor_api.py (Flask) + │ GET /bme280/publish + ▼ +MQTT broker (Mosquitto) ◄── Home Assistant polls topics + │ + ├── sensor/bme280_temperature → 21.55 + ├── sensor/bme280_humidity → 44.57 + └── sensor/bme280_pressure → 1005.16 + │ + ▼ + Home Assistant dashboard +``` + +Every call to `/bme280/publish` (manually or via cron) pushes the three values to the broker. Home Assistant reads them in real time and updates the entity states. + +--- + +## Prerequisites + +### 1. Install Mosquitto in Home Assistant + +In HA: **Settings → Add-ons → Mosquitto broker** → Install → Start. + +> 💡 Enable "Start on boot" to survive reboots. + +### 2. Create a dedicated MQTT user + +**Settings → People → Users** → Add user (e.g. `rpi-bme280`). This user will be used by the Raspberry Pi to publish. + +### 3. Enable the MQTT integration + +**Settings → Devices & Services → Add integration → MQTT** → point to `localhost:1883` with the user created above. + +### 4. Set credentials in `.env` + +```ini +MQTT_BROKER_HOST=192.168.1.10 # HA host IP +MQTT_USERNAME=rpi-bme280 +MQTT_PASSWORD=your_password +``` + +> 🔒 Never commit `.env` — it is listed in `.gitignore`. + +--- + +## Option A — MQTT auto-discovery (recommended) + +Home Assistant supports [MQTT discovery][ha-mqtt-discovery]: publish a JSON config payload once and the entity appears automatically in the UI — no `configuration.yaml` edit needed. + +Run this once from the Pi (replace `` and credentials): + +```bash +mosquitto_pub -h -u rpi-bme280 -P \ + -t "homeassistant/sensor/bme280_temperature/config" \ + -m '{"name":"BME280 Temperature","state_topic":"sensor/bme280_temperature","unit_of_measurement":"°C","device_class":"temperature","state_class":"measurement","unique_id":"bme280_temperature"}' + +mosquitto_pub -h -u rpi-bme280 -P \ + -t "homeassistant/sensor/bme280_humidity/config" \ + -m '{"name":"BME280 Humidity","state_topic":"sensor/bme280_humidity","unit_of_measurement":"%","device_class":"humidity","state_class":"measurement","unique_id":"bme280_humidity"}' + +mosquitto_pub -h -u rpi-bme280 -P \ + -t "homeassistant/sensor/bme280_pressure/config" \ + -m '{"name":"BME280 Pressure","state_topic":"sensor/bme280_pressure","unit_of_measurement":"hPa","device_class":"atmospheric_pressure","state_class":"measurement","unique_id":"bme280_pressure"}' +``` + +The three entities appear under **Settings → Devices & Services → MQTT** within seconds. + +--- + +## Option B — Manual MQTT sensors + +If you prefer explicit config, add to your `configuration.yaml`: + +```yaml +mqtt: + sensor: + - name: "BME280 Temperature" + state_topic: "sensor/bme280_temperature" + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + + - name: "BME280 Humidity" + state_topic: "sensor/bme280_humidity" + unit_of_measurement: "%" + device_class: humidity + state_class: measurement + + - name: "BME280 Pressure" + state_topic: "sensor/bme280_pressure" + unit_of_measurement: "hPa" + device_class: atmospheric_pressure + state_class: measurement +``` + +Then: **Developer Tools → YAML → Check configuration → Restart**. + +--- + +## Automation example + +Trigger an alert when humidity exceeds 70%: + +```yaml +automation: + - alias: "High humidity alert" + trigger: + - platform: numeric_state + entity_id: sensor.bme280_humidity + above: 70 + action: + - service: notify.mobile_app + data: + message: "⚠️ Humidity is {{ states('sensor.bme280_humidity') }}%" +``` + +--- + +## Troubleshooting + +| Symptom | Check | +|---------|-------| +| Entity stuck at `unavailable` | Verify cron is running: `crontab -l` | +| `502` on `/bme280/publish` | Broker unreachable — check `MQTT_BROKER_HOST` and broker is up | +| No entity in HA after discovery | Check discovery is enabled in MQTT integration settings | +| Wrong values | Confirm `BME280_I2C_ADDRESS` matches your wiring (`0x76` vs `0x77`) | + +[ha-mqtt-discovery]: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..02003fb --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,60 @@ +# 📦 Installation + +## Prerequisites + +- Raspberry Pi Rev 2+ with Raspbian / Raspberry Pi OS +- BME280 sensor wired via I²C (see [Hardware](hardware.md)) +- Python 3.10+ +- I²C enabled on the Pi + +```bash +# Enable I²C +sudo raspi-config # Interface Options → I2C → Enable +sudo reboot +``` + +## Clone & install + +```bash +git clone https://github.com/guillaumedelre/bme280.git +cd bme280 + +pip install -r requirements.txt +``` + +## Configure + +```bash +cp .env.example .env +nano .env # fill in your MQTT broker details +``` + +See [Configuration](configuration.md) for the full list of available variables. + +## Verify hardware + +```bash +# Check the sensor is detected on the I²C bus +i2cdetect -y 1 +# Address 0x77 (or 0x76) should appear + +# Run a quick CLI read +python bme280.py +``` + +Expected output: + +``` +Chip ID : 96 +Version : 0 +Temperature : 21.55 °C +Pressure : 1005.16 hPa +Humidity : 44.57 %RH +``` + +## Start the API + +```bash +python sensor_api.py +# Listening on http://0.0.0.0:5000 +``` diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..2d6a27a --- /dev/null +++ b/docs/project-structure.md @@ -0,0 +1,65 @@ +# 📁 Project Structure + +``` +bme280/ +├── bme280.py # I²C driver — Bosch calibration algorithms +├── sensor_api.py # Flask HTTP API + MQTT publisher +│ +├── tests/ +│ ├── test_bme280.py # Driver unit tests (hardware mocked) +│ └── test_api.py # API route tests (Flask test client) +│ +├── docs/ +│ ├── hardware.md # Components, wiring, I²C address +│ ├── architecture.md # System diagram, component descriptions +│ ├── installation.md # Prerequisites, clone, configure +│ ├── configuration.md # Full environment variable reference +│ ├── usage.md # CLI, HTTP API, cron, Docker +│ ├── api-reference.md # Endpoint documentation with examples +│ ├── home-assistant.md # MQTT auto-discovery, HA integration +│ ├── development.md # Test suite, lint, CI pipeline +│ └── project-structure.md +│ +├── .github/ +│ └── workflows/ +│ └── ci.yml # CI: lint, test, security (pip-audit) +│ +├── Dockerfile # Multi-stage: test target + app target +├── docker-compose.yml # Services: test, app +│ +├── .env.example # Configuration template +├── requirements.txt # Runtime dependencies +├── requirements-dev.txt # Dev/test dependencies +├── pytest.ini # Pytest configuration +└── .gitignore +``` + +## Key dependencies + +| Package | Version | Role | +|---------|---------|------| +| `smbus2` | >= 0.4.3 | I²C communication via `/dev/i2c-*` | +| `flask` | >= 3.0.0 | HTTP API framework | +| `paho-mqtt` | >= 1.6.1 | MQTT client (fire-and-forget publish) | +| `python-dotenv` | >= 1.0.0 | `.env` file loading at startup | + +Dev/test only: + +| Package | Version | Role | +|---------|---------|------| +| `pytest` | >= 8.0.0 | Test runner | +| `pytest-flask` | >= 1.3.0 | Flask test client fixture | +| `flake8` | >= 7.0.0 | Style linter (PEP 8, max line 120) | +| `mypy` | >= 1.0.0 | Static type checker | + +## Sensor Specifications + +| Measurement | Range | Resolution | Accuracy | +|-------------|-------|------------|----------| +| Temperature | -40 °C to +85 °C | 0.01 °C | +/- 1 °C | +| Humidity | 0 to 100 %RH | 0.008 %RH | +/- 3 %RH | +| Pressure | 300 to 1100 hPa | 0.008 hPa | +/- 0.0018 hPa | + +[BME280 datasheet][bme280-datasheet] + +[bme280-datasheet]: https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..2edf80e --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,88 @@ +# 🚀 Usage + +## CLI + +Read sensor data directly from the terminal (requires hardware): + +```bash +python bme280.py +``` + +``` +Chip ID : 96 +Version : 0 +Temperature : 21.55 °C +Pressure : 1005.16 hPa +Humidity : 44.57 %RH +``` + +Use an alternate I²C address: + +```bash +BME280_I2C_ADDRESS=0x76 python bme280.py +``` + +## HTTP API + +Start the API server: + +```bash +python sensor_api.py +# Listening on http://0.0.0.0:5000 +``` + +```bash +# Health check +curl http://rpi.local:5000/health + +# Read sensor data +curl http://rpi.local:5000/bme280 | python -m json.tool + +# Publish to MQTT +curl http://rpi.local:5000/bme280/publish +``` + +See [API Reference](api-reference.md) for the full endpoint documentation. + +## Automate with cron + +Publish sensor data every minute: + +```bash +crontab -e +``` + +```cron +* * * * * curl -s http://localhost:5000/bme280/publish >> /var/log/bme280.log 2>&1 +``` + +## Docker + +### Run the API (Raspberry Pi) + +```bash +# Build the app image +docker build --target app -t bme280-app . + +# Run with I²C device passthrough and env file +docker run -d \ + --name bme280 \ + --device /dev/i2c-1:/dev/i2c-1 \ + --env-file .env \ + -p 5000:5000 \ + bme280-app +``` + +Or with docker compose (uncomment `devices` in `docker-compose.yml` first): + +```bash +docker compose up app +``` + +### Run tests (no hardware required) + +```bash +docker compose run --rm test +# or +docker build --target test -t bme280-test . && docker run --rm bme280-test +```