STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_ota_flash.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Fake-board test for the C++ ota_service (diablo_server/services/ota).
4
5No hardware. Exercises the exact ethernet-flash wire format that a real ESP32-S3
6board depends on, against a loopback TCP "fake board" that speaks the espota
7handshake:
8
9 1. board accepts TCP on 127.0.0.1:3232 (OTA_PORT — a compile-time constant)
10 2. board reads a 4-byte BIG-ENDIAN firmware size
11 3. board reads exactly that many firmware bytes (ota_service sends in 4 KB chunks)
12 4. board replies "OK\\n" (or reboots without replying — also success)
13
14The test launches the real ota_service binary, connects to its command port
15(TCP, default 9997) and issues `OTA_FLASH:<ip>:<path>`, then asserts on both the
16bytes the fake board received and the OK/ERR reply the service returns.
17
18Covers: big-endian size header, multi-chunk streaming byte-fidelity, "OK"
19detection, silent-reboot-is-success, board-error -> ERR, and the command
20parser's error paths (missing file, connect refused, bad format, unknown cmd).
21
22Run: python3 test/test_ota_flash.py (from daq-server/)
23Binary is auto-discovered at build/bin/ota_service; override with OTA_SERVICE_BIN.
24"""
25
26from __future__ import annotations
27
28import os
29import random
30import socket
31import struct
32import subprocess
33import sys
34import threading
35import time
36
37OTA_BOARD_PORT = 3232 # fixed in ota_service (OTA_PORT); the fake board must bind this
38BOARD_IP = "127.0.0.1"
39CONNECT_WAIT_S = 5.0 # how long to wait for ota_service to come up
40REPLY_WAIT_S = 20.0 # how long to wait for an OK/ERR reply
41
42
43# ── binary discovery ──────────────────────────────────────────────────────────
44
45
46def find_ota_service() -> str:
47 override = os.environ.get("OTA_SERVICE_BIN")
48 if override:
49 if not os.path.isfile(override):
50 print(f"[test_ota_flash] OTA_SERVICE_BIN not found: {override}", flush=True)
51 sys.exit(2)
52 return override
53 here = os.path.dirname(os.path.abspath(__file__))
54 daq_root = os.path.dirname(here) # daq-server/
55 candidates = [
56 os.path.join(daq_root, "build", "bin", "ota_service"),
57 os.path.join(daq_root, "build-ci", "FSW", "ota_service"),
58 os.path.join(daq_root, "build", "FSW", "ota_service"),
59 ]
60 for c in candidates:
61 if os.path.isfile(c):
62 return c
63 print(
64 "[test_ota_flash] ota_service binary not found. Build it first "
65 "(bash scripts/build.sh) or set OTA_SERVICE_BIN. Looked in:\n "
66 + "\n ".join(candidates),
67 flush=True,
68 )
69 sys.exit(2)
70
71
72def pick_free_port() -> int:
73 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
74 s.bind(("127.0.0.1", 0))
75 port = s.getsockname()[1]
76 s.close()
77 return port
78
79
80# ── fake board ────────────────────────────────────────────────────────────────
81
82
83class FakeBoard(threading.Thread):
84 """One-shot espota listener on 127.0.0.1:3232.
85
86 behavior: 'ok' -> read all, reply "OK\\n"
87 'silent' -> read all, close without replying (ESP32 rebooted first)
88 'error' -> read all, reply "FAILED\\n"
89 Captures the declared size header and the received firmware bytes.
90 """
91
92 def __init__(self, behavior: str = "ok") -> None:
93 super().__init__(daemon=True)
94 self.behavior = behavior
95 self.declared_size: int | None = None
96 self.received: bytes = b""
97 self.error: str | None = None
98 self._ready = threading.Event()
99 self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
100 self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
101
102 def bind(self) -> bool:
103 try:
104 self._srv.bind((BOARD_IP, OTA_BOARD_PORT))
105 self._srv.listen(1)
106 self._srv.settimeout(CONNECT_WAIT_S + REPLY_WAIT_S)
107 return True
108 except OSError as e:
109 self.error = f"fake board bind {BOARD_IP}:{OTA_BOARD_PORT} failed: {e}"
110 return False
111
112 def run(self) -> None:
113 try:
114 self._ready.set()
115 conn, _ = self._srv.accept()
116 except OSError as e:
117 self.error = f"accept failed: {e}"
118 self._srv.close()
119 return
120 try:
121 conn.settimeout(REPLY_WAIT_S)
122 hdr = self._recv_exact(conn, 4)
123 if hdr is None:
124 self.error = "did not receive 4-byte size header"
125 return
126 self.declared_size = struct.unpack(">I", hdr)[0]
127 self.received = self._recv_exact(conn, self.declared_size) or b""
128 if self.behavior == "ok":
129 conn.sendall(b"OK\n")
130 elif self.behavior == "error":
131 conn.sendall(b"FAILED\n")
132 # 'silent': send nothing, just close (board rebooted before replying)
133 except OSError as e:
134 self.error = f"board io error: {e}"
135 finally:
136 try:
137 conn.close()
138 except OSError:
139 pass
140 self._srv.close()
141
142 @staticmethod
143 def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
144 buf = bytearray()
145 while len(buf) < n:
146 chunk = conn.recv(min(65536, n - len(buf)))
147 if not chunk:
148 return None
149 buf += chunk
150 return bytes(buf)
151
152
153# ── ota_service driver ────────────────────────────────────────────────────────
154
155
156def wait_until_listening(port: int, deadline: float) -> bool:
157 while time.time() < deadline:
158 try:
159 with socket.create_connection(("127.0.0.1", port), timeout=0.5):
160 return True
161 except OSError:
162 time.sleep(0.05)
163 return False
164
165
166def send_command(port: int, line: str) -> str:
167 """Send one command line to ota_service; return its trimmed reply."""
168 with socket.create_connection(("127.0.0.1", port), timeout=REPLY_WAIT_S) as s:
169 s.settimeout(REPLY_WAIT_S)
170 s.sendall(line.encode() + b"\n")
171 s.shutdown(socket.SHUT_WR)
172 buf = bytearray()
173 while True:
174 try:
175 chunk = s.recv(4096)
176 except socket.timeout:
177 break
178 if not chunk:
179 break
180 buf += chunk
181 if b"\n" in buf:
182 break
183 return buf.decode(errors="replace").strip()
184
185
186# ── test harness ──────────────────────────────────────────────────────────────
187
188
190 def __init__(self) -> None:
191 self.passed = 0
192 self.failed = 0
193
194 def check(self, name: str, ok: bool, detail: str = "") -> None:
195 mark = "PASS" if ok else "FAIL"
196 if ok:
197 self.passed += 1
198 else:
199 self.failed += 1
200 line = f" [{mark}] {name}"
201 if detail:
202 line += f" — {detail}"
203 print(line, flush=True)
204
205
206def make_firmware(tmpdir: str, name: str, size: int) -> tuple[str, bytes]:
207 # Deterministic pseudo-random content (fixed seed) so failures are reproducible.
208 data = bytes(random.Random(0xB0A2D + size).randbytes(size))
209 path = os.path.join(tmpdir, name)
210 with open(path, "wb") as f:
211 f.write(data)
212 return path, data
213
214
215def run_flash(port: int, ip: str, path: str, behavior: str, r: Results, label: str,
216 expect_ok: bool, verify_bytes: bytes | None) -> None:
217 """Start a fake board, issue OTA_FLASH, and assert reply + received bytes."""
218 board = FakeBoard(behavior=behavior)
219 if not board.bind():
220 r.check(label, False, board.error or "bind failed")
221 return
222 board.start()
223 board._ready.wait(2.0)
224 reply = send_command(port, f"OTA_FLASH:{ip}:{path}")
225 board.join(timeout=REPLY_WAIT_S)
226
227 got_ok = reply.startswith("OK")
228 if expect_ok:
229 r.check(f"{label}: service reply OK", got_ok, reply)
230 else:
231 r.check(f"{label}: service reply ERR", reply.startswith("ERR"), reply)
232
233 if verify_bytes is not None:
234 r.check(
235 f"{label}: size header (big-endian) matches file",
236 board.declared_size == len(verify_bytes),
237 f"declared={board.declared_size} expected={len(verify_bytes)}",
238 )
239 r.check(
240 f"{label}: received bytes identical to firmware",
241 board.received == verify_bytes,
242 f"got {len(board.received)}/{len(verify_bytes)} bytes"
243 + (f"; board error: {board.error}" if board.error else ""),
244 )
245
246
247def main() -> int:
248 import tempfile
249
250 bin_path = find_ota_service()
251 ota_port = pick_free_port()
252 print(f"[test_ota_flash] binary={bin_path}", flush=True)
253 print(f"[test_ota_flash] ota_service command port={ota_port}, board port={OTA_BOARD_PORT}",
254 flush=True)
255
256 # Make sure nothing is already squatting the fixed board port (would poison the
257 # connect-refused case and the byte-fidelity cases alike).
258 probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
259 probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
260 try:
261 probe.bind((BOARD_IP, OTA_BOARD_PORT))
262 except OSError as e:
263 print(
264 f"[test_ota_flash] port {OTA_BOARD_PORT} is in use ({e}); a real stack or "
265 "another test may be running. Aborting.",
266 flush=True,
267 )
268 return 2
269 finally:
270 probe.close()
271
272 proc = subprocess.Popen(
273 [bin_path, "--port", str(ota_port)],
274 stdout=subprocess.PIPE,
275 stderr=subprocess.STDOUT,
276 text=True,
277 )
278
279 r = Results()
280 try:
281 if not wait_until_listening(ota_port, time.time() + CONNECT_WAIT_S):
282 print("[test_ota_flash] ota_service never started listening", flush=True)
283 return 2
284
285 with tempfile.TemporaryDirectory() as tmp:
286 # 10 KB spans three 4 KB chunks -> exercises the send loop / offset math.
287 big_path, big_data = make_firmware(tmp, "firmware_big.bin", 10_000)
288 # Exact multiple of the 4 KB chunk size -> boundary case.
289 aligned_path, aligned_data = make_firmware(tmp, "firmware_aligned.bin", 8192)
290
291 # 1. Happy path: board acks OK. Validates BE size + chunking + exact bytes.
292 run_flash(ota_port, BOARD_IP, big_path, "ok", r,
293 "flash_ok (10KB, multi-chunk)", expect_ok=True, verify_bytes=big_data)
294
295 # 2. Chunk-aligned payload still transfers byte-for-byte.
296 run_flash(ota_port, BOARD_IP, aligned_path, "ok", r,
297 "flash_ok (8KB, chunk-aligned)", expect_ok=True, verify_bytes=aligned_data)
298
299 # 3. Board reboots before replying -> ota_service treats no-response as success.
300 run_flash(ota_port, BOARD_IP, big_path, "silent", r,
301 "flash_silent_reboot", expect_ok=True, verify_bytes=big_data)
302
303 # 4. Board replies with a non-OK string -> ERR, but bytes still arrived intact.
304 run_flash(ota_port, BOARD_IP, big_path, "error", r,
305 "flash_board_error", expect_ok=False, verify_bytes=big_data)
306
307 # 5. Missing firmware file -> ERR before any connect.
308 reply = send_command(ota_port, f"OTA_FLASH:{BOARD_IP}:{tmp}/does_not_exist.bin")
309 r.check("flash_missing_file: ERR cannot open",
310 reply.startswith("ERR") and "cannot open" in reply, reply)
311
312 # 6. Nothing listening on 3232 -> ERR connect refused.
313 reply = send_command(ota_port, f"OTA_FLASH:{BOARD_IP}:{big_path}")
314 r.check("flash_connect_refused: ERR connect",
315 reply.startswith("ERR") and "connect" in reply, reply)
316
317 # 7. Malformed OTA_FLASH (missing the :<path>) -> ERR bad format.
318 reply = send_command(ota_port, "OTA_FLASH:justanip")
319 r.check("bad_format: ERR bad OTA_FLASH",
320 reply.startswith("ERR") and "OTA_FLASH" in reply, reply)
321
322 # 8. Unknown command -> ERR unknown command.
323 reply = send_command(ota_port, "HELLO_WORLD")
324 r.check("unknown_command: ERR",
325 reply.startswith("ERR") and "unknown" in reply, reply)
326 finally:
327 proc.terminate()
328 try:
329 proc.wait(timeout=5)
330 except subprocess.TimeoutExpired:
331 proc.kill()
332
333 print(f"\n[test_ota_flash] {r.passed} passed, {r.failed} failed", flush=True)
334 return 0 if r.failed == 0 else 1
335
336
337if __name__ == "__main__":
338 sys.exit(main())
#define min(a, b)
Definition Arduino.h:42
None __init__(self, str behavior="ok")
bytes|None _recv_exact(socket.socket conn, int n)
None check(self, str name, bool ok, str detail="")
Definition run.py:1
None run_flash(int port, str ip, str path, str behavior, Results r, str label, bool expect_ok, bytes|None verify_bytes)
str send_command(int port, str line)
bool wait_until_listening(int port, float deadline)
tuple[str, bytes] make_firmware(str tmpdir, str name, int size)
TOML_NODISCARD_CTOR time() noexcept=default