Coverage for kv4p/transports/serial.py: 0%
75 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 13:27 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 13:27 +0000
1"""Serial transport for the KV4P-HT (ESP32) radio."""
3from __future__ import annotations
5import logging
6import threading
7import time
8from collections.abc import Callable
10from kv4p.protocol.kiss import KissParser, encode_kiss_frame
11from kv4p.transports import Kv4pTransport
13logger = logging.getLogger(__name__)
15# Standard ESP32 auto-program circuit: RTS drives EN/CHIP_PU (reset), DTR drives
16# GPIO0 (boot mode select). pyserial dtr/rts=True is inverted to a LOW level on
17# the board through the auto-program transistors. Holding DTR deasserted while
18# pulsing RTS resets the chip into its normal firmware (not the ROM bootloader).
19_RESET_PULSE_SECONDS = 0.1
20_RESET_SETTLE_SECONDS = 0.05
23class Kv4pSerialTransport(Kv4pTransport):
24 """Blocking serial transport with an RX thread, for use with `Kv4pRadio`."""
26 def __init__(self, device: str, baudrate: int) -> None:
27 self._device = device
28 self._baudrate = baudrate
29 self._parser: KissParser | None = None
30 self._serial = None
31 self._thread: threading.Thread | None = None
32 self._stop = threading.Event()
33 self._write_lock = threading.Lock()
34 self._on_error: Callable[[Exception], None] | None = None
36 def open(
37 self,
38 on_frame: Callable[[int, bytes], None],
39 on_error: Callable[[Exception], None] | None = None,
40 ) -> None:
41 """Open serial port and start the RX thread."""
42 import serial
44 self._parser = KissParser(on_frame)
45 self._on_error = on_error
46 self._serial = serial.Serial(self._device, self._baudrate, timeout=0.2)
47 self._serial.rts = False
48 self._serial.dtr = False
49 self._stop.clear()
51 self._thread = threading.Thread(
52 target=self._read_loop,
53 name="kv4p-rx",
54 daemon=True,
55 )
57 self._thread.start()
58 logger.info("serial open device=%s baudrate=%d", self._device, self._baudrate)
60 def close(self) -> None:
61 """Stop RX thread and close serial port."""
62 self._stop.set()
63 serial_port = self._serial
64 self._serial = None
66 if serial_port is not None:
67 serial_port.close()
69 if self._thread is not None:
70 self._thread.join(timeout=2)
72 if self._thread.is_alive():
73 logger.warning("serial RX thread did not stop within timeout")
75 self._thread = None
77 logger.info("serial closed")
79 def reset(self) -> None:
80 """Hardware-reset the ESP32 into its normal firmware via an RTS pulse.
82 DTR is held deasserted throughout so GPIO0 stays in run mode (not
83 bootloader mode); RTS is pulsed to toggle EN/CHIP_PU.
84 """
85 with self._write_lock:
86 if self._serial is None:
87 raise RuntimeError("serial transport is not open")
88 self._serial.dtr = False
89 self._serial.rts = True
90 time.sleep(_RESET_PULSE_SECONDS)
91 self._serial.rts = False
92 time.sleep(_RESET_SETTLE_SECONDS)
93 logger.info("serial reset device=%s", self._device)
95 def write_frame(self, command: int, payload: bytes) -> None:
96 """Write one KISS frame."""
97 frame = encode_kiss_frame(command, payload)
99 with self._write_lock:
100 if self._serial is None:
101 raise RuntimeError("serial transport is not open")
103 self._serial.write(frame)
105 logger.debug("serial tx KISS command=0x%02x payload=%d frame=%d", command, len(payload), len(frame))
107 def flush(self) -> None:
108 """Wait until serial output is written."""
109 with self._write_lock:
110 self._serial.flush()
112 def _read_loop(self) -> None:
113 while not self._stop.is_set():
114 try:
115 data = self._serial.read(512)
116 except Exception as exc:
117 if not self._stop.is_set():
118 logger.exception("serial read failed")
119 if self._on_error is not None:
120 self._on_error(exc)
121 return
123 if data:
124 self._parser.feed(data)