From f462fec86f2764a265f8dffd569d426ff9586106 Mon Sep 17 00:00:00 2001 From: Taras Syvash Date: Tue, 16 Dec 2025 18:52:09 +0200 Subject: [PATCH] init --- README.md | 51 ++++++ __pycache__/serial_heartbeat.cpython-314.pyc | Bin 0 -> 11661 bytes requirements.txt | 2 + serial-heartbeat.service | 20 +++ serial_heartbeat.py | 177 +++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/serial_heartbeat.cpython-314.pyc create mode 100644 requirements.txt create mode 100644 serial-heartbeat.service create mode 100644 serial_heartbeat.py 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 0000000000000000000000000000000000000000..d54a4dd9e45762df119f4520f50ca5a0799f1bd4 GIT binary patch literal 11661 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 literal 0 HcmV?d00001 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:]))