commit f462fec86f2764a265f8dffd569d426ff9586106 Author: Taras Syvash Date: Tue Dec 16 18:52:09 2025 +0200 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..4265b50 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Серійний релеювальник Heartbeat + +Цей репозиторій містить невеликий Python‑скрипт, який слухає серійний порт, +шукає JSON‑heartbeat повідомлення та відповідає `{"hb": 2}` на `{"hb": 1}`. +Його можна запускати як звичайну програму або як сервіс systemd із підтримкою +watchdog. + +## Вимоги + +- Python 3.10+ +- `pyserial` +- `sdnotify` (необов’язково, але потрібен для інтеграції з systemd) + +Встановіть залежності в оточення: + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +``` + +## Ручний запуск + +```bash +python3 serial_heartbeat.py --port /dev/ttyUSB0 --baudrate 115200 --log-level DEBUG +``` + +Аргументи: + +- `--port` (обов’язково): шлях до серійного пристрою (наприклад, `/dev/ttyUSB0`). +- `--baudrate`: за замовчуванням 9600. +- `--log-level`: будь-який рівень `logging`, типово `INFO`. + +## Сервіс systemd + +1. Скопіюйте `serial-heartbeat.service` в `/etc/systemd/system/serial-heartbeat.service`. +2. Відкоригуйте шляхи, швидкість та пристрій за потреби. +3. Перезавантажте systemd і увімкніть сервіс: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now serial-heartbeat +``` + +Сервіс використовує `Type=notify`, тож systemd очікує сигнал готовності і +контролює watchdog з таймаутом 20 секунд. + +## Розробка + +- `python3 -m py_compile serial_heartbeat.py` — швидка перевірка синтаксису. +- Увімкніть рівень журналювання DEBUG, щоб переглядати JSON‑трафік у серійному каналі. diff --git a/__pycache__/serial_heartbeat.cpython-314.pyc b/__pycache__/serial_heartbeat.cpython-314.pyc new file mode 100644 index 0000000..d54a4dd Binary files /dev/null and b/__pycache__/serial_heartbeat.cpython-314.pyc differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..97cb8a8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyserial>=3.5 +sdnotify>=0.3.2 diff --git a/serial-heartbeat.service b/serial-heartbeat.service new file mode 100644 index 0000000..61a0f7a --- /dev/null +++ b/serial-heartbeat.service @@ -0,0 +1,20 @@ +[Unit] +Description=Serial heartbeat responder +After=network.target + +[Service] +Type=notify +NotifyAccess=all +Environment=PYTHONUNBUFFERED=1 +Environment=SERIAL_PORT=/dev/ttyUSB0 +Environment=SERIAL_BAUDRATE=9600 +ExecStart=/usr/bin/env python3 /opt/watch-watch-server/serial_heartbeat.py --port ${SERIAL_PORT} --baudrate ${SERIAL_BAUDRATE} +Restart=on-failure +RestartSec=3 +WatchdogSec=20 +WorkingDirectory=/opt/watch-watch-server +StandardOutput=journal +StandardError=inherit + +[Install] +WantedBy=multi-user.target diff --git a/serial_heartbeat.py b/serial_heartbeat.py new file mode 100644 index 0000000..8cc14eb --- /dev/null +++ b/serial_heartbeat.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Simple heartbeat relay for a serial port.""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +import time +from typing import Generator, Optional + +try: + import serial # type: ignore +except ImportError as exc: # pragma: no cover - fail fast when dependency missing + raise SystemExit( + "pyserial is required. Install dependencies via `pip install -r requirements.txt`." + ) from exc + +try: + from sdnotify import SystemdNotifier +except ImportError: + SystemdNotifier = None # type: ignore[assignment] + + +class SystemdIntegration: + """Minimal helper around sdnotify so the service can run under systemd.""" + + def __init__(self) -> None: + self._notifier = self._init_notifier() + self._watchdog_interval = self._resolve_watchdog_interval() + self._last_watchdog = time.monotonic() + self._ready_sent = False + + def _init_notifier(self) -> Optional["SystemdNotifier"]: + if "NOTIFY_SOCKET" not in os.environ: + return None + if SystemdNotifier is None: + logging.warning("sdnotify is not installed but systemd notification is requested.") + return None + try: + return SystemdNotifier() + except Exception as exc: # pragma: no cover - defensive + logging.warning("Unable to initialize systemd notifier: %s", exc) + return None + + def _resolve_watchdog_interval(self) -> Optional[float]: + if not self._notifier: + return None + watchdog_usec = os.environ.get("WATCHDOG_USEC") + if not watchdog_usec: + return None + try: + interval_seconds = int(watchdog_usec) / 1_000_000 + except (TypeError, ValueError): + logging.warning("Invalid WATCHDOG_USEC value: %s", watchdog_usec) + return None + # systemd expects to receive WATCHDOG=1 at least every WATCHDOG_USEC/2 + return max(interval_seconds / 2, 0.5) + + def status(self, message: str) -> None: + self._send(f"STATUS={message}") + + def ready(self, message: str | None = None) -> None: + self._ready_sent = True + self._last_watchdog = time.monotonic() + self._send("READY=1", message) + + def watchdog_ping(self) -> None: + if not self._ready_sent or not self._watchdog_interval or not self._notifier: + return + now = time.monotonic() + if now - self._last_watchdog >= self._watchdog_interval: + self._notifier.notify("WATCHDOG=1") + self._last_watchdog = now + + def stopping(self, message: str | None = None) -> None: + if not self._ready_sent: + return + self._send("STOPPING=1", message) + + def _send(self, primary: str, message: str | None = None) -> None: + if not self._notifier: + return + payload = primary + if message: + payload = f"{payload}\nSTATUS={message}" + self._notifier.notify(payload) + + +def iter_json_objects(port: serial.Serial, systemd: Optional[SystemdIntegration] = None) -> Generator[dict, None, None]: + """Yield JSON objects that appear on the serial stream.""" + decoder = json.JSONDecoder() + buffer = "" + + while True: + chunk = port.read(port.in_waiting or 1) + if systemd: + systemd.watchdog_ping() + if not chunk: + continue + + buffer += chunk.decode("utf-8", errors="ignore") + + while True: + start = buffer.find("{") + if start == -1: + buffer = buffer[-1:] + break + if start: + buffer = buffer[start:] + + try: + payload, idx = decoder.raw_decode(buffer) + except json.JSONDecodeError: + break + + buffer = buffer[idx:] + if isinstance(payload, dict): + yield payload + + +def relay_heartbeat(port_name: str, baudrate: int, systemd: Optional[SystemdIntegration] = None) -> None: + """Read heartbeats from ``port_name`` and reply with {"hb": 2}.""" + try: + if systemd: + systemd.status(f"Opening serial port {port_name} @ {baudrate} baud") + with serial.Serial(port=port_name, baudrate=baudrate, timeout=1) as ser: + logging.info("Listening on %s @ %s baud", port_name, baudrate) + if systemd: + systemd.ready(f"Listening on {port_name} @ {baudrate} baud") + for message in iter_json_objects(ser, systemd): + hb_value = message.get("hb") + logging.debug("Received payload %s", message) + + if hb_value == 1: + ack = json.dumps({"hb": 2}, separators=(",", ":")).encode("utf-8") + b"\n" + ser.write(ack) + ser.flush() + logging.info("Sent reply %s", ack.strip()) + except serial.SerialException as exc: + logging.error("Serial communication error on %s: %s", port_name, exc) + raise + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Serial heartbeat responder.") + parser.add_argument("--port", required=True, help="Serial port path (e.g., /dev/ttyUSB0).") + parser.add_argument("--baudrate", type=int, default=9600, help="Serial baudrate.") + parser.add_argument("--log-level", default="INFO", help="Logging level (INFO, DEBUG ...).") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + logging.basicConfig(level=getattr(logging, args.log_level.upper(), logging.INFO), format="%(message)s") + systemd = SystemdIntegration() + systemd.status("Initializing serial heartbeat responder") + try: + relay_heartbeat(args.port, args.baudrate, systemd=systemd) + except serial.SerialException: + systemd.stopping("Serial communication failure.") + return 1 + except KeyboardInterrupt: + logging.info("Interrupted by user, shutting down.") + systemd.stopping("Interrupted by user, shutting down.") + return 130 + except Exception: + systemd.stopping("Unexpected failure, see logs.") + raise + systemd.stopping("Exiting cleanly.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))