Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
573 changes: 16 additions & 557 deletions README.md

Large diffs are not rendered by default.

136 changes: 136 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 📡 API Reference

Base URL: `http://<raspberry-pi-host>: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 |
62 changes: 62 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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
```
56 changes: 56 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -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
```
104 changes: 104 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
@@ -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
```
Loading
Loading