164 port: int = DEFAULT_PORT,
165 on_progress: Optional[ProgressFn] =
None,
166 connect_timeout: float = CONNECT_TIMEOUT,
167 transfer_timeout: float = TRANSFER_TIMEOUT,
169 """Send `data` to the board and wait for its acknowledgement.
171 Returns the board's reply text. Raises OtaError with something actionable
175 raise OtaError(
"firmware image is empty")
176 if len(data) > MAX_IMAGE_BYTES:
178 f
"image is {len(data)} bytes; the board rejects anything over "
183 sock = socket.create_connection((ip, port), timeout=connect_timeout)
184 except OSError
as exc:
186 f
"could not connect to {ip}:{port} — {exc}. Is the board powered, "
187 f
"on this subnet, and past its boot delay?"
191 sock.settimeout(transfer_timeout)
192 sock.sendall(struct.pack(
">I", len(data)))
195 while sent < len(data):
196 chunk = data[sent : sent + CHUNK_SIZE]
199 except OSError
as exc:
201 f
"transfer failed after {sent} of {len(data)} bytes — " f
"{exc}"
205 on_progress(sent, len(data))
209 reply = sock.recv(64).
decode(
"utf-8",
"replace").strip()
210 except OSError
as exc:
212 "image sent, but the board never acknowledged it "
213 f
"({exc}). It may have rejected the image — check its serial "
214 f
"log for '[OTA] ERROR'."
219 f
"board replied {reply!r} instead of 'OK' — check its serial "
220 f
"log for '[OTA] ERROR'"
228 data: bytes, hosts: list, port: int = DEFAULT_PORT, **kwargs
230 """Upload to the first of `hosts` that accepts a connection. Moves on only
231 when nothing answered; a board that answered and then failed is an error."""
235 upload(data, host, port, **kwargs)
237 except ConnectError
as exc:
238 errors.append(str(exc))
239 print(f
" nothing at {host}:{port}, trying the next address")
241 if any(h.startswith(
"169.254.")
for h
in hosts):
243 "\n To reach a recovery address this machine needs one on the board "
244 "LAN too: sudo ip addr add 169.254.0.20/16 dev <board NIC> "
245 "(star-dhcp.service adds it on the DAQ server)."
247 raise OtaError(
"no board answered:\n " +
"\n ".join(errors) + hint)
272 project: Path, env: Optional[str] =
None, message: Optional[str] =
None
274 """Build `project` and return the path to firmware.bin.
276 `message` is injected as STAR_OTA_TEST_MESSAGE through
277 PLATFORMIO_BUILD_FLAGS, so nothing in the repo has to be edited to make
278 one build visibly different from the last.
280 project = project.resolve()
281 if not (project /
"platformio.ini").exists():
282 raise OtaError(f
"{project} has no platformio.ini")
284 cmd = [
find_pio(),
"run",
"-d", str(project)]
288 build_env = os.environ.copy()
289 if message
is not None:
290 if '"' in message
or "'" in message:
292 "--message cannot contain quote characters; they "
293 "do not survive the trip to the compiler."
299 flag = f
"-DSTAR_OTA_TEST_MESSAGE='\"{message}\"'"
300 existing = build_env.get(
"PLATFORMIO_BUILD_FLAGS",
"")
301 build_env[
"PLATFORMIO_BUILD_FLAGS"] = (existing +
" " + flag).strip()
302 print(f
" baking in test message: {message!r}")
304 print(f
" building {project.name}…")
305 result = subprocess.run(cmd, env=build_env, text=
True, capture_output=
True)
306 if result.returncode != 0:
307 tail =
"\n".join((result.stdout + result.stderr).splitlines()[-25:])
308 raise OtaError(f
"build failed:\n{tail}")
311 (project /
".pio" /
"build").glob(
"*/firmware.bin"),
312 key=
lambda p: p.stat().st_mtime,
316 raise OtaError(f
"build succeeded but no firmware.bin under {project}")
342 p = argparse.ArgumentParser(
343 description=
"Push firmware to a STAR board over Ethernet."
345 p.add_argument(
"--ip", help=
"board IP address")
348 help=
"board MAC; also tries its recovery address 169.254.x.y "
349 "(where a board whose DHCP failed sits)",
353 help=
"[boards.<name>] in --config: its ip, then its recovery address",
355 p.add_argument(
"--config", type=Path, default=DEFAULT_CONFIG)
356 p.add_argument(
"--port", type=int, default=DEFAULT_PORT)
358 "--bin", type=Path, help=
"firmware.bin to upload (skip to build with --project)"
361 "--project", type=Path, help=
"PlatformIO project to build, then upload"
363 p.add_argument(
"--env", help=
"PlatformIO env within --project")
366 help=
"bake this into the build as STAR_OTA_TEST_MESSAGE, so "
367 "the board visibly prints something new afterwards",
369 p.add_argument(
"--timeout", type=float, default=TRANSFER_TIMEOUT)
373 help=
"run an offline round trip against a fake board",
375 args = p.parse_args(argv)
381 if not (args.ip
or args.mac
or args.board):
382 p.error(
"pass --ip, --mac or --board (or use --self-test)")
383 if not args.bin
and not args.project:
384 p.error(
"pass --bin (an existing image) or --project (build it first)")
387 ip, mac = args.ip, args.mac
396 bin_path =
build(args.project, args.env, args.message)
398 p.error(
"--message needs --project: the message is compiled in")
400 bin_path = Path(bin_path)
401 if not bin_path.exists():
402 raise OtaError(f
"{bin_path} does not exist")
403 data = bin_path.read_bytes()
407 print(f
" image: {bin_path}")
408 print(f
" size: {len(data)} bytes")
409 print(f
" sha256: {digest} (of the .bin file)")
410 print(f
" target: {' then '.join(hosts)} (port {args.port})")
417 transfer_timeout=args.timeout,
419 print(f
"\n {host} acknowledged — rebooting into the new image.")
421 print(
' The board will print this at boot ("Firmware hash:") and')
422 print(
" report it in BOARD_HEARTBEAT -- compare against this, not")
423 print(
" the file sha256 above:")
424 print(f
" {board_digest.upper()}")
426 print(
" This image has no appended SHA-256, so the board's own")
427 print(
' "Firmware hash:" line cannot be predicted from here.')
429 print(f
' ...and with "[OTA-MSG] {args.message}" on its serial log.')
431 except OtaError
as exc:
432 print(f
"\nOTA failed: {exc}", file=sys.stderr)
438 """Round-trip against a fake board implementing the same wire protocol."""
444 sock: socket.socket, reply: bytes = b
"OK\r\n", truncate: bool =
False
446 conn, _ = sock.accept()
449 while len(header) < 4:
450 header += conn.recv(4 - len(header))
451 size = struct.unpack(
">I", header)[0]
453 want = size // 2
if truncate
else size
454 while len(body) < want:
455 part = conn.recv(
min(CHUNK_SIZE, want - len(body)))
459 received[
"size"] = size
460 received[
"body"] = body
463 def serve(**kwargs) -> tuple:
464 srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
465 srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
466 srv.bind((
"127.0.0.1", 0))
468 t = threading.Thread(target=fake_board, args=(srv,), kwargs=kwargs, daemon=
True)
473 image = bytes(range(256)) * 40
475 port = srv.getsockname()[1]
478 upload(image,
"127.0.0.1", port, on_progress=
lambda s, tot: seen.append(s))
483 assert received[
"size"] == len(image), received[
"size"]
484 assert received[
"body"] == image,
"image corrupted in transit"
485 assert seen
and seen[-1] == len(image), seen[-1:]
486 assert len(seen) == (len(image) + CHUNK_SIZE - 1) // CHUNK_SIZE
493 srv, t = serve(reply=b
"ERR\r\n")
494 port = srv.getsockname()[1]
496 upload(image,
"127.0.0.1", port)
497 raise AssertionError(
"a non-OK reply must raise")
498 except OtaError
as exc:
499 assert "instead of 'OK'" in str(exc), exc
504 dead = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
505 dead.bind((
"127.0.0.1", 0))
506 dead_port = dead.getsockname()[1]
509 upload(image,
"127.0.0.1", dead_port, connect_timeout=1.0)
510 raise AssertionError(
"connecting to nothing must raise")
511 except OtaError
as exc:
512 assert str(dead_port)
in str(exc), exc
515 for bad, why
in ((b
"",
"empty"), (b
"x" * (MAX_IMAGE_BYTES + 1),
"over")):
517 upload(bad,
"127.0.0.1", 1)
518 raise AssertionError(f
"{why} image must raise")
531 for bad
in (
"4a:27:e2:3a:ad",
"zz:27:e2:3a:ad:71"):
534 raise AssertionError(f
"{bad} must be refused")
541 port = srv.getsockname()[1]
544 upload_to_first(image, [
"127.0.0.2",
"127.0.0.1"], port, connect_timeout=1.0)
549 assert received[
"body"] == image
552 srv, t = serve(reply=b
"ERR\r\n")
553 port = srv.getsockname()[1]
555 upload_to_first(image, [
"127.0.0.1",
"127.0.0.2"], port, connect_timeout=1.0)
556 raise AssertionError(
"a rejecting board must fail, not fall through")
558 raise AssertionError(
"a rejection is not a connect failure")
559 except OtaError
as exc:
561 assert str(exc).startswith(
"board replied"), exc
565 print(
"ota_upload self-test: OK")
str upload(bytes data, str ip, int port=DEFAULT_PORT, Optional[ProgressFn] on_progress=None, float connect_timeout=CONNECT_TIMEOUT, float transfer_timeout=TRANSFER_TIMEOUT)