10 KiB
Adding a New Sensor Plug-in
Sensors in openhop-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 configured sensor of type "foo", it imports repeater.sensors.foo. The Sensor Manager API also imports installed sensor modules before enumerating SensorRegistry, so the UI's Add Sensor menu discovers new types without a hardcoded frontend list. Give the class a _settings_schema for its dynamic form; installing a module does not automatically create a sensor definition or detect physical hardware. Restart the backend after installing code, then add/configure the sensor in the UI.
Step-by-step guide
1. Create repeater/sensors/<type>.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:
"""
<SensorName> sensor plug-in.
Requires: pip install <package>
Config example:
- type: <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("<type>")
class MySensor(SensorBase):
sensor_type = "<type>"
_settings_schema = [
{"key": "some_option", "type": "string", "label": "Some Option", "default": "default"},
]
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_typeclass attribute must match the string passed to@SensorRegistry.register.- Multiple definitions may use the same sensor type (for example two
openhop_modeminstances), but each must have a unique name: the poller caches readings by name. Thepymc_modemalias remains loadable for old configurations but is not offered as a new type in Sensor Manager. self.settingsis thesettings:block from the sensor's config entry (a plain dict).ensure_python_moduleshandles missing dependencies gracefully. Pass a multi-line list of(import_name, pip_package)tuples. ReturnsFalseand logs a warning if any are missing andauto_install_packagesisfalse; installs them via pip iftrue. Sensor-specific packages belong here — do not add them topyproject.toml._readmust return a flatdict[str, Any]. The base class wraps it in a standard envelope (name,type,ok,timestamp,data, optionalerror). Plug-ins may override_reading_metrics(ok)to attach optionalmetricsbesidedata, using only metadata captured during the same read._readmust raiseRuntimeErroron failure — the base class catches it, marksok=False, and logs it without crashing the polling loop.- All hardware initialisation belongs in
__init__, not in_read. Keep_readfast. - Lazy-import third-party packages inside
__init__(afterensure_python_modulesreturnsTrue) 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:
# Example MySensor (commented out by default)
# - type: <type>
# name: my-sensor
# enabled: true
# auto_install_packages: true
# settings:
# some_option: value
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
Add a test to tests/test_sensors.py that:
- Registers a lightweight mock of your sensor (or stubs the hardware import).
- Verifies that
SensorManagerloads it andread_all()returns the expected structure. - Verifies that a hardware failure in
_readproduces anok=Falseresult rather than raising.
Example pattern from the existing test suite:
class _MockMySensor(SensorBase):
sensor_type = "<type>"
def _read(self):
return {"field_one": 42.0, "field_two": 55.0}
SensorRegistry.register("<type>", _MockMySensor)
def test_my_sensor_loads_and_reads():
config = {
"sensors": {
"enabled": True,
"definitions": [
{"name": "test-sensor", "type": "<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/<type>.pycreatedsensor_typeclass attribute matches the@SensorRegistry.registerkey- All settings read from
self.settingswith sensible defaults ensure_python_modulescalled before any third-party import- Hardware initialised in
__init__, not_read _readraisesRuntimeErroron failure (never returnsNoneor partial data silently)- Commented example added to
config.yaml.example - Unit test added to
tests/test_sensors.py
Existing sensors
Networked openHop Modem discovery
Configure each modem's HTTP host, optional Basic authentication, and a unique sensor name once; no LAN or hardware auto-detection occurs. Besides the historical flat keys (including temperature_c as the MCU die-temperature alias), the modem sensor discovers finite JSON numbers and booleans under environment, counters, measurements, and telemetry dictionaries. Known AGC diagnostics under radio are handled explicitly. Generic values use data["modem:/environment/temperature_c"]-style keys and optional metrics descriptors beside data; environmental temperature never replaces the die temperature. Known counter aliases are not duplicated as discovered entities. Numeric strings, arrays, and unknown root/radio/network/config/GPS leaves are not discovered. Credential and location subtrees remain denied even under approved branches.
To admit a reviewed extra numeric/boolean leaf outside the approved roots, set discovery_include_paths to comma-separated exact JSON Pointers (for example /radio/reviewed_diagnostic); discovery_exclude_paths can narrow the policy and wins over inclusion. Neither setting grants access to forbidden credential, network, GPS, configuration or location segments. These are string settings, not lists. IDs are RFC6901-escaped source paths; an entity's identity is configured sensor name plus ID, so renaming a sensor or changing a firmware path changes its identity. Descriptors have category, kind, nullable unit and per-snapshot availability; unknown null-only fields wait for a typed sample before admission, while known null fields remain visible. environment.available=false invalidates environmental values without marking the whole HTTP modem offline; a missing section means unsupported capability, and an HTTP failure sets the reading ok=false. No stale number is reused as current.
Limits per modem: response 128 KiB, dictionary depth four below a root, 512 inspected generic nodes, 64 admitted paths for the instance lifetime, and segment/pointer limits of 64/256 characters. Known fields and reviewed exact included leaves are checked before the generic node budget; included leaves take priority over generic leaves for admission. Truncation is reported as modem_discovery_truncated in flat data. Invalid path policies are rejected by the sensor config save API; a pre-existing invalid policy is ignored at load time (default conservative discovery), so it does not disable the modem sensor. The historical data.url remains present but contains only the origin, never an endpoint path, credentials, query or fragment. Reload resets admission, and readings refresh at the configured modem poll cadence (default 60 seconds), not the dashboard refresh cadence. These diagnostics do not create RRD history or CayenneLPP/RF telemetry. Additions to those boundaries require a separate design and review.
| Type | File | Hardware |
|---|---|---|
victron_vedirect |
repeater/sensors/victron_vedirect.py |
Read-only local Victron VE.Direct serial text telemetry; setup and field units |
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 |
bme280 |
repeater/sensors/bme280.py |
BME280 I²C temperature, humidity and barometric pressure sensor |