From 9bfe1259da6d4771d85a8dcdbbbdedff567b2575 Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Wed, 13 May 2026 11:46:26 +1000 Subject: [PATCH 1/4] feat: add ENS210 temperature/humidity sensor plug-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for the ENS210 relative humidity and temperature sensor as a new plug-in under repeater/sensors/ens210.py. Also adds a commented configuration example to config.yaml.example and a contributor guide at docs/adding_sensors.md explaining how to add further sensor plug-ins. ## Implementation notes ### Why smbus2 instead of an Adafruit/CircuitPython library The ENS210 has no maintained Adafruit CircuitPython driver. The available third-party options are either unmaintained or bring in the full Blinka/CircuitPython hardware-abstraction stack as a dependency. smbus2 is a thin, widely-packaged wrapper around the Linux i2c-dev kernel interface that is already present on Raspberry Pi OS and most Debian-based systems. It has no transitive dependencies and adds no abstraction cost. The ENS210 protocol is simple enough that direct register access is preferable: two writes to start a measurement (REG_SENS_RUN + REG_SENS_START) and two three-byte block reads to retrieve temperature and humidity. The status/validity bit is checked inline rather than relying on a library to surface it. There is no value a higher-level driver would add here. ### Read strategy A fixed post-trigger delay is unreliable — the sensor datasheet quotes ~130 ms typical conversion time but the actual ready time varies. The implementation instead polls the data-valid status bit (bit 1 of the third byte in each register block) every 50 ms for up to read_timeout_seconds (default 1.0 s), breaking as soon as both T and H report valid data. This is the same approach used in the validated reference script. The I2C bus is opened and closed on every read rather than kept open across poll cycles. Keeping a persistent SMBus handle caused subsequent reads to time out, consistent with the Linux i2c-dev file descriptor accumulating state between transactions. Co-Authored-By: Claude Sonnet 4.6 --- config.yaml.example | 10 +++ docs/adding_sensors.md | 167 +++++++++++++++++++++++++++++++++++++ repeater/sensors/ens210.py | 102 ++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 docs/adding_sensors.md create mode 100644 repeater/sensors/ens210.py diff --git a/config.yaml.example b/config.yaml.example index 9bdad3a..e8c86a2 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -236,6 +236,16 @@ sensors: # max_expected_amps: 2.0 # shunt_ohms: 0.1 + # Example ENS210 temperature/humidity sensor (commented out by default) + # - type: ens210 + # name: ambient + # enabled: true + # auto_install_packages: true + # settings: + # i2c_address: 67 # 0x43 in decimal (default ENS210 address) + # bus_number: 1 # I2C bus number (1 for Raspberry Pi default) + # read_timeout_seconds: 1.0 # Max seconds to wait for valid data (polls every 50 ms) + # Mesh Network Configuration mesh: # Unscoped flood policy - controls whether the repeater allows or denies unscoped flooding diff --git a/docs/adding_sensors.md b/docs/adding_sensors.md new file mode 100644 index 0000000..e16de4b --- /dev/null +++ b/docs/adding_sensors.md @@ -0,0 +1,167 @@ +# Adding a New Sensor Plug-in + +Sensors in pyMC_Repeater are self-contained modules that live in `repeater/sensors/`. The subsystem is plug-in based: adding a new sensor requires only one new file. The manager discovers and loads it automatically at runtime by importing the module named after the sensor type. + +--- + +## How the sensor subsystem works + +| Component | File | Role | +|-----------|------|------| +| `SensorBase` | `repeater/sensors/base.py` | Abstract base class all sensors inherit from | +| `SensorRegistry` | `repeater/sensors/registry.py` | Maps type strings → sensor classes via `@SensorRegistry.register` | +| `SensorManager` | `repeater/sensors/manager.py` | Reads config, imports sensor modules, polls sensors in background | + +When `SensorManager` loads a sensor of type `"foo"`, it calls `importlib.import_module("repeater.sensors.foo")`. That import runs the `@SensorRegistry.register("foo")` decorator on your class, making it available. No changes to `__init__.py` or the manager are needed. + +--- + +## Step-by-step guide + +### 1. Create `repeater/sensors/.py` + +Name the file after the sensor type string (lowercase, underscores for hyphens). The type string is what operators write in `config.yaml`. + +Minimal template: + +```python +""" + sensor plug-in. + +Requires: pip install + +Config example: + - type: + name: "my-sensor" + enabled: true + auto_install_packages: false + settings: + some_option: value +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .base import SensorBase +from .registry import SensorRegistry + + +@SensorRegistry.register("") +class MySensor(SensorBase): + sensor_type = "" + + def __init__(self, name: str, config: Optional[Dict[str, Any]] = None, log=None): + super().__init__(name=name, config=config, log=log) + + # Read settings with safe defaults + self.some_option = self.settings.get("some_option", "default") + + self.available = False + if not self.ensure_python_modules([("import_name", "pip-package-name")]): + return # logs a warning; sensor will report unavailable + + try: + import import_name # type: ignore[import-not-found] + # Initialise hardware here + self.device = import_name.Device(...) + self.available = True + self.log.info("MySensor initialized") + except Exception as exc: + self.log.warning("MySensor init failed: %s", exc) + self.available = False + + def _read(self) -> Dict[str, Any]: + if not self.available: + raise RuntimeError("device not available") + try: + return { + "field_one": ..., + "field_two": ..., + } + except Exception as exc: + raise RuntimeError(f"read failed: {exc}") from exc +``` + +Key rules: + +- **`sensor_type`** class attribute must match the string passed to `@SensorRegistry.register`. +- **`self.settings`** is the `settings:` block from the sensor's config entry (a plain dict). +- **`ensure_python_modules`** handles missing dependencies gracefully. Pass a list of `(import_name, pip_package)` tuples. Returns `False` and logs a warning if any are missing and `auto_install_packages` is `false`; installs them if `true`. +- **`_read`** must return a flat `dict[str, Any]`. The base class wraps it in a standard envelope (`name`, `type`, `ok`, `timestamp`, `data`, optional `error`). +- **`_read`** must raise `RuntimeError` on failure — the base class catches it, marks `ok=False`, and logs it without crashing the polling loop. +- All hardware initialisation belongs in `__init__`, not in `_read`. Keep `_read` fast. +- Lazy-import third-party packages inside `__init__` (after `ensure_python_modules` returns `True`) so the module can be imported on hosts that don't have the package installed. + +### 2. Add a commented example to `config.yaml.example` + +Find the `sensors.definitions` block and add your sensor alongside the existing examples: + +```yaml + # Example MySensor (commented out by default) + # - type: + # name: my-sensor + # enabled: true + # auto_install_packages: true + # settings: + # some_option: value +``` + +Use decimal for numeric config values that would naturally be written in hex (e.g. I2C addresses: `0x43` = `67`). Add a comment showing both forms if the value is commonly written in hex. + +### 3. Test locally + +Add a test to `tests/test_sensors.py` that: + +1. Registers a lightweight mock of your sensor (or stubs the hardware import). +2. Verifies that `SensorManager` loads it and `read_all()` returns the expected structure. +3. Verifies that a hardware failure in `_read` produces an `ok=False` result rather than raising. + +Example pattern from the existing test suite: + +```python +class _MockMySensor(SensorBase): + sensor_type = "" + + def _read(self): + return {"field_one": 42.0, "field_two": 55.0} + +SensorRegistry.register("", _MockMySensor) + +def test_my_sensor_loads_and_reads(): + config = { + "sensors": { + "enabled": True, + "definitions": [ + {"name": "test-sensor", "type": "", "settings": {}}, + ], + } + } + manager = SensorManager(config) + readings = manager.read_all() + assert readings[0]["ok"] is True + assert readings[0]["data"]["field_one"] == 42.0 +``` + +--- + +## Checklist + +- [ ] `repeater/sensors/.py` created +- [ ] `sensor_type` class attribute matches the `@SensorRegistry.register` key +- [ ] All settings read from `self.settings` with sensible defaults +- [ ] `ensure_python_modules` called before any third-party import +- [ ] Hardware initialised in `__init__`, not `_read` +- [ ] `_read` raises `RuntimeError` on failure (never returns `None` or partial data silently) +- [ ] Commented example added to `config.yaml.example` +- [ ] Unit test added to `tests/test_sensors.py` + +--- + +## Existing sensors + +| Type | File | Hardware | +|------|------|----------| +| `hardware_stats` | `repeater/sensors/hardware_stats.py` | Host CPU / memory / disk / network (via `psutil`) | +| `ina219` | `repeater/sensors/ina219.py` | INA219 I²C current/voltage/power monitor | +| `ens210` | `repeater/sensors/ens210.py` | ENS210 I²C relative humidity and temperature sensor | diff --git a/repeater/sensors/ens210.py b/repeater/sensors/ens210.py new file mode 100644 index 0000000..35eb3d5 --- /dev/null +++ b/repeater/sensors/ens210.py @@ -0,0 +1,102 @@ +""" +ENS210 relative humidity and temperature sensor plug-in. + +Requires: pip install smbus2 + +Config example: + - type: ens210 + name: "ambient" + enabled: true + auto_install_packages: false + settings: + i2c_address: 0x43 # Default ENS210 I2C address + bus_number: 1 # I2C bus number (1 for Raspberry Pi default) + read_timeout_seconds: 1.0 # Max time to wait for valid data (polls every 50 ms) +""" + +from __future__ import annotations + +import time +from typing import Any, Dict, Optional + +from .base import SensorBase +from .registry import SensorRegistry + +# ENS210 register addresses +_REG_SENS_RUN = 0x21 +_REG_SENS_START = 0x22 +_REG_T_VAL = 0x30 +_REG_H_VAL = 0x33 + + +@SensorRegistry.register("ens210") +class ENS210Sensor(SensorBase): + sensor_type = "ens210" + + 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", 0x43)) + self.bus_number = int(self.settings.get("bus_number", 1)) + self._poll_interval = 0.05 # 50 ms between validity checks + self._poll_attempts = max(1, int(float(self.settings.get("read_timeout_seconds", 1.0)) / self._poll_interval)) + + self.available = False + if not self.ensure_python_modules([("smbus2", "smbus2")]): + return + + try: + import smbus2 # type: ignore[import-not-found] + + self._smbus2 = smbus2 + # Verify the bus is accessible + smbus2.SMBus(self.bus_number).close() + self.available = True + self.log.info( + "ENS210 initialized (addr=0x%02X, bus=%d)", + self.i2c_address, + self.bus_number, + ) + except Exception as exc: + self.log.warning( + "ENS210 init failed (addr=0x%02X, bus=%d): %s", + self.i2c_address, + self.bus_number, + exc, + ) + self.available = False + + def _read(self) -> Dict[str, Any]: + """Read temperature and humidity from ENS210.""" + if not self.available: + raise RuntimeError("ENS210 device not available") + + bus = self._smbus2.SMBus(self.bus_number) + try: + bus.write_byte_data(self.i2c_address, _REG_SENS_RUN, 0x03) + bus.write_byte_data(self.i2c_address, _REG_SENS_START, 0x03) + + for _ in range(self._poll_attempts): + time.sleep(self._poll_interval) + t_data = bus.read_i2c_block_data(self.i2c_address, _REG_T_VAL, 3) + h_data = bus.read_i2c_block_data(self.i2c_address, _REG_H_VAL, 3) + if ((t_data[2] >> 1) & 0x01) and ((h_data[2] >> 1) & 0x01): + break + else: + raise RuntimeError( + f"ENS210 measurement timed out after {self._poll_attempts * self._poll_interval:.1f}s" + ) + + t_raw = t_data[0] | (t_data[1] << 8) + h_raw = h_data[0] | (h_data[1] << 8) + + return { + "temperature_c": round(t_raw / 64.0 - 273.15, 2), + "humidity_pct": round(h_raw / 512.0, 2), + } + except RuntimeError: + raise + except Exception as exc: + raise RuntimeError(f"ENS210 read failed: {exc}") from exc + finally: + bus.close() From 3f7b6d5cdc62c7e3b75420a0f55b2f1c620bb89e Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Wed, 13 May 2026 11:54:32 +1000 Subject: [PATCH 2/4] fix: add smbus2 dependency, i2c-tools, and use hex I2C addresses in docs - Add smbus2>=0.4.0 to pyproject.toml core dependencies so it is always present in the venv rather than relying on runtime auto-install - Add i2c-tools to apt-get installs in both install and upgrade paths so /dev/i2c-* devices are accessible and i2cdetect is available for diagnostics (service user was already being added to the i2c group) - Switch ENS210 config examples to hex I2C address notation (0x43) to match datasheets and i2cdetect output; update contributor docs guidance accordingly Co-Authored-By: Claude Sonnet 4.6 --- config.yaml.example | 2 +- docs/adding_sensors.md | 2 +- manage.sh | 4 ++-- pyproject.toml | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/config.yaml.example b/config.yaml.example index e8c86a2..2776f59 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -242,7 +242,7 @@ sensors: # enabled: true # auto_install_packages: true # settings: - # i2c_address: 67 # 0x43 in decimal (default ENS210 address) + # i2c_address: 0x43 # Default ENS210 address # bus_number: 1 # I2C bus number (1 for Raspberry Pi default) # read_timeout_seconds: 1.0 # Max seconds to wait for valid data (polls every 50 ms) diff --git a/docs/adding_sensors.md b/docs/adding_sensors.md index e16de4b..ca3ed65 100644 --- a/docs/adding_sensors.md +++ b/docs/adding_sensors.md @@ -107,7 +107,7 @@ Find the `sensors.definitions` block and add your sensor alongside the existing # some_option: value ``` -Use decimal for numeric config values that would naturally be written in hex (e.g. I2C addresses: `0x43` = `67`). Add a comment showing both forms if the value is commonly written in hex. +Use hex notation for I2C addresses (e.g. `0x43`) as this matches how addresses are listed in datasheets and tools like `i2cdetect`. ### 3. Test locally diff --git a/manage.sh b/manage.sh index aa8dcde..7d5b2fa 100755 --- a/manage.sh +++ b/manage.sh @@ -341,7 +341,7 @@ install_repeater() { echo "25"; echo "# Installing system dependencies..." apt-get update -qq - DEBIAN_FRONTEND=noninteractive apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev + DEBIAN_FRONTEND=noninteractive apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev i2c-tools # Install polkit (package name varies by distro version) DEBIAN_FRONTEND=noninteractive apt-get install -y policykit-1 2>/dev/null \ || DEBIAN_FRONTEND=noninteractive apt-get install -y polkitd pkexec 2>/dev/null \ @@ -746,7 +746,7 @@ upgrade_repeater() { echo "[3/9] Updating system dependencies..." apt-get update -qq - apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev + apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev i2c-tools # Install polkit (package name varies by distro version) apt-get install -y policykit-1 2>/dev/null \ || apt-get install -y polkitd pkexec 2>/dev/null \ diff --git a/pyproject.toml b/pyproject.toml index b189948..aa08873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "pyserial>=3.5", "pyjwt>=2.8.0", "ws4py>=0.6.0", + "smbus2>=0.4.0", ] From 7865e9cb4bf6fba9daec4233d659c77dc709ba57 Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Wed, 13 May 2026 16:33:37 +1000 Subject: [PATCH 3/4] fix: standardise sensor module structure and docs - Use multi-line ensure_python_modules list format in ens210.py, matching the established pattern from ina219.py - Fix auto_install_packages indentation in ina219.py docstring - Remove smbus2 from pyproject.toml core dependencies; sensor packages are handled at runtime via ensure_python_modules/auto_install_packages - Update docs/adding_sensors.md template and guidance to match Co-Authored-By: Claude Sonnet 4.6 --- docs/adding_sensors.md | 8 ++++++-- pyproject.toml | 1 - repeater/sensors/ens210.py | 6 +++++- repeater/sensors/ina219.py | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/adding_sensors.md b/docs/adding_sensors.md index ca3ed65..28e672e 100644 --- a/docs/adding_sensors.md +++ b/docs/adding_sensors.md @@ -58,7 +58,11 @@ class MySensor(SensorBase): self.some_option = self.settings.get("some_option", "default") self.available = False - if not self.ensure_python_modules([("import_name", "pip-package-name")]): + if not self.ensure_python_modules( + [ + ("import_name", "pip-package-name"), + ] + ): return # logs a warning; sensor will report unavailable try: @@ -87,7 +91,7 @@ Key rules: - **`sensor_type`** class attribute must match the string passed to `@SensorRegistry.register`. - **`self.settings`** is the `settings:` block from the sensor's config entry (a plain dict). -- **`ensure_python_modules`** handles missing dependencies gracefully. Pass a list of `(import_name, pip_package)` tuples. Returns `False` and logs a warning if any are missing and `auto_install_packages` is `false`; installs them if `true`. +- **`ensure_python_modules`** handles missing dependencies gracefully. Pass a multi-line list of `(import_name, pip_package)` tuples. Returns `False` and logs a warning if any are missing and `auto_install_packages` is `false`; installs them via pip if `true`. Sensor-specific packages belong here — do **not** add them to `pyproject.toml`. - **`_read`** must return a flat `dict[str, Any]`. The base class wraps it in a standard envelope (`name`, `type`, `ok`, `timestamp`, `data`, optional `error`). - **`_read`** must raise `RuntimeError` on failure — the base class catches it, marks `ok=False`, and logs it without crashing the polling loop. - All hardware initialisation belongs in `__init__`, not in `_read`. Keep `_read` fast. diff --git a/pyproject.toml b/pyproject.toml index aa08873..b189948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dependencies = [ "pyserial>=3.5", "pyjwt>=2.8.0", "ws4py>=0.6.0", - "smbus2>=0.4.0", ] diff --git a/repeater/sensors/ens210.py b/repeater/sensors/ens210.py index 35eb3d5..221ab9f 100644 --- a/repeater/sensors/ens210.py +++ b/repeater/sensors/ens210.py @@ -42,7 +42,11 @@ class ENS210Sensor(SensorBase): self._poll_attempts = max(1, int(float(self.settings.get("read_timeout_seconds", 1.0)) / self._poll_interval)) self.available = False - if not self.ensure_python_modules([("smbus2", "smbus2")]): + if not self.ensure_python_modules( + [ + ("smbus2", "smbus2"), + ] + ): return try: diff --git a/repeater/sensors/ina219.py b/repeater/sensors/ina219.py index 0d8866f..9a0b9cf 100644 --- a/repeater/sensors/ina219.py +++ b/repeater/sensors/ina219.py @@ -7,7 +7,7 @@ Config example: - type: ina219 name: "power_monitor" enabled: true - auto_install_packages: false + auto_install_packages: false settings: i2c_address: 0x40 # Default INA219 I2C address max_expected_amps: 2.0 From a01d59381b41f97132d37364ac77331c50ae637d Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Wed, 13 May 2026 17:07:28 +1000 Subject: [PATCH 4/4] fix(ens210): check correct VALID bit in T_VAL/H_VAL polling Bit 0 of byte 2 is the T_VALID/H_VALID flag (datasheet Figure 32/33, page 23 example: t_valid = (t_val>>16) & 0x1). The previous code checked bit 1 (CRC LSB), which caused sporadic timeouts when the CRC happened to have a 0 in that position. Co-Authored-By: Claude Sonnet 4.6 --- repeater/sensors/ens210.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repeater/sensors/ens210.py b/repeater/sensors/ens210.py index 221ab9f..4ab6f29 100644 --- a/repeater/sensors/ens210.py +++ b/repeater/sensors/ens210.py @@ -84,7 +84,7 @@ class ENS210Sensor(SensorBase): time.sleep(self._poll_interval) t_data = bus.read_i2c_block_data(self.i2c_address, _REG_T_VAL, 3) h_data = bus.read_i2c_block_data(self.i2c_address, _REG_H_VAL, 3) - if ((t_data[2] >> 1) & 0x01) and ((h_data[2] >> 1) & 0x01): + if (t_data[2] & 0x01) and (h_data[2] & 0x01): break else: raise RuntimeError(