Merge PR #2152 (aepod) into release integration

# Conflicts:
#	firmware/esp32-csi-node/provision.py
This commit is contained in:
ruv committed 2026-10-05 11:43:47 -04:00
commit e0c4207bd5
2 files changed
+400 -3

No files matched your search

+154 -3
View File
@@ -28,6 +28,11 @@ ADDITIVE-BY-DEFAULT (issue #391, #574 phase 1):
3. Generate + flash NVS from the merged state.
4. Write the merged state back to the state file.
State is keyed by the board's MAC, read with esptool, so a board plugged
into a port another board used doesn't inherit its node_id (#1755).
Port-keyed files from earlier versions move to the board's record the
first time that board is provisioned.
Net effect: partial reconfigure works the way users expect. Pass `--reset`
to wipe both the state file AND the device NVS for first-time provisioning
of a recycled board.
@@ -46,6 +51,7 @@ import io
import json
import os
import stat
import re
import struct
import subprocess
import sys
@@ -291,6 +297,114 @@ def save_state(port: str, state_dir: str, state: dict) -> str:
return path
# ---------------------------------------------------------------------------
# Board identity (#1755)
# ---------------------------------------------------------------------------
#
# Serial port names get reused when boards are swapped, so a port-keyed state
# file hands one board's node_id to the next. State is keyed by the chip's
# base MAC instead. Each record also stores the MAC and the port it was last
# written from, so --state can find it without opening the port.
STATE_MAC_KEY = "_chip_mac"
STATE_PORT_KEY = "_port"
_ESPTOOL_MAC_RE = re.compile(
r"^\s*(BASE MAC|MAC):\s*((?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})\s*$", re.MULTILINE
)
def normalize_mac(value: str):
"""Return `value` as lowercase aa:bb:cc:dd:ee:ff, or None if it isn't a MAC."""
parts = re.split(r"[:-]", value.strip())
if len(parts) != 6 or not all(re.fullmatch(r"[0-9a-fA-F]{2}", p) for p in parts):
return None
return ":".join(p.lower() for p in parts)
def parse_esptool_mac(output: str):
"""Return the base MAC from `esptool read_mac` output, or None.
Most chips print one 6-byte "MAC:" line. EUI-64 chips (C5, C6, H2) print an
8-byte "MAC:" line, then "BASE MAC:" with the 6-byte address.
"""
found = {label: mac.lower() for label, mac in _ESPTOOL_MAC_RE.findall(output)}
return found.get("BASE MAC") or found.get("MAC")
def read_chip_mac(port: str, baud: int, chip: str):
"""Read the connected board's base MAC with esptool. None if it can't."""
cmd = [
sys.executable, "-m", "esptool",
"--chip", chip,
"--port", port,
"--baud", str(baud),
"read_mac",
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
return parse_esptool_mac(result.stdout)
def mac_state_key(mac: str) -> str:
"""State-file key for a board, e.g. mac-aabbccddeeff."""
return "mac-" + mac.replace(":", "")
def boards_last_on_port(port: str, state_dir: str) -> list:
"""MACs of boards whose state was last written from `port`, newest first."""
if not os.path.isdir(state_dir):
return []
found = []
for name in os.listdir(state_dir):
if not (name.startswith("mac-") and name.endswith(".json")):
continue
data = load_state(name[:-len(".json")], state_dir)
mac = data.get(STATE_MAC_KEY)
if data.get(STATE_PORT_KEY) == port and mac:
found.append((os.path.getmtime(os.path.join(state_dir, name)), mac))
return [mac for _, mac in sorted(found, reverse=True)]
def load_board_state(port: str, mac, state_dir: str):
"""Return (prior state, legacy path) for the board `mac` on `port`.
Without a MAC this is the old port-keyed lookup. With one, the board's own
record wins. If it has none but a port-keyed file from an earlier version
exists, that file is returned for migration along with its path.
"""
if mac is None:
return load_state(port, state_dir), None
prior = load_state(mac_state_key(mac), state_dir)
if prior:
return prior, None
legacy = _state_path_for(port, state_dir)
if os.path.isfile(legacy):
return load_state(port, state_dir), legacy
return {}, None
def save_board_state(port: str, mac, state_dir: str, state: dict, legacy=None) -> str:
"""Persist `state` under the board's MAC (or the port if the MAC is unknown).
A migrated port-keyed file is removed afterwards, so no other board on that
port can pick it up.
"""
if mac is None:
return save_state(port, state_dir, state)
state = dict(state)
state[STATE_MAC_KEY] = mac
state[STATE_PORT_KEY] = port
path = save_state(mac_state_key(mac), state_dir, state)
if legacy and os.path.isfile(legacy):
os.unlink(legacy)
return path
def merge_state_into_args(args, prior: dict) -> dict:
"""Overlay `args` onto `prior` for every MERGEABLE_ATTRS attribute.
@@ -514,6 +628,10 @@ def main():
"previously-staged keys should NOT be re-applied.")
parser.add_argument("--state-dir", default=_default_state_dir(),
help="Override the per-user state directory (default: per-OS user config dir).")
parser.add_argument("--mac", type=str,
help="Board MAC (AA:BB:CC:DD:EE:FF) whose state to use. Normally read "
"from the chip with esptool; --state and --dry-run never open the "
"port, so pass it there to pick a board.")
parser.add_argument("--state", action="store_true",
help="Print the merged state that WOULD be flashed for this port and exit. "
"Useful for debugging which keys are about to land on the device. "
@@ -537,6 +655,33 @@ def main():
print("WARNING: --password is visible in ps and shell history; "
"use --password-file instead.", file=sys.stderr)
cli_ssid, cli_password = args.ssid, args.password
# --- Board identity (#1755) ---
# State is keyed by the chip's MAC so a board swapped onto a reused port
# doesn't inherit the previous board's node_id.
if args.mac is not None:
chip_mac = normalize_mac(args.mac)
if chip_mac is None:
parser.error(f"--mac must be in AA:BB:CC:DD:EE:FF format, got '{args.mac}'")
elif args.state:
# Inspection only: don't open the port; show the board last
# provisioned from it.
recent = boards_last_on_port(args.port, args.state_dir)
chip_mac = recent[0] if recent else None
if chip_mac:
print(f"Showing board {chip_mac}, last provisioned on {args.port}. "
f"Pass --mac to pick another.", file=sys.stderr)
elif args.dry_run:
# No board involved; keep the port-keyed state as before.
chip_mac = None
else:
chip_mac = read_chip_mac(args.port, args.baud, args.chip)
if chip_mac:
print(f"Board MAC: {chip_mac}")
else:
print(f"WARNING: could not read the board's MAC with esptool, so state "
f"stays keyed by port {args.port}. Pass --mac to key it by board.",
file=sys.stderr)
legacy = None
# --- Per-port state load + merge (additive-by-default, #391 / #574) ---
if args.reset:
@@ -548,7 +693,12 @@ def main():
print(f"--reset: ignoring state file {path}", file=sys.stderr)
prior = {}
else:
prior = load_state(args.port, args.state_dir)
prior, legacy = load_board_state(args.port, chip_mac, args.state_dir)
if legacy:
print(f"Using port-keyed state {legacy} for board {chip_mac}; it moves to "
f"the board's own record when state is next saved. If this isn't the "
f"board last provisioned on {args.port}, rerun with --reset.",
file=sys.stderr)
merged = merge_state_into_args(args, prior)
# A new --ssid with no password given: ask for it on a terminal instead of
@@ -585,11 +735,12 @@ def main():
] if val is None or val == ""
]
if wifi_trio_missing and not args.force_partial:
state_key = mac_state_key(chip_mac) if chip_mac else args.port
parser.error(
f"Missing required WiFi credentials after merging prior state: "
f"{', '.join(wifi_trio_missing)}.\n"
f"\n"
f" No per-port state file at {_state_path_for(args.port, args.state_dir)}\n"
f" No saved state at {_state_path_for(state_key, args.state_dir)}\n"
f" and the CLI didn't include them. Either pass --ssid + --password + --target-ip\n"
f" on this run, or add --force-partial to flash without WiFi.\n"
)
@@ -691,7 +842,7 @@ def main():
# Persist merged state after a successful flash so future partial
# invocations from this machine merge on top of what's actually on the
# device. This is the heart of the additive-by-default fix (#391/#574).
path = save_state(args.port, args.state_dir, merged)
path = save_board_state(args.port, chip_mac, args.state_dir, merged, legacy)
print(f"State persisted to {path}")
@@ -0,0 +1,246 @@
"""Tests for keying provision.py state by board MAC instead of serial port (#1755).
main() runs in-process. esptool's MAC read, the NVS generator and the flasher
are all stubbed, so no serial port is opened and nothing is flashed. MACs and
credentials are placeholders.
"""
import contextlib
import csv
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
provision = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(provision)
PORT = "/dev/cu.usbmodem-test"
OTHER_PORT = "/dev/cu.usbmodem-other"
MAC_A = "02:00:00:00:00:0a"
MAC_B = "02:00:00:00:00:0b"
CREDS = ("--ssid", "test-ssid", "--password", "fake-pass-not-real",
"--target-ip", "192.0.2.10")
# Shapes of `esptool read_mac` output (esptool/cmds.py read_mac in 4.x and 5.x).
ESPTOOL_V5_S3 = "Connected to ESP32-S3 on /dev/x:\nMAC: 02:00:00:00:00:0a\n"
ESPTOOL_V4_S3 = "Chip is ESP32-S3\nMAC: 02:00:00:00:00:0a\nHard resetting via RTS pin...\n"
ESPTOOL_V5_C6 = (
"MAC: 02:00:00:ff:fe:00:00:0a\n"
"BASE MAC: 02:00:00:00:00:0a\n"
"MAC_EXT: ff:fe\n"
)
class TestParseEsptoolMac(unittest.TestCase):
def test_six_byte_mac_line(self):
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V5_S3), MAC_A)
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V4_S3), MAC_A)
def test_eui64_chips_use_the_base_mac(self):
# C5/C6 print an 8-byte "MAC:" line first; that isn't the base MAC.
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V5_C6), MAC_A)
def test_no_mac_in_output(self):
self.assertIsNone(provision.parse_esptool_mac("A fatal error occurred\n"))
def test_normalize_mac(self):
self.assertEqual(provision.normalize_mac("02-00-00-00-00-0A"), MAC_A)
self.assertIsNone(provision.normalize_mac("02:00:00:00:00"))
self.assertIsNone(provision.normalize_mac("02:00:00:00:00:zz"))
class TestReadChipMac(unittest.TestCase):
def test_runs_esptool_read_mac_on_the_port(self):
done = subprocess.CompletedProcess([], 0, stdout=ESPTOOL_V5_C6, stderr="")
with mock.patch.object(provision.subprocess, "run", return_value=done) as run:
self.assertEqual(provision.read_chip_mac(PORT, 115200, "auto"), MAC_A)
cmd = run.call_args.args[0]
self.assertIn("read_mac", cmd)
self.assertEqual(cmd[cmd.index("--port") + 1], PORT)
def test_failure_returns_none(self):
failed = subprocess.CompletedProcess([], 2, stdout="", stderr="no port")
with mock.patch.object(provision.subprocess, "run", return_value=failed):
self.assertIsNone(provision.read_chip_mac(PORT, 115200, "auto"))
with mock.patch.object(provision.subprocess, "run",
side_effect=subprocess.TimeoutExpired("esptool", 30)):
self.assertIsNone(provision.read_chip_mac(PORT, 115200, "auto"))
class _MainCase(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp(prefix="provision-mac-")
self.state_dir = os.path.join(self.root, "state")
self.flashed_csv = []
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def run_main(self, *argv, mac=None):
"""Run main() with the board `mac` attached; return (code, stdout, stderr)."""
def fake_generate(csv_content, _size):
self.flashed_csv.append(csv_content)
return b"nvs"
out, err = io.StringIO(), io.StringIO()
code = 0
old_cwd = os.getcwd()
os.chdir(self.root)
try:
with mock.patch.object(sys, "argv", ["provision.py", *argv,
"--state-dir", self.state_dir]), \
mock.patch.object(provision, "read_chip_mac", return_value=mac,
create=True) as read, \
mock.patch.object(provision, "generate_nvs_binary",
side_effect=fake_generate), \
mock.patch.object(provision, "flash_nvs"), \
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
self.read_mac = read
try:
provision.main()
except SystemExit as exc:
code = exc.code
finally:
os.chdir(old_cwd)
return code, out.getvalue(), err.getvalue()
def last_flashed(self):
rows = csv.DictReader(io.StringIO(self.flashed_csv[-1]))
return {row["key"]: row["value"] for row in rows}
def board_file(self, mac):
return provision._state_path_for(provision.mac_state_key(mac), self.state_dir)
class TestBoardKeyedState(_MainCase):
def test_second_board_on_same_port_does_not_inherit_node_id(self):
self.assertEqual(self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)[0], 0)
code, _, _ = self.run_main("--port", PORT, *CREDS, mac=MAC_B)
self.assertEqual(code, 0)
self.assertNotIn("node_id", self.last_flashed())
def test_second_board_without_credentials_is_not_given_the_first_boards(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
code, _, err = self.run_main("--port", PORT, "--node-id", "3", mac=MAC_B)
self.assertEqual(code, 2)
self.assertIn("Missing required WiFi credentials", err)
def test_board_keeps_its_state_on_a_different_port(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
code, _, _ = self.run_main("--port", OTHER_PORT, "--zone", "lab", mac=MAC_A)
self.assertEqual(code, 0)
flashed = self.last_flashed()
self.assertEqual(flashed["node_id"], "2")
self.assertEqual(flashed["ssid"], "test-ssid")
def test_record_carries_board_identity(self):
self.run_main("--port", PORT, *CREDS, mac=MAC_A)
with open(self.board_file(MAC_A), encoding="utf-8") as f:
saved = json.load(f)
self.assertEqual(saved[provision.STATE_MAC_KEY], MAC_A)
self.assertEqual(saved[provision.STATE_PORT_KEY], PORT)
self.assertNotIn("_chip_mac", self.flashed_csv[-1])
def test_unreadable_mac_falls_back_to_port_keyed_state(self):
code, _, err = self.run_main("--port", PORT, *CREDS, mac=None)
self.assertEqual(code, 0)
self.assertIn("could not read the board's MAC", err)
self.assertTrue(os.path.isfile(provision._state_path_for(PORT, self.state_dir)))
def test_reset_ignores_the_boards_record(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
self.run_main("--port", PORT, "--reset", *CREDS, mac=MAC_A)
self.assertNotIn("node_id", self.last_flashed())
def test_invalid_mac_flag_is_rejected(self):
code, _, err = self.run_main("--port", PORT, "--mac", "not-a-mac", "--state")
self.assertEqual(code, 2)
self.assertIn("--mac", err)
class TestMigration(_MainCase):
def write_legacy(self, port, state):
os.makedirs(self.state_dir, exist_ok=True)
path = provision._state_path_for(port, self.state_dir)
with open(path, "w", encoding="utf-8") as f:
json.dump(state, f)
return path
def test_port_keyed_record_moves_to_the_board_and_is_retired(self):
legacy = self.write_legacy(PORT, {
"ssid": "test-ssid", "password": "fake-pass-not-real",
"target_ip": "192.0.2.10", "node_id": 5,
})
code, _, err = self.run_main("--port", PORT, "--zone", "lab", mac=MAC_A)
self.assertEqual(code, 0)
self.assertIn("port-keyed state", err)
self.assertEqual(self.last_flashed()["node_id"], "5")
self.assertFalse(os.path.exists(legacy))
with open(self.board_file(MAC_A), encoding="utf-8") as f:
self.assertEqual(json.load(f)["node_id"], 5)
# The next board on that port starts clean.
self.run_main("--port", PORT, *CREDS, mac=MAC_B)
self.assertNotIn("node_id", self.last_flashed())
def test_board_record_wins_over_a_port_keyed_file(self):
self.run_main("--port", OTHER_PORT, *CREDS, "--node-id", "2", mac=MAC_A)
self.write_legacy(PORT, {"node_id": 9})
code, _, _ = self.run_main("--port", PORT, "--zone", "lab", mac=MAC_A)
self.assertEqual(code, 0)
self.assertEqual(self.last_flashed()["node_id"], "2")
class TestNoSerialForInspection(_MainCase):
def test_state_shows_last_board_on_port_without_opening_it(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
code, out, err = self.run_main("--port", PORT, "--state", mac=MAC_B)
self.assertEqual(code, 0)
self.read_mac.assert_not_called()
self.assertEqual(json.loads(out)["node_id"], 2)
self.assertIn(MAC_A, err)
def test_state_with_mac_flag_selects_that_board(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
self.run_main("--port", PORT, *CREDS, "--node-id", "3", mac=MAC_B)
code, out, _ = self.run_main("--port", PORT, "--state", "--mac", MAC_A.upper())
self.assertEqual(code, 0)
self.assertEqual(json.loads(out)["node_id"], 2)
def test_dry_run_does_not_open_the_port(self):
code, _, _ = self.run_main("--port", PORT, *CREDS, "--dry-run", mac=MAC_A)
self.assertEqual(code, 0)
self.read_mac.assert_not_called()
if __name__ == "__main__":
unittest.main()