Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
020b41e
http api on port 5000 + mqtt publication
guillaumedelre Mar 8, 2020
76b2861
up date readme
guillaumedelre Mar 8, 2020
05fa496
chore: add .gitignore and untrack compiled bytecode
guillaumedelre May 14, 2026
e66eab1
Merge pull request #2 from guillaumedelre/feat/add-gitignore
guillaumedelre May 14, 2026
ea06f84
feat: migrate driver to Python 3 with smbus2 and type hints
guillaumedelre May 14, 2026
428a2b7
Merge pull request #3 from guillaumedelre/feat/python3-migration
guillaumedelre May 14, 2026
156e777
feat(api): externalize config and add error handling
guillaumedelre May 14, 2026
1e8ccad
Merge pull request #4 from guillaumedelre/feat/secure-config
guillaumedelre May 14, 2026
cad3f50
test: add unit tests for driver and API
guillaumedelre May 14, 2026
c0f790d
Merge pull request #5 from guillaumedelre/feat/add-tests
guillaumedelre May 14, 2026
603d8c4
feat: add multi-stage Dockerfile and docker-compose
guillaumedelre May 14, 2026
842b3a3
Merge pull request #6 from guillaumedelre/feat/add-docker
guillaumedelre May 14, 2026
3ae78be
docs: add AUDIT.md and CLAUDE.md
guillaumedelre May 14, 2026
18c5ffe
Merge pull request #7 from guillaumedelre/feat/add-docs
guillaumedelre May 14, 2026
161bf25
ci: add GitHub Actions pipeline
guillaumedelre May 14, 2026
70ba883
Merge pull request #8 from guillaumedelre/feat/ci-pipeline
guillaumedelre May 14, 2026
a5a764d
chore: untrack AUDIT.md and CLAUDE.md, keep local only
guillaumedelre May 14, 2026
bcaeb93
Merge pull request #9 from guillaumedelre/chore/untrack-local-docs
guillaumedelre May 14, 2026
efa8625
docs(readme): rewrite with full reference, examples and badges
guillaumedelre May 14, 2026
ea9eeb0
Merge pull request #10 from guillaumedelre/feat/update-readme
guillaumedelre May 14, 2026
cd16bbd
ci: add flake8 and mypy lint jobs
guillaumedelre May 14, 2026
876d178
Merge pull request #11 from guillaumedelre/feat/ci-lint
guillaumedelre May 14, 2026
08134a4
fix: add soft reset and NVM copy wait on sensor init
guillaumedelre May 14, 2026
1da97a5
Merge pull request #12 from guillaumedelre/fix/sensor-init-reset
guillaumedelre May 14, 2026
3eac243
fix: poll measuring status register instead of fixed sleep only
guillaumedelre May 14, 2026
bb13f99
Merge pull request #13 from guillaumedelre/fix/measurement-status-pol…
guillaumedelre May 14, 2026
290f696
feat: add IIR filter configuration via BME280_IIR_FILTER env var
guillaumedelre May 14, 2026
3a42617
Merge pull request #14 from guillaumedelre/feat/iir-filter
guillaumedelre May 14, 2026
6d924f7
fix: read chip ID with single byte read from 0xD0
guillaumedelre May 14, 2026
700fff7
Merge pull request #15 from guillaumedelre/fix/chip-id-read
guillaumedelre May 14, 2026
bba5f4f
docs: expand Home Assistant MQTT integration section
guillaumedelre May 14, 2026
c87e837
Merge pull request #16 from guillaumedelre/docs/ha-mqtt-integration
guillaumedelre May 14, 2026
c2f98d1
docs: split README into individual files under docs/
guillaumedelre May 14, 2026
05c0664
Merge pull request #17 from guillaumedelre/docs/split-into-sections
guillaumedelre May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# 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
MQTT_USERNAME=homeassistant
MQTT_PASSWORD=
MQTT_CLIENT_ID=rpi-bme280

# Flask
FLASK_PORT=5000
FLASK_DEBUG=false
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: CI

on:
push:
branches: [develop, master]
pull_request:
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

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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
.env
.pytest_cache/
AUDIT.md
CLAUDE.md
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,49 @@
# bme280
# 🌡️ BME280 Environmental Sensor

[![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)

> 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.

---

## 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

python sensor_api.py # API available at http://0.0.0.0:5000
```

```bash
curl http://rpi.local:5000/bme280
curl http://rpi.local:5000/bme280/publish
```

---

## Documentation

| 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
170 changes: 170 additions & 0 deletions bme280.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import os
import time
from ctypes import c_short

import smbus2

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


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 = bus.read_byte_data(addr, 0xD0)
return chip_id, 0


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
OVERSAMPLE_HUM = 2
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, 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)

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: 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)
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")
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading