STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ota_upload.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Push firmware to a STAR board over Ethernet.
4
5Board-agnostic: anything running ``libraries/STAR_EthernetOTA`` speaks this
6protocol, which is all of them (sense boards, actuator, stacklight, encoder,
7environmental tracker).
8
9 # upload a binary you already built
10 python firmware/tools/ota_upload.py --ip 192.168.2.41 \
11 --bin firmware/Hotfire_Code/LC_Hotfire/.pio/build/adafruit_feather_esp32s3/firmware.bin
12
13 # build the project first, then upload it
14 python firmware/tools/ota_upload.py --ip 192.168.2.41 \
15 --project firmware/Hotfire_Code/LC_Hotfire
16
17 # by MAC or by config name: tries the board's DHCP address, then its
18 # recovery address 169.254.x.y (a board whose DHCP failed sits there)
19 python firmware/tools/ota_upload.py --mac 4a:27:e2:3a:ad:71 --bin rtd.bin
20 python firmware/tools/ota_upload.py --board rtd_board --bin rtd.bin
21
22 # bake a marker into the build so you can SEE the update land on serial:
23 # the board starts printing "[OTA-MSG] third try" instead of whatever it
24 # printed before
25 python firmware/tools/ota_upload.py --ip 192.168.2.41 \
26 --project firmware/Hotfire_Code/LC_Hotfire --message "third try"
27
28Wire protocol (see libraries/STAR_EthernetOTA/src/STAR_EthernetOTA.h):
29
30 client -> board : [4-byte big-endian image size][raw firmware bytes]
31 board -> client: "OK\\r\\n", then reboot
32
33Two different SHA-256 values are involved, and confusing them makes a good
34update look like a failed one:
35
36* ``sha256`` of the .bin file — what you get from ``shasum -a 256``.
37* the **appended image hash**, the last 32 bytes of the .bin, which is what
38 ``esp_partition_get_sha256()`` returns and therefore what the board prints
39 at boot and reports in BOARD_HEARTBEAT (see
40 ``Hotfire_Code/common/FIRMWARE_HASH_VERIFICATION.md``).
41
42They are never equal. This tool prints both and labels which one to compare
43against the board.
44
45Pure standard library. ``python -m ota_upload --self-test`` runs an offline
46round trip against a fake board.
47"""
48
49from __future__ import annotations
50
51import argparse
52import hashlib
53import os
54import shutil
55import socket
56import struct
57import subprocess
58import sys
59import time
60from pathlib import Path
61from typing import Callable, Optional
62
63DEFAULT_PORT = 3232
64CHUNK_SIZE = 4096
65CONNECT_TIMEOUT = 5.0
66TRANSFER_TIMEOUT = 60.0
67# Refuse anything the board would reject anyway (STAR_OTA_MAX_IMAGE_BYTES).
68MAX_IMAGE_BYTES = 0x200000
69
70ProgressFn = Callable[[int, int], None]
71
72
73class OtaError(RuntimeError):
74 """Upload failed. The message is meant to be shown to a human."""
75
76
78 """Nothing answered at that address: safe to try the next one."""
79
80
81# ---------------------------------------------------------------------------
82# Finding a board: its DHCP address, or its MAC-derived recovery address
83# ---------------------------------------------------------------------------
84REPO_ROOT = Path(__file__).resolve().parents[2]
85DEFAULT_CONFIG = REPO_ROOT / "daq-server" / "config" / "config.toml"
86
87
88def parse_mac(mac: str) -> bytes:
89 parts = mac.strip().replace("-", ":").split(":")
90 if len(parts) != 6:
91 raise OtaError(f"{mac!r} is not a MAC address (aa:bb:cc:dd:ee:ff)")
92 try:
93 out = bytes(int(p, 16) for p in parts)
94 except ValueError as exc:
95 raise OtaError(f"{mac!r} is not a MAC address (aa:bb:cc:dd:ee:ff)") from exc
96 return out
97
98
99def recovery_address(mac: str) -> str:
100 """Where a board sits when no DHCP server answered it:
101 169.254.<1 + mac[4] % 254>.<mac[5]>. Must match BoardNet::recoveryAddress
102 (firmware/Hotfire_Code/common/board_net.h) and daq-server's config check."""
103 m = parse_mac(mac)
104 return f"169.254.{1 + m[4] % 254}.{m[5]}"
105
106
107def board_from_config(name: str, config: Path) -> tuple:
108 """(mac, ip) of [boards.<name>] in a daq-server config.toml."""
109 import tomllib
110
111 try:
112 cfg = tomllib.loads(config.read_text())
113 except (OSError, tomllib.TOMLDecodeError) as exc:
114 raise OtaError(f"cannot read {config}: {exc}") from exc
115 boards = cfg.get("boards", {})
116 b = boards.get(name)
117 if not isinstance(b, dict):
118 raise OtaError(
119 f"no [boards.{name}] in {config}; boards there: {', '.join(sorted(boards))}"
120 )
121 mac = str(b.get("mac") or "")
122 if not mac:
123 raise OtaError(
124 f"[boards.{name}] has no mac = line, so its recovery address is unknown"
125 )
126 return mac, (str(b["ip"]) if b.get("ip") else None)
127
128
129def candidate_addresses(ip: Optional[str], mac: Optional[str]) -> list:
130 """Addresses to try, in order: the DHCP one, then recovery."""
131 out = []
132 if ip:
133 out.append(ip)
134 if mac:
135 rec = recovery_address(mac)
136 if rec not in out:
137 out.append(rec)
138 return out
139
140
141def firmware_sha256(data: bytes) -> str:
142 """SHA-256 of the .bin file itself. NOT what the board reports."""
143 return hashlib.sha256(data).hexdigest()
144
145
146def board_reported_sha256(data: bytes) -> Optional[str]:
147 """The digest the board prints for this image, or None if it has none.
148
149 The board uses esp_partition_get_sha256(), which returns the 32-byte
150 SHA-256 that esptool appends to the image -- computed over the image
151 *excluding* those 32 bytes. Hashing the whole .bin gives a different
152 number that will never match, which makes a perfectly good update look
153 like a failed one. The appended hash is present when byte 23 of the
154 ESP32 image header (magic 0xE9) has the hash_appended flag set.
155 """
156 if len(data) < 33 or data[0] != 0xE9 or not data[23]:
157 return None
158 return data[-32:].hex()
159
160
162 data: bytes,
163 ip: str,
164 port: int = DEFAULT_PORT,
165 on_progress: Optional[ProgressFn] = None,
166 connect_timeout: float = CONNECT_TIMEOUT,
167 transfer_timeout: float = TRANSFER_TIMEOUT,
168) -> str:
169 """Send `data` to the board and wait for its acknowledgement.
170
171 Returns the board's reply text. Raises OtaError with something actionable
172 on any failure.
173 """
174 if not data:
175 raise OtaError("firmware image is empty")
176 if len(data) > MAX_IMAGE_BYTES:
177 raise OtaError(
178 f"image is {len(data)} bytes; the board rejects anything over "
179 f"{MAX_IMAGE_BYTES}"
180 )
181
182 try:
183 sock = socket.create_connection((ip, port), timeout=connect_timeout)
184 except OSError as exc:
185 raise ConnectError(
186 f"could not connect to {ip}:{port} — {exc}. Is the board powered, "
187 f"on this subnet, and past its boot delay?"
188 ) from exc
189
190 try:
191 sock.settimeout(transfer_timeout)
192 sock.sendall(struct.pack(">I", len(data)))
193
194 sent = 0
195 while sent < len(data):
196 chunk = data[sent : sent + CHUNK_SIZE]
197 try:
198 sock.sendall(chunk)
199 except OSError as exc:
200 raise OtaError(
201 f"transfer failed after {sent} of {len(data)} bytes — " f"{exc}"
202 ) from exc
203 sent += len(chunk)
204 if on_progress:
205 on_progress(sent, len(data))
206
207 # The board replies "OK" only after Update.end() validates the image.
208 try:
209 reply = sock.recv(64).decode("utf-8", "replace").strip()
210 except OSError as exc:
211 raise OtaError(
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'."
215 ) from exc
216
217 if reply != "OK":
218 raise OtaError(
219 f"board replied {reply!r} instead of 'OK' — check its serial "
220 f"log for '[OTA] ERROR'"
221 )
222 return reply
223 finally:
224 sock.close()
225
226
228 data: bytes, hosts: list, port: int = DEFAULT_PORT, **kwargs
229) -> str:
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."""
232 errors = []
233 for host in hosts:
234 try:
235 upload(data, host, port, **kwargs)
236 return host
237 except ConnectError as exc:
238 errors.append(str(exc))
239 print(f" nothing at {host}:{port}, trying the next address")
240 hint = ""
241 if any(h.startswith("169.254.") for h in hosts):
242 hint = (
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)."
246 )
247 raise OtaError("no board answered:\n " + "\n ".join(errors) + hint)
248
249
250# ---------------------------------------------------------------------------
251# Building (optional — only when --project is given)
252# ---------------------------------------------------------------------------
253def find_pio() -> str:
254 """Locate the PlatformIO CLI, including its default install locations."""
255 found = shutil.which("pio") or shutil.which("platformio")
256 if found:
257 return found
258 candidates = [
259 Path.home() / ".platformio" / "penv" / "bin" / "pio",
260 Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe",
261 ]
262 for c in candidates:
263 if c.exists():
264 return str(c)
265 raise OtaError(
266 "PlatformIO CLI not found. Install it (pip install platformio) or "
267 "build the project yourself and pass --bin."
268 )
269
270
272 project: Path, env: Optional[str] = None, message: Optional[str] = None
273) -> Path:
274 """Build `project` and return the path to firmware.bin.
275
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.
279 """
280 project = project.resolve()
281 if not (project / "platformio.ini").exists():
282 raise OtaError(f"{project} has no platformio.ini")
283
284 cmd = [find_pio(), "run", "-d", str(project)]
285 if env:
286 cmd += ["-e", env]
287
288 build_env = os.environ.copy()
289 if message is not None:
290 if '"' in message or "'" in message:
291 raise OtaError(
292 "--message cannot contain quote characters; they "
293 "do not survive the trip to the compiler."
294 )
295 # Single-quote the whole value. PlatformIO splits PLATFORMIO_BUILD_FLAGS
296 # with shlex, so a bare \"two words\" is torn in half at the space and
297 # the string literal never terminates. This is the form the library's
298 # own header documents: -DSTAR_OTA_TEST_MESSAGE='"hello"'
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}")
303
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}")
309
310 bins = sorted(
311 (project / ".pio" / "build").glob("*/firmware.bin"),
312 key=lambda p: p.stat().st_mtime,
313 reverse=True,
314 )
315 if not bins:
316 raise OtaError(f"build succeeded but no firmware.bin under {project}")
317 return bins[0]
318
319
320# ---------------------------------------------------------------------------
321def _progress_printer() -> ProgressFn:
322 state = {"bucket": -1, "start": time.time()}
323
324 def report(sent: int, total: int) -> None:
325 pct = int(sent * 100 / total)
326 if pct // 5 == state["bucket"]:
327 return
328 state["bucket"] = pct // 5
329 elapsed = time.time() - state["start"]
330 rate = (sent / elapsed / 1024) if elapsed > 0 else 0
331 bar = "#" * (pct // 5) + "." * (20 - pct // 5)
332 print(
333 f"\r [{bar}] {pct:3d}% {sent}/{total} bytes {rate:.0f} KB/s",
334 end="",
335 flush=True,
336 )
337
338 return report
339
340
341def main(argv=None) -> int:
342 p = argparse.ArgumentParser(
343 description="Push firmware to a STAR board over Ethernet."
344 )
345 p.add_argument("--ip", help="board IP address")
346 p.add_argument(
347 "--mac",
348 help="board MAC; also tries its recovery address 169.254.x.y "
349 "(where a board whose DHCP failed sits)",
350 )
351 p.add_argument(
352 "--board",
353 help="[boards.<name>] in --config: its ip, then its recovery address",
354 )
355 p.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
356 p.add_argument("--port", type=int, default=DEFAULT_PORT)
357 p.add_argument(
358 "--bin", type=Path, help="firmware.bin to upload (skip to build with --project)"
359 )
360 p.add_argument(
361 "--project", type=Path, help="PlatformIO project to build, then upload"
362 )
363 p.add_argument("--env", help="PlatformIO env within --project")
364 p.add_argument(
365 "--message",
366 help="bake this into the build as STAR_OTA_TEST_MESSAGE, so "
367 "the board visibly prints something new afterwards",
368 )
369 p.add_argument("--timeout", type=float, default=TRANSFER_TIMEOUT)
370 p.add_argument(
371 "--self-test",
372 action="store_true",
373 help="run an offline round trip against a fake board",
374 )
375 args = p.parse_args(argv)
376
377 if args.self_test:
378 _self_test()
379 return 0
380
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)")
385
386 try:
387 ip, mac = args.ip, args.mac
388 if args.board:
389 cfg_mac, cfg_ip = board_from_config(args.board, args.config)
390 mac = mac or cfg_mac
391 ip = ip or cfg_ip
392 hosts = candidate_addresses(ip, mac)
393
394 bin_path = args.bin
395 if args.project:
396 bin_path = build(args.project, args.env, args.message)
397 elif args.message:
398 p.error("--message needs --project: the message is compiled in")
399
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()
404
405 digest = firmware_sha256(data)
406 board_digest = board_reported_sha256(data)
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})")
411
412 host = upload_to_first(
413 data,
414 hosts,
415 args.port,
416 on_progress=_progress_printer(),
417 transfer_timeout=args.timeout,
418 )
419 print(f"\n {host} acknowledged — rebooting into the new image.")
420 if board_digest:
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()}")
425 else:
426 print(" This image has no appended SHA-256, so the board's own")
427 print(' "Firmware hash:" line cannot be predicted from here.')
428 if args.message:
429 print(f' ...and with "[OTA-MSG] {args.message}" on its serial log.')
430 return 0
431 except OtaError as exc:
432 print(f"\nOTA failed: {exc}", file=sys.stderr)
433 return 1
434
435
436# ---------------------------------------------------------------------------
437def _self_test() -> None:
438 """Round-trip against a fake board implementing the same wire protocol."""
439 import threading
440
441 received = {}
442
443 def fake_board(
444 sock: socket.socket, reply: bytes = b"OK\r\n", truncate: bool = False
445 ) -> None:
446 conn, _ = sock.accept()
447 with conn:
448 header = b""
449 while len(header) < 4:
450 header += conn.recv(4 - len(header))
451 size = struct.unpack(">I", header)[0]
452 body = b""
453 want = size // 2 if truncate else size
454 while len(body) < want:
455 part = conn.recv(min(CHUNK_SIZE, want - len(body)))
456 if not part:
457 break
458 body += part
459 received["size"] = size
460 received["body"] = body
461 conn.sendall(reply)
462
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))
467 srv.listen(1)
468 t = threading.Thread(target=fake_board, args=(srv,), kwargs=kwargs, daemon=True)
469 t.start()
470 return srv, t
471
472 # -- a good image arrives byte-for-byte ---------------------------------
473 image = bytes(range(256)) * 40 # 10240 bytes, spans several chunks
474 srv, t = serve()
475 port = srv.getsockname()[1]
476 seen = []
477 assert (
478 upload(image, "127.0.0.1", port, on_progress=lambda s, tot: seen.append(s))
479 == "OK"
480 )
481 t.join(timeout=5)
482 srv.close()
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
487
488 # -- the hash matches what the board would compute ----------------------
489 assert firmware_sha256(image) == hashlib.sha256(image).hexdigest()
490 assert len(firmware_sha256(image)) == 64
491
492 # -- a board that rejects the image is reported, not silently ignored ---
493 srv, t = serve(reply=b"ERR\r\n")
494 port = srv.getsockname()[1]
495 try:
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
500 t.join(timeout=5)
501 srv.close()
502
503 # -- nothing listening: the error names the address ---------------------
504 dead = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
505 dead.bind(("127.0.0.1", 0))
506 dead_port = dead.getsockname()[1]
507 dead.close()
508 try:
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
513
514 # -- guard rails --------------------------------------------------------
515 for bad, why in ((b"", "empty"), (b"x" * (MAX_IMAGE_BYTES + 1), "over")):
516 try:
517 upload(bad, "127.0.0.1", 1)
518 raise AssertionError(f"{why} image must raise")
519 except OtaError:
520 pass
521
522 # -- recovery address: the vectors board_net.h's tests pin too ----------
523 assert recovery_address("4a:27:e2:3a:ad:71") == "169.254.174.113"
524 assert recovery_address("00:00:00:00:00:00") == "169.254.1.0"
525 assert recovery_address("00:00:00:00:fe:ff") == "169.254.1.255"
526 assert recovery_address("00:00:00:00:ff:01") == "169.254.2.1"
527 assert candidate_addresses("192.168.2.31", "4a:27:e2:3a:ad:71") == [
528 "192.168.2.31",
529 "169.254.174.113",
530 ]
531 for bad in ("4a:27:e2:3a:ad", "zz:27:e2:3a:ad:71"):
532 try:
534 raise AssertionError(f"{bad} must be refused")
535 except OtaError:
536 pass
537
538 # -- nothing at the first address: the image goes to the next ----------
539 received.clear()
540 srv, t = serve()
541 port = srv.getsockname()[1]
542 # 127.0.0.2 has nothing on `port`; 127.0.0.1 is the fake board.
543 assert (
544 upload_to_first(image, ["127.0.0.2", "127.0.0.1"], port, connect_timeout=1.0)
545 == "127.0.0.1"
546 )
547 t.join(timeout=5)
548 srv.close()
549 assert received["body"] == image
550
551 # -- a board that answered and failed is NOT retried elsewhere ----------
552 srv, t = serve(reply=b"ERR\r\n")
553 port = srv.getsockname()[1]
554 try:
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")
557 except ConnectError:
558 raise AssertionError("a rejection is not a connect failure")
559 except OtaError as exc:
560 # The rejection itself, not "no board answered" after trying 127.0.0.2.
561 assert str(exc).startswith("board replied"), exc
562 t.join(timeout=5)
563 srv.close()
564
565 print("ota_upload self-test: OK")
566
567
568if __name__ == "__main__":
569 raise SystemExit(main())
#define min(a, b)
Definition Arduino.h:42
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
Path build(Path project, Optional[str] env=None, Optional[str] message=None)
list candidate_addresses(Optional[str] ip, Optional[str] mac)
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)
tuple board_from_config(str name, Path config)
ProgressFn _progress_printer()
str recovery_address(str mac)
Definition ota_upload.py:99
bytes parse_mac(str mac)
Definition ota_upload.py:88
Optional[str] board_reported_sha256(bytes data)
str find_pio()
str firmware_sha256(bytes data)
None _self_test()
str upload_to_first(bytes data, list hosts, int port=DEFAULT_PORT, **kwargs)
TOML_NODISCARD_CTOR time() noexcept=default
int main()
static void decode(const disk_t *d)