diff --git a/config.yaml.example b/config.yaml.example index d66da5e..b269525 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -225,9 +225,8 @@ sensors: # Master switch for sensor subsystem enabled: true - # Poll interval for all configured sensors. Keep modem-backed sensors at 60s - # or slower so their HTTP stats reads don't contend with modem traffic. - poll_interval_seconds: 60.0 + # Poll interval for all configured sensors + poll_interval_seconds: 10.0 # If true, missing Python packages for a sensor may be installed at runtime # using "python -m pip install ...". @@ -302,6 +301,7 @@ sensors: # endpoint: "/api/stats" # username: "admin" # password: "" # modem HTTP password; may match pymc_tcp.token + # poll_interval_seconds: 60.0 # avoid contending with modem traffic # timeout_seconds: 2.0 diff --git a/repeater/sensors/manager.py b/repeater/sensors/manager.py index 7cb09ee..86b9e11 100644 --- a/repeater/sensors/manager.py +++ b/repeater/sensors/manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib import logging import threading +import time from typing import Any, Dict, List, Optional from .registry import SensorRegistry @@ -112,53 +113,80 @@ class SensorManager: def read_all(self) -> List[Dict[str, Any]]: readings: List[Dict[str, Any]] = [] for sensor in self.sensors: - try: - readings.append(sensor.read()) - except Exception as exc: - self.log.warning("Sensor manager caught read error for %s: %s", sensor.name, exc) - readings.append( - { - "name": sensor.name, - "type": getattr(sensor, "sensor_type", "sensor"), - "ok": False, - "timestamp": None, - "data": {}, - "error": f"{type(exc).__name__}: {exc}", - } - ) + readings.append(self._read_sensor(sensor)) return readings + def _read_sensor(self, sensor) -> Dict[str, Any]: + try: + return sensor.read() + except Exception as exc: + self.log.warning("Sensor manager caught read error for %s: %s", sensor.name, exc) + return { + "name": sensor.name, + "type": getattr(sensor, "sensor_type", "sensor"), + "ok": False, + "timestamp": None, + "data": {}, + "error": f"{type(exc).__name__}: {exc}", + } + + @staticmethod + def _sensor_poll_interval(sensor, default_interval: float) -> float: + raw = getattr(sensor, "poll_interval_seconds", default_interval) + try: + interval = float(raw) + except (TypeError, ValueError): + interval = default_interval + return max(0.1, interval) + def _poll_loop(self) -> None: """Background thread: poll sensors at configured interval and cache readings.""" section = self.config.get("sensors", {}) - poll_interval = 60.0 + poll_interval = 30.0 if isinstance(section, dict): try: - poll_interval = float(section.get("poll_interval_seconds", 60.0)) + poll_interval = float(section.get("poll_interval_seconds", 30.0)) except (TypeError, ValueError): pass self.log.debug("Sensor polling loop started (interval=%.1f sec)", poll_interval) + next_read_at: Dict[str, float] = {} + latest_by_name: Dict[str, Dict[str, Any]] = {} + while not self._stop_event.is_set(): + now = time.monotonic() + next_due_in = poll_interval try: - readings = self.read_all() - with self._readings_lock: - self._latest_readings = readings + updated = False + for sensor in self.sensors: + name = sensor.name + interval = self._sensor_poll_interval(sensor, poll_interval) + due_at = next_read_at.get(name, 0.0) + if now >= due_at: + latest_by_name[name] = self._read_sensor(sensor) + next_read_at[name] = now + interval + updated = True + next_due_in = min(next_due_in, max(0.0, next_read_at[name] - now)) + + if updated: + readings = [latest_by_name[s.name] for s in self.sensors if s.name in latest_by_name] + with self._readings_lock: + self._latest_readings = readings except Exception as exc: self.log.warning("Sensor poll cycle failed: %s", exc) # Wait for next poll or stop signal - self._stop_event.wait(poll_interval) + self._stop_event.wait(max(0.1, next_due_in)) self.log.debug("Sensor polling loop stopped") def get_summary(self) -> Dict[str, Any]: section = self.config.get("sensors", {}) - poll_interval = 60.0 + poll_interval = 30.0 if isinstance(section, dict): try: - poll_interval = float(section.get("poll_interval_seconds", 60.0)) + poll_interval = float(section.get("poll_interval_seconds", 30.0)) except (TypeError, ValueError): pass diff --git a/repeater/sensors/pymc_modem.py b/repeater/sensors/pymc_modem.py index 95bf1ad..6a3c6c7 100644 --- a/repeater/sensors/pymc_modem.py +++ b/repeater/sensors/pymc_modem.py @@ -19,6 +19,7 @@ class PymcModemSensor(SensorBase): def __init__(self, name: str, config: Optional[Dict[str, Any]] = None, log=None): super().__init__(name=name, config=config, log=log) + self.poll_interval_seconds = float(self.settings.get("poll_interval_seconds", 60.0)) self.timeout_seconds = float(self.settings.get("timeout_seconds", 2.0)) self.endpoint = str(self.settings.get("endpoint", "/api/stats") or "/api/stats") self.url = self._build_url() diff --git a/tests/test_sensors.py b/tests/test_sensors.py index e00c8c2..d060fea 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -108,12 +108,17 @@ def test_sensor_manager_summary_reflects_sensor_config(): assert summary["loaded"] == 1 -def test_sensor_manager_default_poll_interval_is_sixty_seconds(): - config = {"sensors": {"enabled": True, "definitions": []}} +def test_pymc_modem_sensor_defaults_to_sixty_second_poll_interval(): + sensor = PymcModemSensor("modem", {"settings": {"host": "192.168.0.205"}}) - manager = SensorManager(config, registry=_TestRegistry) + assert sensor.poll_interval_seconds == 60.0 - assert manager.get_summary()["poll_interval_seconds"] == 60.0 + +def test_sensor_manager_uses_sensor_specific_poll_interval(): + sensor = _DummySensor("demo", {"settings": {"value": 7}}) + setattr(sensor, "poll_interval_seconds", 60.0) + + assert SensorManager._sensor_poll_interval(sensor, 10.0) == 60.0 def test_sensor_manager_loads_and_reads_sensors_without_stopping_on_failure():