diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..389bd2d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de1a63c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56cb6f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.env +.pytest_cache/ +AUDIT.md +CLAUDE.md 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/README.md b/README.md index 72d237e..3f54589 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bme280.py b/bme280.py new file mode 100644 index 0000000..af2c7cb --- /dev/null +++ b/bme280.py @@ -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") 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 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 +``` 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..e832700 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=8.0.0 +pytest-flask>=1.3.0 +flake8>=7.0.0 +mypy>=1.0.0 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 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) 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..fbffb2c --- /dev/null +++ b/tests/test_bme280.py @@ -0,0 +1,141 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +def make_mock_bus(chip_id: int = 96) -> MagicMock: + bus = MagicMock() + bus.read_byte_data.side_effect = [chip_id, 0, 0] # read_id, NVM done, not measuring + bus.read_i2c_block_data.side_effect = [ + [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 # always 0 — register 0xD1 is undocumented + + +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) + instance.__exit__ = MagicMock(return_value=False) + import bme280 + chip_id, _ = bme280.read_id() + assert chip_id == 96 + bus.read_byte_data.assert_called_with(bme280.DEVICE_ADDRESS, 0xD0) + + +# --- 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 + + +def test_nvm_copy_timeout_raises_oserror(): + bus = MagicMock() + 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) + instance.__exit__ = MagicMock(return_value=False) + 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