mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-11-03 11:52:22 +01:00
223 lines
7.9 KiB
Python
223 lines
7.9 KiB
Python
"""
|
|
BME280 temperature, humidity and barometric pressure sensor plug-in.
|
|
|
|
Requires: pip install smbus2
|
|
|
|
Config example:
|
|
- type: bme280
|
|
name: "ambient"
|
|
enabled: true
|
|
auto_install_packages: false
|
|
settings:
|
|
i2c_address: 0x76 # 0x77 when SDO is tied high
|
|
bus_number: 1 # I2C bus number (1 for Raspberry Pi default)
|
|
read_timeout_seconds: 1.0 # Max time to wait for the measurement (polls every 5 ms)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
|
|
from .base import SensorBase
|
|
from .registry import SensorRegistry
|
|
|
|
# BME280 register addresses
|
|
_REG_CALIB_LOW = 0x88 # dig_T1..dig_P9 + dig_H1, 26 bytes
|
|
_REG_CALIB_HIGH = 0xE1 # dig_H2..dig_H6, 7 bytes
|
|
_REG_ID = 0xD0
|
|
_REG_CTRL_HUM = 0xF2
|
|
_REG_STATUS = 0xF3
|
|
_REG_CTRL_MEAS = 0xF4
|
|
_REG_CONFIG = 0xF5
|
|
_REG_DATA = 0xF7 # press(3) + temp(3) + hum(2)
|
|
|
|
_CHIP_ID = 0x60
|
|
_CHIP_ID_BMP280 = 0x58 # Same register map, no humidity channel
|
|
|
|
_OVERSAMPLING_X1 = 0x01
|
|
_MODE_FORCED = 0x01
|
|
# osrs_t x1, osrs_p x1, forced mode
|
|
_CTRL_MEAS_FORCED = (_OVERSAMPLING_X1 << 5) | (_OVERSAMPLING_X1 << 2) | _MODE_FORCED
|
|
_STATUS_MEASURING = 0x08
|
|
|
|
|
|
@SensorRegistry.register("bme280")
|
|
class BME280Sensor(SensorBase):
|
|
sensor_type = "bme280"
|
|
|
|
_settings_schema = [
|
|
{
|
|
"key": "i2c_address",
|
|
"type": "string",
|
|
"label": "I2C Address",
|
|
"default": "0x76",
|
|
"help": "Hex I2C address (e.g. 0x76, 0x77)",
|
|
},
|
|
{
|
|
"key": "bus_number",
|
|
"type": "integer",
|
|
"label": "I2C Bus Number",
|
|
"default": 0,
|
|
"help": "Linux I2C bus number",
|
|
},
|
|
{
|
|
"key": "read_timeout_seconds",
|
|
"type": "number",
|
|
"label": "Read Timeout (s)",
|
|
"default": 1.0,
|
|
"help": "Max time to wait for measurement",
|
|
},
|
|
]
|
|
|
|
def __init__(self, name: str, config: Optional[Dict[str, Any]] = None, log=None):
|
|
super().__init__(name=name, config=config, log=log)
|
|
|
|
self.i2c_address = int(self.settings.get("i2c_address", 0x76))
|
|
self.bus_number = int(self.settings.get("bus_number", 1))
|
|
self._poll_interval = 0.005 # 5 ms between measurement-complete checks
|
|
self._poll_attempts = max(
|
|
1, int(float(self.settings.get("read_timeout_seconds", 1.0)) / self._poll_interval)
|
|
)
|
|
self._calibration: Optional[Dict[str, Any]] = None
|
|
|
|
self.available = False
|
|
if not self.ensure_python_modules([("smbus2", "smbus2")]):
|
|
return
|
|
|
|
try:
|
|
import smbus2 # type: ignore[import-not-found]
|
|
|
|
self._smbus2 = smbus2
|
|
|
|
bus = smbus2.SMBus(self.bus_number)
|
|
try:
|
|
chip_id = bus.read_byte_data(self.i2c_address, _REG_ID)
|
|
if chip_id != _CHIP_ID:
|
|
hint = (
|
|
" (that is a BMP280, which has no humidity channel)"
|
|
if (chip_id == _CHIP_ID_BMP280)
|
|
else ""
|
|
)
|
|
raise RuntimeError(f"unexpected chip id 0x{chip_id:02X}{hint}")
|
|
self._calibration = self._read_calibration(bus)
|
|
finally:
|
|
bus.close()
|
|
|
|
self.available = True
|
|
self.log.info(
|
|
"BME280 initialized (addr=0x%02X, bus=%d)",
|
|
self.i2c_address,
|
|
self.bus_number,
|
|
)
|
|
except Exception as exc:
|
|
self.log.warning(
|
|
"BME280 init failed (addr=0x%02X, bus=%d): %s",
|
|
self.i2c_address,
|
|
self.bus_number,
|
|
exc,
|
|
)
|
|
self.available = False
|
|
|
|
def _read_calibration(self, bus) -> Dict[str, Any]:
|
|
"""Read the factory calibration block."""
|
|
low = bytes(bus.read_i2c_block_data(self.i2c_address, _REG_CALIB_LOW, 26))
|
|
high = bytes(bus.read_i2c_block_data(self.i2c_address, _REG_CALIB_HIGH, 7))
|
|
|
|
t1, t2, t3 = struct.unpack_from("<Hhh", low, 0)
|
|
p1, p2, p3, p4, p5, p6, p7, p8, p9 = struct.unpack_from("<Hhhhhhhhh", low, 6)
|
|
h1 = low[25]
|
|
|
|
h2 = struct.unpack_from("<h", high, 0)[0]
|
|
h3 = high[2]
|
|
# dig_H4 and dig_H5 are signed 12-bit values sharing the middle byte
|
|
h4 = (high[3] << 4) | (high[4] & 0x0F)
|
|
h5 = (high[5] << 4) | (high[4] >> 4)
|
|
h4 = h4 - 4096 if h4 > 2047 else h4
|
|
h5 = h5 - 4096 if h5 > 2047 else h5
|
|
h6 = struct.unpack_from("<b", high, 6)[0]
|
|
|
|
return {
|
|
"t": (t1, t2, t3),
|
|
"p": (p1, p2, p3, p4, p5, p6, p7, p8, p9),
|
|
"h": (h1, h2, h3, h4, h5, h6),
|
|
}
|
|
|
|
def _measure(self, bus) -> tuple[int, int, int]:
|
|
"""Trigger one forced-mode measurement and return raw ADC counts."""
|
|
# ctrl_hum only takes effect on the following ctrl_meas write
|
|
bus.write_byte_data(self.i2c_address, _REG_CTRL_HUM, _OVERSAMPLING_X1)
|
|
bus.write_byte_data(self.i2c_address, _REG_CONFIG, 0x00) # IIR filter off
|
|
bus.write_byte_data(self.i2c_address, _REG_CTRL_MEAS, _CTRL_MEAS_FORCED)
|
|
|
|
for _ in range(self._poll_attempts):
|
|
time.sleep(self._poll_interval)
|
|
if not bus.read_byte_data(self.i2c_address, _REG_STATUS) & _STATUS_MEASURING:
|
|
break
|
|
else:
|
|
raise RuntimeError(
|
|
f"measurement timed out after {self._poll_attempts * self._poll_interval:.1f}s"
|
|
)
|
|
|
|
d = bus.read_i2c_block_data(self.i2c_address, _REG_DATA, 8)
|
|
adc_p = (d[0] << 12) | (d[1] << 4) | (d[2] >> 4)
|
|
adc_t = (d[3] << 12) | (d[4] << 4) | (d[5] >> 4)
|
|
adc_h = (d[6] << 8) | d[7]
|
|
return adc_t, adc_p, adc_h
|
|
|
|
def _compensate(self, adc_t: int, adc_p: int, adc_h: int) -> tuple[float, float, float]:
|
|
"""Apply the datasheet floating-point compensation formulas (section 8.1)."""
|
|
calibration = self._calibration
|
|
t1, t2, t3 = calibration["t"]
|
|
p1, p2, p3, p4, p5, p6, p7, p8, p9 = calibration["p"]
|
|
h1, h2, h3, h4, h5, h6 = calibration["h"]
|
|
|
|
var1 = (adc_t / 16384.0 - t1 / 1024.0) * t2
|
|
var2 = ((adc_t / 131072.0 - t1 / 8192.0) ** 2) * t3
|
|
t_fine = var1 + var2
|
|
temperature_c = t_fine / 5120.0
|
|
|
|
var1 = t_fine / 2.0 - 64000.0
|
|
var2 = var1 * var1 * p6 / 32768.0
|
|
var2 = var2 + var1 * p5 * 2.0
|
|
var2 = var2 / 4.0 + p4 * 65536.0
|
|
var1 = (p3 * var1 * var1 / 524288.0 + p2 * var1) / 524288.0
|
|
var1 = (1.0 + var1 / 32768.0) * p1
|
|
if var1 == 0:
|
|
raise RuntimeError("pressure compensation divided by zero")
|
|
pressure = 1048576.0 - adc_p
|
|
pressure = (pressure - var2 / 4096.0) * 6250.0 / var1
|
|
var1 = p9 * pressure * pressure / 2147483648.0
|
|
var2 = pressure * p8 / 32768.0
|
|
pressure_pa = pressure + (var1 + var2 + p7) / 16.0
|
|
|
|
humidity = t_fine - 76800.0
|
|
humidity = (adc_h - (h4 * 64.0 + h5 / 16384.0 * humidity)) * (
|
|
h2 / 65536.0 * (1.0 + h6 / 67108864.0 * humidity * (1.0 + h3 / 67108864.0 * humidity))
|
|
)
|
|
humidity = humidity * (1.0 - h1 * humidity / 524288.0)
|
|
humidity_pct = min(100.0, max(0.0, humidity))
|
|
|
|
return temperature_c, pressure_pa, humidity_pct
|
|
|
|
def _read(self) -> Dict[str, Any]:
|
|
"""Read temperature, pressure and humidity from the BME280."""
|
|
if not self.available:
|
|
raise RuntimeError("BME280 device not available")
|
|
|
|
bus = self._smbus2.SMBus(self.bus_number)
|
|
try:
|
|
temperature_c, pressure_pa, humidity_pct = self._compensate(*self._measure(bus))
|
|
return {
|
|
"temperature_c": round(temperature_c, 2),
|
|
"humidity_pct": round(humidity_pct, 2),
|
|
"pressure_hpa": round(pressure_pa / 100.0, 2),
|
|
}
|
|
except RuntimeError:
|
|
raise
|
|
except Exception as exc:
|
|
raise RuntimeError(f"BME280 read failed: {exc}") from exc
|
|
finally:
|
|
bus.close()
|