init
This commit is contained in:
51
README.md
Normal file
51
README.md
Normal file
@@ -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‑трафік у серійному каналі.
|
||||
BIN
__pycache__/serial_heartbeat.cpython-314.pyc
Normal file
BIN
__pycache__/serial_heartbeat.cpython-314.pyc
Normal file
Binary file not shown.
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pyserial>=3.5
|
||||
sdnotify>=0.3.2
|
||||
20
serial-heartbeat.service
Normal file
20
serial-heartbeat.service
Normal file
@@ -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
|
||||
177
serial_heartbeat.py
Normal file
177
serial_heartbeat.py
Normal file
@@ -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:]))
|
||||
Reference in New Issue
Block a user