Coverage for kv4p/state_tracker.py: 85%
204 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"""Protocol state machine: HELLO handshake, device-state cache, HostDesiredState building."""
3from __future__ import annotations
5import logging
6import threading
7from collections.abc import Callable
9from kv4p.constants.messages import (
10 DEVICE_STATE_PHYS_PTT_DOWN,
11 DEVICE_STATE_SQUELCHED,
12 DEVICE_STATE_TX_ACTIVE,
13 HOST_STATE_PTT_REQUESTED,
14 HOST_STATE_RADIO_CONFIG_VALID,
15 HOST_STATE_TX_ALLOWED,
16)
17from kv4p.constants.vendor import COMMAND_AUDIO_ADPCM, COMMAND_AUDIO_OPUS
18from kv4p.messages.desired_state import HostDesiredState, dra818_to_bandwidth
19from kv4p.messages.device_state import DeviceState, RadioMode
20from kv4p.messages.hello import Hello
22logger = logging.getLogger(__name__)
24# Firmware 17 is the last known version using Opus TX audio on 0x07. There is
25# no published feature bit to detect ADPCM (0x0C) support, and it's unknown
26# at which version upstream actually switches — this threshold is a guess
27# based on "17 is confirmed 0x07" and must be revisited once a newer firmware
28# version's actual behavior is known.
29_OPUS_MAX_FW = 17
32class DeviceStateTracker:
33 """Tracks the firmware handshake/state and builds outgoing HostDesiredState frames.
35 Radio settings (frequency, bandwidth, squelch, CTCSS, ...) are seeded from
36 the DeviceState carried in HELLO — the firmware always reports its actual
37 tuned state there, right after `Kv4pRadio.open()`/`reset()` forces a
38 reboot. There is no separate "desired settings" object with its own
39 defaults; `set_*()` calls mutate this tracked state directly.
40 """
42 def __init__(
43 self,
44 send_desired_state: Callable[[HostDesiredState], None],
45 on_rx_audio: Callable[[bytes], None] | None = None,
46 on_sql: Callable[[bool], None] | None = None,
47 on_phy_ptt: Callable[[bool], None] | None = None,
48 on_tx_active: Callable[[bool], None] | None = None,
49 ) -> None:
50 self._send_desired_state = send_desired_state
51 self._on_rx_audio = on_rx_audio
52 self._on_sql = on_sql
53 self._on_phy_ptt = on_phy_ptt
54 self._on_tx_active = on_tx_active
56 self._lock = threading.RLock()
57 self._hello_event = threading.Event()
58 self._hello: Hello | None = None
59 self._device_state: DeviceState | None = None
60 self._sequence = 0
61 self._flags = HOST_STATE_RADIO_CONFIG_VALID
62 self._tx_audio_command = COMMAND_AUDIO_OPUS
64 # Radio settings, seeded from HELLO's DeviceState in on_hello().
65 self._freq_rx = 0.0
66 self._freq_tx = 0.0
67 self._bw = 0
68 self._squelch = 0
69 self._ctcss_rx = 0
70 self._ctcss_tx = 0
72 self._last_sql_open: bool | None = None
73 self._last_phy_ptt: bool | None = None
74 self._last_tx_active: bool | None = None
75 self._last_status_key: tuple[object, ...] | None = None
77 # -- handshake / incoming state -----------------------------------------
79 def on_hello(self, hello: Hello) -> None:
80 """Handle a HELLO frame (only ever sent once, right after the ESP32 boots)."""
81 with self._lock:
82 self._hello = hello
83 self._device_state = hello.device_state
84 self._sequence = hello.device_state.applied_sequence
85 self._flags = (hello.device_state.flags
86 & ~(DEVICE_STATE_PHYS_PTT_DOWN | DEVICE_STATE_TX_ACTIVE | DEVICE_STATE_SQUELCHED )) \
87 | HOST_STATE_RADIO_CONFIG_VALID
88 self._seed_settings_locked(hello.device_state)
89 self._tx_audio_command = (
90 COMMAND_AUDIO_ADPCM if hello.version.ver > _OPUS_MAX_FW else COMMAND_AUDIO_OPUS
91 )
92 logger.info(
93 "HELLO firmware=%d window=%d radio=%s range=%.3f-%.3f features=0x%02x",
94 hello.version.ver,
95 hello.version.window_size,
96 hello.version.radio_module_status,
97 hello.version.min_radio_freq,
98 hello.version.max_radio_freq,
99 hello.version.features,
100 )
101 self._hello_event.set()
102 self.on_device_state(hello.device_state)
104 def wait_for_hello(self, timeout: float | None = None) -> bool:
105 """Block until HELLO has been received."""
106 return self._hello_event.wait(timeout=timeout)
108 def on_device_state(self, state: DeviceState) -> None:
109 """Handle a DEVICE_STATE frame."""
110 with self._lock:
111 self._device_state = state
112 sql_open = state.sql_open
113 if state.applied_sequence > self._sequence:
114 self._sequence = state.applied_sequence
116 self._log_device_status(state)
118 if sql_open != self._last_sql_open:
119 self._last_sql_open = sql_open
120 logger.info("sql %s", "open" if sql_open else "closed")
121 if self._on_sql is not None:
122 self._on_sql(sql_open)
124 phy_ptt = bool(state.flags & DEVICE_STATE_PHYS_PTT_DOWN)
125 if phy_ptt != self._last_phy_ptt:
126 self._last_phy_ptt = phy_ptt
127 logger.info("physical ptt %s", "down" if phy_ptt else "up")
128 if self._on_phy_ptt is not None:
129 self._on_phy_ptt(phy_ptt)
131 tx_active = bool(state.flags & DEVICE_STATE_TX_ACTIVE)
132 if tx_active != self._last_tx_active:
133 self._last_tx_active = tx_active
134 logger.info("tx active %s", tx_active)
135 if self._on_tx_active is not None:
136 self._on_tx_active(tx_active)
138 def on_rx_audio(self, payload: bytes) -> None:
139 """Handle an RX audio payload."""
140 if self._on_rx_audio is not None:
141 self._on_rx_audio(payload)
143 # -- setters ----------------------------------------------------------------
145 def request_ptt(self, enabled: bool) -> bool:
146 """Set/clear the PTT-requested bit. Returns True if the flags actually changed."""
147 with self._lock:
148 if enabled and not (self._flags & HOST_STATE_TX_ALLOWED):
149 logger.warning("PTT requested while TX is not allowed")
150 old_flags = self._flags
151 if enabled:
152 self._flags |= HOST_STATE_PTT_REQUESTED
153 else:
154 self._flags &= ~HOST_STATE_PTT_REQUESTED
155 if self._flags == old_flags:
156 return False
157 logger.info("ptt %s", "on" if enabled else "off")
158 self._send_desired_state_locked()
159 return True
161 def set_frequency(self, *, rx: float | None = None, tx: float | None = None) -> None:
162 """Update RX/TX frequency and send the new desired state."""
163 with self._lock:
164 if rx is not None:
165 self._freq_rx = rx
166 if tx is not None:
167 self._freq_tx = tx
168 self._send_desired_state_locked()
170 def set_bandwidth(self, bw: int) -> None:
171 """Update bandwidth (a DRA818_* constant) and send the new desired state."""
172 with self._lock:
173 self._bw = bw
174 self._send_desired_state_locked()
176 def set_squelch(self, squelch: int) -> None:
177 """Update squelch level and send the new desired state."""
178 with self._lock:
179 self._squelch = squelch
180 self._send_desired_state_locked()
182 def set_ctcss(self, *, rx: int | None = None, tx: int | None = None) -> None:
183 """Update RX/TX CTCSS tone and send the new desired state."""
184 with self._lock:
185 if rx is not None:
186 self._ctcss_rx = rx
187 if tx is not None:
188 self._ctcss_tx = tx
189 self._send_desired_state_locked()
191 def set_flag(self, flag: int, enabled: bool) -> None:
192 """Set/clear one of the HOST_STATE_* option bits and send the new desired state."""
193 with self._lock:
194 if enabled:
195 self._flags |= flag
196 else:
197 self._flags &= ~flag
198 self._send_desired_state_locked()
200 def _send_desired_state_locked(self) -> None:
201 applied_sequence = self._device_state.applied_sequence if self._device_state is not None else 0
202 self._sequence = max(self._sequence, applied_sequence) + 1
203 state = self._build_desired_state_locked()
204 # Release the lock before handing off to I/O: the caller's send callback
205 # may block on flow control / serial writes and must not hold up readers
206 # of phy_ptt/mode from other threads.
207 self._lock.release()
208 try:
209 self._send_desired_state(state)
210 finally:
211 self._lock.acquire()
212 logger.info(
213 (
214 "desired state sequence=%d flags=0x%04x rx=%.5f tx=%.5f "
215 "bw=%s squelch=%d ctcss_rx=%d ctcss_tx=%d"
216 ),
217 state.sequence,
218 state.flags,
219 state.freq_rx,
220 state.freq_tx,
221 dra818_to_bandwidth(state.bw),
222 state.squelch,
223 state.ctcss_rx,
224 state.ctcss_tx,
225 )
227 def _build_desired_state_locked(self) -> HostDesiredState:
228 return HostDesiredState(
229 sequence=self._sequence,
230 memory_id=-1,
231 flags=self._flags,
232 bw=self._bw,
233 freq_tx=self._freq_tx,
234 freq_rx=self._freq_rx,
235 ctcss_tx=self._ctcss_tx,
236 squelch=self._squelch,
237 ctcss_rx=self._ctcss_rx,
238 )
240 # -- derived properties -----------------------------------------------
242 @property
243 def hello(self) -> Hello | None:
244 with self._lock:
245 return self._hello
247 @property
248 def device_state(self) -> DeviceState | None:
249 with self._lock:
250 return self._device_state
252 @property
253 def flags(self) -> int:
254 with self._lock:
255 return self._flags
257 @property
258 def freq_rx(self) -> float:
259 with self._lock:
260 return self._freq_rx
262 @property
263 def freq_tx(self) -> float:
264 with self._lock:
265 return self._freq_tx
267 @property
268 def bandwidth(self) -> str:
269 with self._lock:
270 return dra818_to_bandwidth(self._bw)
272 @property
273 def squelch(self) -> int:
274 with self._lock:
275 return self._squelch
277 @property
278 def ctcss_rx(self) -> int:
279 with self._lock:
280 return self._ctcss_rx
282 @property
283 def ctcss_tx(self) -> int:
284 with self._lock:
285 return self._ctcss_tx
287 @property
288 def tx_audio_command(self) -> int:
289 """TX audio vendor command, guessed once in on_hello() from the firmware version.
291 See `_OPUS_MAX_FW` above — there is no feature bit to detect this properly yet.
292 """
293 with self._lock:
294 return self._tx_audio_command
296 @property
297 def phy_ptt(self) -> bool:
298 with self._lock:
299 if self._device_state is None:
300 return False
301 return bool(self._device_state.flags & DEVICE_STATE_PHYS_PTT_DOWN)
303 @property
304 def tx_active(self) -> bool:
305 with self._lock:
306 if self._device_state is None:
307 return False
308 return bool(self._device_state.flags & DEVICE_STATE_TX_ACTIVE)
310 @property
311 def sql_open(self) -> bool:
312 with self._lock:
313 if self._device_state is None:
314 return False
315 return self._device_state.sql_open
317 @property
318 def mode(self) -> RadioMode | None:
319 with self._lock:
320 if self._device_state is None:
321 return None
322 return self._device_state.mode
324 # -- internals -----------------------------------------------------------
326 def _seed_settings_locked(self, state: DeviceState) -> None:
327 """Seed radio settings from the firmware's actual tuned state at boot."""
328 self._freq_rx = state.freq_rx
329 self._freq_tx = state.freq_tx
330 self._bw = state.bw
331 self._squelch = state.squelch
332 self._ctcss_rx = state.ctcss_rx
333 self._ctcss_tx = state.ctcss_tx
335 def _log_device_status(self, state: DeviceState) -> None:
336 key = (
337 state.applied_sequence,
338 state.flags,
339 state.mode,
340 state.last_error,
341 round(state.freq_rx, 5),
342 round(state.freq_tx, 5),
343 state.bw,
344 state.squelch,
345 state.ctcss_rx,
346 state.ctcss_tx,
347 state.radio_module_status,
348 state.latest_rssi if state.mode == RadioMode.TX else None,
349 )
350 if key == self._last_status_key:
351 return
352 self._last_status_key = key
353 logger.info(
354 (
355 "radio status mode=%s sql=%s rx=%.5f tx=%.5f bw=%s "
356 "squelch=%d ctcss_rx=%d ctcss_tx=%d flags=0x%04x "
357 "applied_sequence=%d error=%d rssi=%d module=%s"
358 ),
359 state.mode.name,
360 "open" if state.sql_open else "closed",
361 state.freq_rx,
362 state.freq_tx,
363 dra818_to_bandwidth(state.bw),
364 state.squelch,
365 state.ctcss_rx,
366 state.ctcss_tx,
367 state.flags,
368 state.applied_sequence,
369 state.last_error,
370 state.latest_rssi,
371 state.radio_module_status,
372 )