Ctrl K
ComunidadGuías…

relay_controller.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
relay_controller.py — Arduino relay controller (SP5IOU SDR220 emulator).

Talks to the Arduino over a serial port using a text protocol:
  SKn  - turn relay n on (n=0..7)
  RKn  - turn relay n off
  RPK  - status of all relays (binary)
  IDN? - identification

Supports two operating modes per relay (configured in webapp.py):
  - "manual"    - toggle on/off (the user clicks, the state changes)
  - "momentary" - pulse: turn on -> wait T seconds -> turn off automatically

Safety:
  - Max pulse duration: 10.0s (hardcoded MAX_PULSE_S)
  - If the server crashes mid-pulse, the timer won't complete — the relay
    stays ON. A hardware fail-safe or an Arduino watchdog is recommended.
"""

import asyncio
import serial
import serial.tools.list_ports


MAX_PULSE_S = 10.0
RELAY_COUNT = 8


class RelayController:
    """Relay controller over Arduino UART."""

    def __init__(self, port: str, baudrate: int = 9600):
        self.port = port
        self.baudrate = baudrate
        self._serial: serial.Serial | None = None
        self._states: list[bool] = [False] * RELAY_COUNT  # cached state
        self._lock = asyncio.Lock()
        self._pulse_tasks: dict[int, asyncio.Task] = {}
        self._connected = False

    async def connect(self) -> bool:
        """Opens the serial port. Returns True on success."""
        try:
            self._serial = serial.Serial(
                port=self.port,
                baudrate=self.baudrate,
                timeout=1.0,
                write_timeout=1.0,
            )
            # The Arduino resets when the port is opened - wait for it to boot
            await asyncio.sleep(2.0)
            self._serial.reset_input_buffer()
            self._connected = True
            print(f"[relay] Connected to Arduino on {self.port} @ {self.baudrate} bps")
            # Read the initial state
            await self.read_all_states()
            return True
        except Exception as e:
            print(f"[relay] Connection error on {self.port}: {e}")
            self._connected = False
            return False

    def is_connected(self) -> bool:
        return self._connected and self._serial is not None and self._serial.is_open

    async def disconnect(self):
        # Stop all active pulse tasks
        for task in list(self._pulse_tasks.values()):
            task.cancel()
        self._pulse_tasks.clear()
        if self._serial:
            try:
                self._serial.close()
            except Exception:
                pass
        self._connected = False

    async def _send_command(self, cmd: str) -> str:
        """Sends a command and reads the reply. Returns the reply or an empty string."""
        if not self.is_connected():
            return ""
        async with self._lock:
            try:
                # Send the command (the Arduino expects CR/LF)
                self._serial.write((cmd + "\r\n").encode("ascii"))
                self._serial.flush()
                # Brief wait for the reply
                await asyncio.sleep(0.05)
                if self._serial.in_waiting > 0:
                    data = self._serial.read(self._serial.in_waiting)
                    return data.decode("ascii", errors="ignore").strip()
                return ""
            except Exception as e:
                print(f"[relay] Error sending '{cmd}': {e}")
                self._connected = False
                return ""

    async def set_on(self, relay_num: int) -> bool:
        """Turn a relay on (SKn)."""
        if not 0 <= relay_num < RELAY_COUNT:
            return False
        await self._send_command(f"SK{relay_num}")
        self._states[relay_num] = True
        return True

    async def set_off(self, relay_num: int) -> bool:
        """Turn a relay off (RKn)."""
        if not 0 <= relay_num < RELAY_COUNT:
            return False
        await self._send_command(f"RK{relay_num}")
        self._states[relay_num] = False
        return True

    async def toggle(self, relay_num: int) -> bool:
        """Flip a relay's state."""
        if not 0 <= relay_num < RELAY_COUNT:
            return False
        if self._states[relay_num]:
            return await self.set_off(relay_num)
        else:
            return await self.set_on(relay_num)

    async def pulse(self, relay_num: int, duration_s: float) -> bool:
        """Pulse: turn on, wait, turn off. Cancels a previous pulse if one exists.
        Safety: duration_s is clamped to <0, MAX_PULSE_S>."""
        if not 0 <= relay_num < RELAY_COUNT:
            return False
        duration_s = max(0.0, min(MAX_PULSE_S, float(duration_s)))
        # Cancel any previous pulse task for this relay
        prev = self._pulse_tasks.pop(relay_num, None)
        if prev and not prev.done():
            prev.cancel()

        async def _pulse_task():
            try:
                await self.set_on(relay_num)
                await asyncio.sleep(duration_s)
                await self.set_off(relay_num)
            except asyncio.CancelledError:
                # If cancelled - turn off just in case
                await self.set_off(relay_num)
                raise

        task = asyncio.create_task(_pulse_task())
        self._pulse_tasks[relay_num] = task
        return True

    async def read_all_states(self) -> list[bool]:
        """Read the state of all relays from the Arduino (RPK)."""
        resp = await self._send_command("RPK")
        # The Arduino returns a binary string, e.g. "10110000" (bit 0 = REL0)
        # The format may include extra characters: "String: RPK\r\n10110000"
        if resp:
            # Find a run of 8 '0'/'1' characters
            for line in resp.split("\n"):
                line = line.strip()
                if len(line) >= 8 and all(c in "01" for c in line[-8:]):
                    bits = line[-8:]
                    # LSB first in the SDR220: bit 0 = REL0
                    self._states = [bits[7 - i] == "1" for i in range(RELAY_COUNT)]
                    break
        return list(self._states)

    def get_states(self) -> list[bool]:
        """Return the current cached states (without querying the Arduino)."""
        return list(self._states)

    def get_state(self, relay_num: int) -> bool:
        if 0 <= relay_num < RELAY_COUNT:
            return self._states[relay_num]
        return False


def list_serial_ports() -> list[dict]:
    """Return the list of available serial ports (for the config UI)."""
    ports = []
    for p in serial.tools.list_ports.comports():
        ports.append({
            "device": p.device,
            "description": p.description or "",
            "manufacturer": p.manufacturer or "",
        })
    return ports