diff --git a/config/pir_guard.json5 b/config/pir_guard.json5 new file mode 100644 index 0000000000..4ed492f75f --- /dev/null +++ b/config/pir_guard.json5 @@ -0,0 +1,56 @@ +{ + // PIR Guard / Security Mode + // Run with: python -m run --config pir_guard + // + // connector options: + // "mock" → no hardware needed (default, safe for any machine) + // "serial" → Arduino via USB, set port to your device path + // "gpio" → Raspberry Pi / Jetson direct GPIO + // "zenoh" → network-distributed PIR publisher + version: "v1.0.3", + hertz: 0.5, + name: "pir_guard", + api_key: "${OM_API_KEY:-openmind_free}", + system_prompt_base: "You are a security guard robot. Your role is to monitor the environment for unexpected motion. When motion is detected by the PIR sensor, alert clearly and calmly. When there is no motion, remain on standby and stay quiet.", + system_governance: "Here are the laws that govern your actions. Do not violate these laws.\nFirst Law: A robot cannot harm a human or allow a human to come to harm.\nSecond Law: A robot must obey orders from humans, unless those orders conflict with the First Law.\nThird Law: A robot must protect itself, as long as that protection does not conflict with the First or Second Law.", + system_prompt_examples: "Here are some examples of interactions you might encounter:\n\n1. PIR sensor detects motion:\n Speak: 'Motion detected. Identifying presence.'\n\n2. No motion for a long time:\n Speak: 'All clear. Continuing to monitor.'", + agent_inputs: [ + { + type: "PIRMotionInput", + config: { + // Change to "serial", "gpio", or "zenoh" for real hardware + connector: "mock", + + // serial connector settings (connector="serial") + // port: "/dev/ttyUSB0", // Linux + // port: "COM3", // Windows + // port: "/dev/cu.usbmodem1101", // macOS + // baudrate: 9600, + + // gpio connector settings (connector="gpio") + // gpio_pin: 17, // BCM GPIO17 = physical pin 11 on RPi + + // zenoh connector settings (connector="zenoh") + // zenoh_topic: "om/sensors/pir", + + // minimum seconds between motion alerts sent to LLM + // increase if robot speaks too frequently when sensor stays HIGH + cooldown: 5.0, + }, + }, + ], + cortex_llm: { + type: "OpenAILLM", + config: { + agent_name: "Guard", + history_length: 5, + }, + }, + agent_actions: [ + { + name: "speak", + llm_label: "speak", + connector: "elevenlabs_tts", + }, + ], +} diff --git a/docs/developing/4_inputs.md b/docs/developing/4_inputs.md index 5dca87f392..f1e0040d76 100644 --- a/docs/developing/4_inputs.md +++ b/docs/developing/4_inputs.md @@ -25,5 +25,6 @@ Here are a few examples for you to reuse and build on: - [VLM_COCO_Local](https://github.com/openmind/OM1/blob/main/src/inputs/plugins/vlm_coco_local.py) - [VLM_Vila](https://github.com/openmind/OM1/blob/main/src/inputs/plugins/vlm_vila.py) - [Arduino GPS](https://github.com/openmind/OM1/blob/main/src/inputs/plugins/gps.py) +- [PIR Motion Sensor (HC-SR501)](https://github.com/openmind/OM1/blob/main/src/inputs/plugins/pir_motion.py) Learn how to build a new input plugin [here](../developer_cookbook/input.md) diff --git a/docs/robotics/pir_hc_sr501.md b/docs/robotics/pir_hc_sr501.md new file mode 100644 index 0000000000..4473930200 --- /dev/null +++ b/docs/robotics/pir_hc_sr501.md @@ -0,0 +1,146 @@ +--- +title: PIR Motion Sensor (HC-SR501) +description: "HC-SR501 Passive Infrared Motion Sensor" +icon: sensor +--- + +## Overview + +The HC-SR501 is a Passive Infrared (PIR) sensor that detects motion by measuring changes in infrared radiation emitted by people and animals. It outputs a simple digital HIGH/LOW signal, making it an efficient and low-power trigger for robot guard and security modes. + +Because the sensor consumes only ~1 mA in standby, it is ideal as a **wake-up trigger**: keep the PIR active continuously, then activate heavier subsystems (camera, VLM, LLM) only when motion is detected. + +## Hardware + +| Parameter | Value | +|-----------|-------| +| Supply voltage | 4.5 V – 20 V (typically 5 V) | +| Output voltage | 3.3 V HIGH / 0 V LOW | +| Detection range | Up to 7 m | +| Detection angle | ~120° cone | +| Trigger hold time | 0.3 s – ~200 s (adjustable via onboard potentiometer) | +| Current draw | ~1 mA standby | + +The board has two potentiometers: +- **Sensitivity** — adjusts detection range (turn clockwise to increase) +- **Time delay** — adjusts how long OUT stays HIGH after detection + +And a jumper for trigger mode: +- **H (repeatable)** — OUT stays HIGH as long as motion continues (recommended) +- **L (single)** — OUT pulses once per trigger event + +## Connector Options + +The `PIRMotionInput` plugin supports four hardware backends: + +| Connector | Hardware | Use case | +|-----------|----------|----------| +| `serial` | Arduino / any USB microcontroller | Cross-platform, multi-sensor | +| `gpio` | Raspberry Pi / Jetson | Direct wiring, minimal hardware | +| `zenoh` | Any Zenoh-capable device | Distributed / multi-robot setups | +| `mock` | No hardware | Development and testing (default) | + +## Wiring + +### Arduino (serial connector) +``` +HC-SR501 VCC → Arduino 5V +HC-SR501 GND → Arduino GND +HC-SR501 OUT → Arduino D2 +``` + +Upload this sketch to the Arduino: +```cpp +const int PIR_PIN = 2; + +void setup() { + Serial.begin(9600); + pinMode(PIR_PIN, INPUT); +} + +void loop() { + Serial.println(digitalRead(PIR_PIN) ? "MOTION:1" : "MOTION:0"); + delay(500); +} +``` + +### Raspberry Pi (gpio connector) +``` +HC-SR501 VCC → RPi Pin 2 (5V) +HC-SR501 GND → RPi Pin 6 (GND) +HC-SR501 OUT → RPi Pin 11 (GPIO17, BCM) +``` + +> **Note:** HC-SR501 OUT is typically 3.3 V-safe, but verify your specific sensor's datasheet before connecting directly to a 3.3 V GPIO pin. + +### Finding the Arduino on Linux +```bash +sudo dmesg | grep ttyUSB* +# or +sudo dmesg | grep ttyACM* +``` + +Read the data to verify the sensor is streaming: +```bash +screen /dev/ttyUSB0 9600 +``` + +### Finding the Arduino on macOS +```bash +ls /dev/cu.* +``` + +It should appear as something like `/dev/cu.usbmodem1101`. + +## Configuration + +Add `PIRMotionInput` to your config's `agent_inputs`: +```json5 +{ + agent_inputs: [ + { + type: "PIRMotionInput", + config: { + connector: "serial", // serial | gpio | zenoh | mock + port: "/dev/ttyUSB0", // serial only + baudrate: 9600, // serial only + gpio_pin: 17, // gpio only (BCM numbering) + zenoh_topic: "om/sensors/pir", // zenoh only + cooldown: 5.0, // seconds between LLM alerts + }, + }, + ], +} +``` + +The `cooldown` parameter is important: HC-SR501 can hold its output HIGH for up to ~200 seconds depending on the time delay potentiometer setting. Without cooldown the LLM would receive hundreds of identical motion alerts. The default of 5.0 seconds is a safe starting point — increase it if your robot speaks too frequently. + +## Running + +A ready-to-use config is provided: +```bash +python -m run --config pir_guard +``` + +This runs the robot in security/guard mode using the mock connector by default. Change `connector` to `"serial"` or `"gpio"` for real hardware. + +## Zenoh Distributed Setup + +If the sensor is on a remote device (e.g. a Raspberry Pi on the robot body, while OM1 runs on a separate computer): +```python +import zenoh +import time +import RPi.GPIO as GPIO + +GPIO.setmode(GPIO.BCM) +GPIO.setup(17, GPIO.IN) + +session = zenoh.open() +pub = session.declare_publisher("om/sensors/pir") + +while True: + pub.put("1" if GPIO.input(17) else "0") + time.sleep(0.5) +``` + +Then set `connector: "zenoh"` in your OM1 config. diff --git a/src/inputs/plugins/pir_motion.py b/src/inputs/plugins/pir_motion.py new file mode 100644 index 0000000000..3e4ba069c5 --- /dev/null +++ b/src/inputs/plugins/pir_motion.py @@ -0,0 +1,547 @@ +import asyncio +import logging +import time +from typing import Optional + +from pydantic import Field + +from inputs.base import Message, SensorConfig +from inputs.base.loop import FuserInput +from providers.io_provider import IOProvider + +try: + import serial as _serial + + _SERIAL_AVAILABLE = True +except ImportError: + _serial = None # type: ignore + _SERIAL_AVAILABLE = False + logging.warning( + "pyserial not found. PIRMotionInput serial connector unavailable. " + "Install with: pip install pyserial" + ) + +try: + import RPi.GPIO as _GPIO # type: ignore + + _GPIO_LIB = _GPIO + _GPIO_AVAILABLE = True + logging.info("PIRMotionInput: using RPi.GPIO") +except ImportError: + try: + import Jetson.GPIO as _GPIO # type: ignore + + _GPIO_LIB = _GPIO + _GPIO_AVAILABLE = True + logging.info("PIRMotionInput: using Jetson.GPIO") + except ImportError: + _GPIO_LIB = None + _GPIO_AVAILABLE = False + logging.warning( + "Neither RPi.GPIO nor Jetson.GPIO found. " + "PIRMotionInput GPIO connector unavailable." + ) + +try: + import zenoh as _zenoh # type: ignore + + _ZENOH_AVAILABLE = True +except ImportError: + _zenoh = None # type: ignore + _ZENOH_AVAILABLE = False + logging.warning("zenoh not found. PIRMotionInput zenoh connector unavailable.") + + +class PIRMotionConfig(SensorConfig): + """ + Configuration for PIR Motion Sensor (HC-SR501) input plugin. + + Supports four hardware backends via the ``connector`` field: + + - ``"serial"`` : Arduino or any USB microcontroller via pyserial. + - ``"gpio"`` : Direct BCM GPIO on Raspberry Pi or Jetson. + - ``"zenoh"`` : Network-distributed input via Zenoh pub/sub. + - ``"mock"`` : Simulated events for development and testing (default). + + Parameters + ---------- + connector : str + Hardware backend to use. Default is ``"mock"``. + port : str + Serial device path. Used only when ``connector="serial"``. + Examples: ``"/dev/ttyUSB0"`` (Linux), ``"COM3"`` (Windows), + ``"/dev/cu.usbmodem1101"`` (macOS). + baudrate : int + Serial baudrate. Must match the Arduino sketch. Default 9600. + serial_timeout : float + Serial readline timeout in seconds. Default 1.0. + gpio_pin : int + BCM GPIO pin number connected to HC-SR501 OUT. Default 17. + zenoh_topic : str + Zenoh key expression to subscribe to. Default ``"om/sensors/pir"``. + cooldown : float + Minimum seconds between motion events forwarded to the LLM. + Prevents context flooding when the sensor output stays HIGH + (HC-SR501 can hold HIGH for up to ~200 s). Default 5.0. + mock_trigger_interval : int + Average number of poll cycles between simulated motion events. + Only used when ``connector="mock"``. Default 10. + """ + + connector: str = Field( + default="mock", + description=( + "Hardware backend: 'serial' (Arduino/USB), " + "'gpio' (Raspberry Pi/Jetson), " + "'zenoh' (network), " + "'mock' (testing/simulation)" + ), + ) + port: str = Field( + default="/dev/ttyUSB0", + description="Serial port path. Used when connector='serial'.", + ) + baudrate: int = Field( + default=9600, + description="Serial baudrate. Used when connector='serial'.", + ) + serial_timeout: float = Field( + default=1.0, + description="Serial read timeout in seconds. Used when connector='serial'.", + ) + gpio_pin: int = Field( + default=17, + description="BCM GPIO pin number for PIR OUT signal. Used when connector='gpio'.", + ) + zenoh_topic: str = Field( + default="om/sensors/pir", + description="Zenoh topic for PIR data. Used when connector='zenoh'.", + ) + cooldown: float = Field( + default=5.0, + description=( + "Minimum seconds between motion events forwarded to LLM. " + "Prevents context flooding when sensor stays HIGH." + ), + ) + mock_trigger_interval: int = Field( + default=10, + description=( + "Average poll cycles between simulated motion events. " + "Used only when connector='mock'." + ), + ) + + +class _SerialPIRConnector: + """ + Read HC-SR501 state from an Arduino via USB serial. + + The paired Arduino sketch must send one line per reading:: + + "MOTION:1" → PIR OUT is HIGH (motion detected) + "MOTION:0" → PIR OUT is LOW (no motion) + + Minimal Arduino sketch + ---------------------- + .. code-block:: cpp + + const int PIR_PIN = 2; + void setup() { + Serial.begin(9600); + pinMode(PIR_PIN, INPUT); + } + void loop() { + Serial.println(digitalRead(PIR_PIN) ? "MOTION:1" : "MOTION:0"); + delay(500); + } + + Wiring (HC-SR501 to Arduino Uno) + --------------------------------- + HC-SR501 VCC → Arduino 5V + HC-SR501 GND → Arduino GND + HC-SR501 OUT → Arduino D2 + """ + + def __init__(self, port: str, baudrate: int, timeout: float): + self._ser = None + if not _SERIAL_AVAILABLE or _serial is None: + logging.error("_SerialPIRConnector: pyserial not available.") + return + try: + self._ser = _serial.Serial(port, baudrate, timeout=timeout) + logging.info( + f"PIRMotionInput serial: connected to {port} @ {baudrate} baud" + ) + except _serial.SerialException as e: + logging.error(f"PIRMotionInput serial: failed to open {port}: {e}") + + async def read(self) -> Optional[bool]: + if self._ser is None: + return None + try: + raw = self._ser.readline().decode("utf-8").strip() + except Exception as e: + logging.warning(f"PIRMotionInput serial: read error: {e}") + return None + if raw == "MOTION:1": + return True + if raw == "MOTION:0": + return False + if raw: + logging.debug(f"PIRMotionInput serial: unrecognised line: '{raw}'") + return None + + def stop(self): + if self._ser and self._ser.is_open: + self._ser.close() + logging.info("PIRMotionInput serial: port closed") + + +class _GPIOPIRConnector: + """ + Read HC-SR501 state directly from a BCM GPIO pin. + + Compatible with Raspberry Pi (RPi.GPIO) and Jetson (Jetson.GPIO). + + Wiring (HC-SR501 to Raspberry Pi) + ----------------------------------- + HC-SR501 VCC → RPi Pin 2 (5V) + HC-SR501 GND → RPi Pin 6 (GND) + HC-SR501 OUT → RPi Pin 11 (GPIO17, BCM) + + .. note:: + HC-SR501 OUT is typically 3.3 V-safe, but verify your sensor's + datasheet before connecting directly to a 3.3 V GPIO. + """ + + def __init__(self, pin: int): + self._pin = pin + self._ready = False + if not _GPIO_AVAILABLE or _GPIO_LIB is None: + logging.error("PIRMotionInput GPIO: GPIO library not available.") + return + try: + _GPIO_LIB.setmode(_GPIO_LIB.BCM) + _GPIO_LIB.setup(self._pin, _GPIO_LIB.IN) + self._ready = True + logging.info(f"PIRMotionInput GPIO: GPIO{pin} configured as input (BCM)") + except Exception as e: + logging.error(f"PIRMotionInput GPIO: setup failed: {e}") + + async def read(self) -> Optional[bool]: + if not self._ready or _GPIO_LIB is None: + return None + try: + return bool(_GPIO_LIB.input(self._pin)) + except Exception as e: + logging.warning(f"PIRMotionInput GPIO: read error on GPIO{self._pin}: {e}") + return None + + def stop(self): + if self._ready and _GPIO_AVAILABLE and _GPIO_LIB is not None: + try: + _GPIO_LIB.cleanup(self._pin) + logging.info(f"PIRMotionInput GPIO: GPIO{self._pin} cleaned up") + except Exception as e: + logging.warning(f"PIRMotionInput GPIO: cleanup error: {e}") + + +class _ZenohPIRConnector: + """ + Read HC-SR501 state from a Zenoh topic. + + Enables distributed setups where the PIR sensor is attached to a + remote device (e.g. a Raspberry Pi running a Zenoh publisher) and + the OM1 runtime is on a different machine on the same network. + + Expected message payload (UTF-8) + ---------------------------------- + ``"1"`` or ``"MOTION:1"`` → motion detected + ``"0"`` or ``"MOTION:0"`` → no motion + + Example publisher (Python) + --------------------------- + .. code-block:: python + + import zenoh, time + import RPi.GPIO as GPIO + + GPIO.setmode(GPIO.BCM) + GPIO.setup(17, GPIO.IN) + session = zenoh.open() + pub = session.declare_publisher("om/sensors/pir") + while True: + pub.put("1" if GPIO.input(17) else "0") + time.sleep(0.5) + """ + + def __init__(self, topic: str): + self._topic = topic + self._session = None + self._subscriber = None + self._queue: asyncio.Queue = asyncio.Queue() + + if not _ZENOH_AVAILABLE or _zenoh is None: + logging.error("PIRMotionInput zenoh: zenoh not available.") + return + try: + self._session = _zenoh.open(_zenoh.Config()) + self._subscriber = self._session.declare_subscriber(topic, self._on_message) + logging.info(f"PIRMotionInput zenoh: subscribed to '{topic}'") + except Exception as e: + logging.error( + f"PIRMotionInput zenoh: failed to subscribe to '{topic}': {e}" + ) + + def _on_message(self, sample): + try: + text = sample.payload.decode("utf-8").strip() + if text in ("1", "MOTION:1"): + self._queue.put_nowait(True) + elif text in ("0", "MOTION:0"): + self._queue.put_nowait(False) + else: + logging.debug(f"PIRMotionInput zenoh: unrecognised payload: '{text}'") + except Exception as e: + logging.warning(f"PIRMotionInput zenoh: message parse error: {e}") + + async def read(self) -> Optional[bool]: + if self._session is None: + return None + try: + return self._queue.get_nowait() + except asyncio.QueueEmpty: + return None + + def stop(self): + if self._subscriber: + try: + self._subscriber.undeclare() + except Exception as e: + logging.warning(f"PIRMotionInput zenoh: undeclare error: {e}") + if self._session: + try: + self._session.close() + logging.info("PIRMotionInput zenoh: session closed") + except Exception as e: + logging.warning(f"PIRMotionInput zenoh: session close error: {e}") + + +class _MockPIRConnector: + """ + Simulated PIR connector for development and testing. + + Generates synthetic motion events without any physical hardware. + This is the default connector, ensuring a safe out-of-box experience + on any machine. + + A motion event (``True``) is produced approximately once every + ``trigger_interval`` calls, with random jitter. + """ + + def __init__(self, trigger_interval: int = 10): + import random + + self._trigger_interval = trigger_interval + self._call_count = 0 + self._random = random + logging.info( + f"PIRMotionInput mock: simulation active " + f"(trigger_interval={trigger_interval}). No real hardware used." + ) + + async def read(self) -> Optional[bool]: + await asyncio.sleep(0) + self._call_count += 1 + jitter = self._random.randint( + max(1, self._trigger_interval // 2), + self._trigger_interval + self._trigger_interval // 2, + ) + if self._call_count >= jitter: + self._call_count = 0 + logging.debug("PIRMotionInput mock: simulated motion event") + return True + return False + + def stop(self): + logging.info("PIRMotionInput mock: stopped") + + +class PIRMotionInput(FuserInput[PIRMotionConfig, Optional[bool]]): + """ + PIR Motion Sensor (HC-SR501) input plugin for OM1. + + Reads motion detection events from an HC-SR501 PIR sensor and converts + them into natural language context for the LLM. Supports four hardware + backends selectable via the ``connector`` config field. + + A ``cooldown`` parameter prevents the LLM context from being flooded + when the sensor output stays HIGH (HC-SR501 can hold HIGH for up to + ~200 seconds). + + Example config entry (``config/pir_guard.json5``):: + + { + "type": "PIRMotionInput", + "config": { + "connector": "serial", + "port": "/dev/ttyUSB0", + "baudrate": 9600, + "cooldown": 5.0 + } + } + """ + + def __init__(self, config: PIRMotionConfig): + """ + Initialize PIRMotionInput. + + Parameters + ---------- + config : PIRMotionConfig + Plugin configuration. ``connector`` selects the hardware backend. + """ + super().__init__(config) + + self.descriptor_for_LLM = "PIR Motion Sensor" + self.io_provider = IOProvider() + self.messages: list[Message] = [] + self._last_motion_time: float = 0.0 + + connector = config.connector.lower() + + if connector == "serial": + self._connector = _SerialPIRConnector( + port=config.port, + baudrate=config.baudrate, + timeout=config.serial_timeout, + ) + elif connector == "gpio": + self._connector = _GPIOPIRConnector(pin=config.gpio_pin) + elif connector == "zenoh": + self._connector = _ZenohPIRConnector(topic=config.zenoh_topic) + elif connector == "mock": + self._connector = _MockPIRConnector( + trigger_interval=config.mock_trigger_interval + ) + else: + logging.error( + f"PIRMotionInput: unknown connector '{connector}'. " + "Valid options: serial, gpio, zenoh, mock. " + "Falling back to mock." + ) + self._connector = _MockPIRConnector( + trigger_interval=config.mock_trigger_interval + ) + + logging.info( + f"PIRMotionInput initialized: connector='{connector}', " + f"cooldown={config.cooldown}s" + ) + + async def _poll(self) -> Optional[bool]: + """ + Poll the active connector for a motion event. + + Applies cooldown logic to suppress repeated detections while + the sensor output remains HIGH. + + Returns + ------- + Optional[bool] + ``True`` if motion detected and cooldown has elapsed. + ``False`` if no motion detected. + ``None`` if connector is unavailable or within cooldown window. + """ + await asyncio.sleep(0.5) + + detected: Optional[bool] = await self._connector.read() + + if detected is True: + now = time.time() + if (now - self._last_motion_time) >= self.config.cooldown: + self._last_motion_time = now + logging.info("PIRMotionInput: motion detected (cooldown passed)") + return True + logging.debug( + "PIRMotionInput: motion detected but within cooldown, suppressing" + ) + return None + + return False + + async def _raw_to_text(self, raw_input: Optional[bool]) -> Optional[Message]: + """ + Convert raw sensor state to a natural language message. + + Parameters + ---------- + raw_input : Optional[bool] + ``True`` → motion detected. + ``False`` → no motion (not forwarded to LLM). + ``None`` → suppressed or unavailable. + + Returns + ------- + Optional[Message] + Timestamped message for LLM context, or ``None``. + """ + if raw_input is not True: + return None + + message = ( + "Motion detected by PIR sensor. " + "A person or moving object is present nearby. " + "Consider alerting or investigating." + ) + return Message(timestamp=time.time(), message=message) + + async def raw_to_text(self, raw_input: Optional[bool]): + """ + Convert raw input to text and append to message buffer. + + Parameters + ---------- + raw_input : Optional[bool] + Raw boolean state from the sensor connector. + """ + pending = await self._raw_to_text(raw_input) + if pending is not None: + self.messages.append(pending) + + def formatted_latest_buffer(self) -> Optional[str]: + """ + Format the latest buffered message for LLM context injection. + + Clears the buffer after formatting. + + Returns + ------- + Optional[str] + Formatted context string, or ``None`` if no events buffered. + """ + if len(self.messages) == 0: + return None + + latest = self.messages[-1] + + result = ( + f"\nINPUT: {self.descriptor_for_LLM}\n// START\n" + f"{latest.message}\n// END\n" + ) + + self.io_provider.add_input( + self.__class__.__name__, latest.message, latest.timestamp + ) + self.messages = [] + return result + + def stop(self): + """ + Gracefully shut down the sensor connector. + """ + logging.info("PIRMotionInput: stopping") + if self._connector and hasattr(self._connector, "stop"): + self._connector.stop() + self.messages = [] diff --git a/tests/inputs/plugins/test_pir_motion.py b/tests/inputs/plugins/test_pir_motion.py new file mode 100644 index 0000000000..a7a89dd08b --- /dev/null +++ b/tests/inputs/plugins/test_pir_motion.py @@ -0,0 +1,547 @@ +""" +Tests for PIRMotionInput plugin. + +Follows the OM1 test conventions from: +- tests/inputs/plugins/test_serial_reader.py +- tests/inputs/plugins/test_mock_input.py + +Run with: + pytest tests/inputs/plugins/test_pir_motion.py -v +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from inputs.base import Message +from inputs.plugins.pir_motion import ( + PIRMotionConfig, + PIRMotionInput, + _GPIOPIRConnector, + _MockPIRConnector, + _SerialPIRConnector, + _ZenohPIRConnector, +) + + +def make_plugin(connector: str = "mock", cooldown: float = 0.0) -> PIRMotionInput: + config = PIRMotionConfig(connector=connector, cooldown=cooldown) + with patch("inputs.plugins.pir_motion.IOProvider"): + return PIRMotionInput(config=config) + + +def test_default_connector_is_mock(): + config = PIRMotionConfig() + assert config.connector == "mock" + + +def test_default_cooldown(): + config = PIRMotionConfig() + assert config.cooldown == 5.0 + + +def test_serial_config_fields(): + config = PIRMotionConfig(connector="serial", port="/dev/ttyUSB0", baudrate=115200) + assert config.connector == "serial" + assert config.port == "/dev/ttyUSB0" + assert config.baudrate == 115200 + + +def test_gpio_config_fields(): + config = PIRMotionConfig(connector="gpio", gpio_pin=27) + assert config.gpio_pin == 27 + + +def test_zenoh_config_fields(): + config = PIRMotionConfig(connector="zenoh", zenoh_topic="robot/pir") + assert config.zenoh_topic == "robot/pir" + + +def test_mock_connector_selected_by_default(): + plugin = make_plugin("mock") + assert isinstance(plugin._connector, _MockPIRConnector) + + +def test_unknown_connector_falls_back_to_mock(): + plugin = make_plugin("nonexistent_xyz") + assert isinstance(plugin._connector, _MockPIRConnector) + + +def test_serial_connector_selected(): + with patch("inputs.plugins.pir_motion._serial.Serial"): + plugin = make_plugin("serial") + assert isinstance(plugin._connector, _SerialPIRConnector) + + +def test_gpio_connector_selected(): + mock_gpio = MagicMock() + mock_gpio.BCM = 11 + mock_gpio.IN = 1 + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", True), + patch("inputs.plugins.pir_motion._GPIO_LIB", mock_gpio), + ): + plugin = make_plugin("gpio") + assert isinstance(plugin._connector, _GPIOPIRConnector) + + +def test_zenoh_connector_selected(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + plugin = make_plugin("zenoh") + assert isinstance(plugin._connector, _ZenohPIRConnector) + + +@pytest.mark.asyncio +async def test_poll_returns_true_on_motion(): + plugin = make_plugin(cooldown=0.0) + plugin._connector.read = AsyncMock(return_value=True) + with patch("inputs.plugins.pir_motion.asyncio.sleep", new=AsyncMock()): + result = await plugin._poll() + assert result is True + + +@pytest.mark.asyncio +async def test_poll_returns_false_on_no_motion(): + plugin = make_plugin(cooldown=0.0) + plugin._connector.read = AsyncMock(return_value=False) + with patch("inputs.plugins.pir_motion.asyncio.sleep", new=AsyncMock()): + result = await plugin._poll() + assert result is False + + +@pytest.mark.asyncio +async def test_poll_returns_false_when_connector_returns_none(): + plugin = make_plugin(cooldown=0.0) + plugin._connector.read = AsyncMock(return_value=None) + with patch("inputs.plugins.pir_motion.asyncio.sleep", new=AsyncMock()): + result = await plugin._poll() + assert result is False + + +@pytest.mark.asyncio +async def test_cooldown_suppresses_second_motion(): + plugin = make_plugin(cooldown=60.0) + plugin._connector.read = AsyncMock(return_value=True) + with patch("inputs.plugins.pir_motion.asyncio.sleep", new=AsyncMock()): + first = await plugin._poll() + second = await plugin._poll() + assert first is True + assert second is None + + +@pytest.mark.asyncio +async def test_cooldown_allows_motion_after_elapsed(): + plugin = make_plugin(cooldown=0.0) + plugin._connector.read = AsyncMock(return_value=True) + with patch("inputs.plugins.pir_motion.asyncio.sleep", new=AsyncMock()): + first = await plugin._poll() + second = await plugin._poll() + assert first is True + assert second is True + + +@pytest.mark.asyncio +async def test_raw_to_text_true_produces_message(): + plugin = make_plugin() + with patch("inputs.plugins.pir_motion.time.time", return_value=1234.0): + msg = await plugin._raw_to_text(True) + assert msg is not None + assert msg.timestamp == 1234.0 + assert "motion" in msg.message.lower() + + +@pytest.mark.asyncio +async def test_raw_to_text_false_returns_none(): + plugin = make_plugin() + msg = await plugin._raw_to_text(False) + assert msg is None + + +@pytest.mark.asyncio +async def test_raw_to_text_none_returns_none(): + plugin = make_plugin() + msg = await plugin._raw_to_text(None) + assert msg is None + + +@pytest.mark.asyncio +async def test_raw_to_text_fills_buffer_on_motion(): + plugin = make_plugin() + await plugin.raw_to_text(True) + assert len(plugin.messages) == 1 + + +@pytest.mark.asyncio +async def test_raw_to_text_does_not_fill_buffer_on_false(): + plugin = make_plugin() + await plugin.raw_to_text(False) + assert len(plugin.messages) == 0 + + +def test_formatted_latest_buffer_empty_returns_none(): + plugin = make_plugin() + assert plugin.formatted_latest_buffer() is None + + +def test_formatted_latest_buffer_returns_formatted_string(): + plugin = make_plugin() + plugin.io_provider = MagicMock() + plugin.messages = [Message(timestamp=1000.0, message="Motion detected.")] + result = plugin.formatted_latest_buffer() + assert result is not None + assert "PIR Motion Sensor" in result + assert "Motion detected." in result + + +def test_formatted_latest_buffer_clears_messages(): + plugin = make_plugin() + plugin.io_provider = MagicMock() + plugin.messages = [Message(timestamp=1000.0, message="Motion detected.")] + plugin.formatted_latest_buffer() + assert len(plugin.messages) == 0 + + +def test_formatted_latest_buffer_calls_io_provider(): + plugin = make_plugin() + plugin.io_provider = MagicMock() + plugin.messages = [Message(timestamp=1000.0, message="Motion detected.")] + plugin.formatted_latest_buffer() + plugin.io_provider.add_input.assert_called_once() + + +def test_stop_calls_connector_stop(): + plugin = make_plugin() + plugin._connector.stop = MagicMock() + plugin.stop() + plugin._connector.stop.assert_called_once() + + +def test_stop_clears_message_buffer(): + plugin = make_plugin() + plugin.messages = [Message(timestamp=1000.0, message="test")] + plugin.stop() + assert plugin.messages == [] + + +@pytest.mark.asyncio +async def test_mock_connector_returns_bool(): + connector = _MockPIRConnector(trigger_interval=1) + result = await connector.read() + assert isinstance(result, bool) + + +@pytest.mark.asyncio +async def test_mock_connector_eventually_triggers(): + connector = _MockPIRConnector(trigger_interval=3) + results = [await connector.read() for _ in range(30)] + assert True in results + + +def test_serial_connector_no_serial_lib(): + with patch("inputs.plugins.pir_motion._SERIAL_AVAILABLE", False): + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + assert connector._ser is None + + +@pytest.mark.asyncio +async def test_serial_connector_parse_motion_1(): + with patch("inputs.plugins.pir_motion._serial.Serial") as mock_ser: + mock_ser.return_value.readline.return_value = b"MOTION:1\r\n" + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + result = await connector.read() + assert result is True + + +@pytest.mark.asyncio +async def test_serial_connector_parse_motion_0(): + with patch("inputs.plugins.pir_motion._serial.Serial") as mock_ser: + mock_ser.return_value.readline.return_value = b"MOTION:0\r\n" + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + result = await connector.read() + assert result is False + + +@pytest.mark.asyncio +async def test_serial_connector_unknown_line_returns_none(): + with patch("inputs.plugins.pir_motion._serial.Serial") as mock_ser: + mock_ser.return_value.readline.return_value = b"GARBAGE\r\n" + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + result = await connector.read() + assert result is None + + +@pytest.mark.asyncio +async def test_serial_connector_no_connection_returns_none(): + with patch("inputs.plugins.pir_motion._SERIAL_AVAILABLE", False): + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + result = await connector.read() + assert result is None + + +def test_gpio_connector_no_lib(): + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", False), + patch("inputs.plugins.pir_motion._GPIO_LIB", None), + ): + connector = _GPIOPIRConnector(pin=17) + assert connector._ready is False + + +@pytest.mark.asyncio +async def test_gpio_connector_reads_high(): + mock_gpio = MagicMock() + mock_gpio.BCM = 11 + mock_gpio.IN = 1 + mock_gpio.input.return_value = 1 + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", True), + patch("inputs.plugins.pir_motion._GPIO_LIB", mock_gpio), + ): + connector = _GPIOPIRConnector(pin=17) + result = await connector.read() + assert result is True + + +@pytest.mark.asyncio +async def test_gpio_connector_reads_low(): + mock_gpio = MagicMock() + mock_gpio.BCM = 11 + mock_gpio.IN = 1 + mock_gpio.input.return_value = 0 + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", True), + patch("inputs.plugins.pir_motion._GPIO_LIB", mock_gpio), + ): + connector = _GPIOPIRConnector(pin=17) + result = await connector.read() + assert result is False + + +@pytest.mark.asyncio +async def test_serial_connector_open_fails(): + import serial as _serial + + with patch( + "inputs.plugins.pir_motion._serial.Serial", + side_effect=_serial.SerialException("Port busy"), + ): + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + assert connector._ser is None + + +@pytest.mark.asyncio +async def test_serial_connector_read_exception_returns_none(): + with patch("inputs.plugins.pir_motion._serial.Serial") as mock_ser: + mock_ser.return_value.readline.side_effect = Exception("read error") + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + result = await connector.read() + assert result is None + + +def test_gpio_connector_setup_exception(): + mock_gpio = MagicMock() + mock_gpio.BCM = 11 + mock_gpio.IN = 1 + mock_gpio.setup.side_effect = Exception("GPIO busy") + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", True), + patch("inputs.plugins.pir_motion._GPIO_LIB", mock_gpio), + ): + connector = _GPIOPIRConnector(pin=17) + assert connector._ready is False + + +@pytest.mark.asyncio +async def test_gpio_connector_not_ready_returns_none(): + connector = _GPIOPIRConnector(pin=17) + connector._ready = False + result = await connector.read() + assert result is None + + +@pytest.mark.asyncio +async def test_gpio_connector_read_exception_returns_none(): + mock_gpio = MagicMock() + mock_gpio.BCM = 11 + mock_gpio.IN = 1 + mock_gpio.input.side_effect = Exception("GPIO error") + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", True), + patch("inputs.plugins.pir_motion._GPIO_LIB", mock_gpio), + ): + connector = _GPIOPIRConnector(pin=17) + result = await connector.read() + assert result is None + + +def test_zenoh_connector_no_zenoh_lib(): + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", False), + patch("inputs.plugins.pir_motion._zenoh", None), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + assert connector._session is None + + +def test_zenoh_connector_subscribe_fails(): + mock_zenoh = MagicMock() + mock_zenoh.open.side_effect = Exception("connection failed") + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + assert connector._session is None + + +def test_zenoh_on_message_motion_1(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + sample = MagicMock() + sample.payload.decode.return_value = "MOTION:1" + connector._on_message(sample) + assert connector._queue.get_nowait() is True + + +def test_zenoh_on_message_motion_0(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + sample = MagicMock() + sample.payload.decode.return_value = "MOTION:0" + connector._on_message(sample) + assert connector._queue.get_nowait() is False + + +def test_zenoh_on_message_unknown_payload(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + sample = MagicMock() + sample.payload.decode.return_value = "GARBAGE" + connector._on_message(sample) + assert connector._queue.empty() + + +def test_zenoh_on_message_exception(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + sample = MagicMock() + sample.payload.decode.side_effect = Exception("decode error") + connector._on_message(sample) + assert connector._queue.empty() + + +@pytest.mark.asyncio +async def test_zenoh_read_no_session_returns_none(): + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", False), + patch("inputs.plugins.pir_motion._zenoh", None), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + result = await connector.read() + assert result is None + + +@pytest.mark.asyncio +async def test_zenoh_read_empty_queue_returns_none(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + result = await connector.read() + assert result is None + + +def test_zenoh_stop_calls_undeclare_and_close(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + subscriber = connector._subscriber + session = connector._session + connector.stop() + assert subscriber is not None + assert session is not None + + +def test_zenoh_stop_undeclare_exception(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + if connector._subscriber: + connector._subscriber.undeclare = MagicMock( + side_effect=Exception("undeclare error") + ) + connector.stop() + + +def test_zenoh_stop_close_exception(): + mock_zenoh = MagicMock() + with ( + patch("inputs.plugins.pir_motion._ZENOH_AVAILABLE", True), + patch("inputs.plugins.pir_motion._zenoh", mock_zenoh), + ): + connector = _ZenohPIRConnector("om/sensors/pir") + if connector._session: + connector._session.close = MagicMock(side_effect=Exception("close error")) + connector.stop() + + +def test_serial_connector_stop_closes_open_port(): + with patch("inputs.plugins.pir_motion._serial.Serial") as mock_ser: + mock_ser.return_value.is_open = True + connector = _SerialPIRConnector("/dev/ttyUSB0", 9600, 1.0) + connector.stop() + mock_ser.return_value.close.assert_called_once() + + +def test_gpio_connector_stop_calls_cleanup(): + mock_gpio = MagicMock() + mock_gpio.BCM = 11 + mock_gpio.IN = 1 + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", True), + patch("inputs.plugins.pir_motion._GPIO_LIB", mock_gpio), + ): + connector = _GPIOPIRConnector(pin=17) + connector.stop() + mock_gpio.cleanup.assert_called_once_with(17) + + +def test_gpio_connector_stop_cleanup_exception(): + mock_gpio = MagicMock() + mock_gpio.BCM = 11 + mock_gpio.IN = 1 + mock_gpio.cleanup.side_effect = Exception("cleanup error") + with ( + patch("inputs.plugins.pir_motion._GPIO_AVAILABLE", True), + patch("inputs.plugins.pir_motion._GPIO_LIB", mock_gpio), + ): + connector = _GPIOPIRConnector(pin=17) + connector.stop()