Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
rm -f "$SELECT_FILE"
if [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then
git fetch --no-tags origin master || true
# the selector reads each roster board's flasher from agentrc's board registry
python3 tools/get_deps.py lib/agentrc
# the suites gate the selector: test_change_impact.py owns the rules,
# test_build_select.py the check_build contract, and test_ci_metrics.py the
# config2 sentinel contract this job rewrites
Expand Down
3 changes: 3 additions & 0 deletions .claude/skills/build-doc/scripts/gen_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(ROOT / 'tools'))
sys.path.insert(0, str(ROOT / 'test' / 'hil'))
from get_deps import gen_doc as gen_deps_doc, rst_table # noqa: E402
from helper import hil_util # noqa: E402

HEADER = '<!-- Generated by .claude/skills/build-doc/scripts/gen_doc.py - do not edit. -->'

Expand Down Expand Up @@ -109,6 +111,7 @@ def hil_md(root):
out = [HEADER, '']
for title, src in (('ci rig', 'test/hil/tinyusb.json'), ('hfp rig', 'test/hil/hfp.json')):
entries = json.loads((root / src).read_text(encoding='utf-8')).get('boards', [])
entries = [hil_util.hil_boards().resolve(b) for b in entries] # each flasher is agentrc's
if not entries:
continue
out += [f'### {title}', '', f'{len(entries)} boards, from `{src}`.', '',
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/hil-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ htpc, a dev PC, also carries TinyUF2's rig, `test/hil/local.json` in that checko
`tusb` (ssh alias `hifiphile`) is an external rig (hosted by maintainer hifiphile), exercised by the
GitHub CI `hil-tinyusb (hfp.json)` matrix job — **never run HIL against it unless the user explicitly asks.**

A roster lists `boards` and parked `boards-skip`. Common entry fields: `name` (also the board's lock name), `uid`, `tests`, `flasher {name, uid, args, vid_pid, verify}` (only `name` and `uid` required), and optional `flasher_recover`, `variant`, `logger`, `toolchain`, `uart_uid`, `note`; `test/hil/hil_test.py`'s `Board` type is the full shape. A review may add or enable a `local.json` entry on a dev PC: name, board uid, flasher and tests, and a flashable example to run on it.
A roster lists `boards` and parked `boards-skip`. Common entry fields: `name` (also the board's lock name), `uid` (the serial TinyUSB firmware reports), `probe`, `tests`, and optional `variant`, `logger`, `toolchain`, `uart_uid`, `note`; `test/hil/hil_test.py`'s `Board` type is the full shape. A board's `flasher {name, uid, args, vid_pid, verify}` and optional `flasher_recover` are agentrc's, from its board registry (`lib/agentrc/skills/hil/boards.json`, `hil` Board registry) by `probe`, the serial of the debug probe the board is wired to, never the roster's: a rig board's wiring changes by an agentrc commit and a `lib/agentrc` pin bump. Every reader of a flasher resolves the entry through `hil_util.hil_boards()`. A review may add or enable a `local.json` entry on a dev PC: name, board uid, tests, a flashable example to run on it, and `probe` when the registry holds the board's probe, else the board's own `flasher`.

If `local.json` is missing on a dev PC, ask the user to supply one before a `hil_test.py` run or a farm check. An agent that cannot ask (`hil-operator`) does not run `hil_test.py`: it returns the no-run rows Results defines, `detail` naming the missing `test/hil/local.json`. Fall back to `tinyusb.json` only when the request or task scope says so; a manual session locks by board name as Reservation says.

Expand Down
10 changes: 8 additions & 2 deletions .claude/skills/hil-contract/scripts/hil_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ def helper(name):
'test/hil/pyocd/README.md',
'tools/get_deps.py',
'lib/agentrc/.git/HEAD',
'lib/agentrc/skills/hil/boards.json',
'lib/agentrc/skills/hil/scripts/hil_boards.py',
'lib/agentrc/skills/hil/scripts/hil_flash.py',
'lib/agentrc/skills/hil/scripts/hil_load.py',
'lib/agentrc/skills/hil/scripts/hil_openocd_esp.py',
Expand Down Expand Up @@ -137,8 +139,12 @@ def select_boards(config, args):
fail(f'not in the config: {" ".join(unknown)} (-b takes board names, not variant names)')
selected = boards or list(roster)
if args.flasher or args.exclude_flasher:
selected = [n for n in selected if roster[n]['flasher']['name'] not in args.exclude_flasher
and (not args.flasher or roster[n]['flasher']['name'] in args.flasher)]
try: # each board's flasher is agentrc's (its board registry), as hil_test.py resolves it
kind = {n: helper('hil_util').hil_boards().resolve(roster[n])['flasher']['name'] for n in selected}
except ValueError as e:
fail(f'config: {e}')
selected = [n for n in selected if kind[n] not in args.exclude_flasher
and (not args.flasher or kind[n] in args.flasher)]
if not selected:
fail(f'no board left after the flasher filter (--flasher {args.flasher or "-"}, '
f'--exclude-flasher {args.exclude_flasher or "-"})')
Expand Down
11 changes: 6 additions & 5 deletions .claude/skills/usbtest/scripts/run_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ def kill_children(spare=()):


def resolve(config, board_name, variant):
"""(board, variant name) from the roster, or Refused naming the alternatives."""
"""(board, variant name) from the roster, its flashers from agentrc's board registry, or Refused
naming the alternatives."""
try:
cfg = json.loads(Path(config).read_text())
except (OSError, ValueError) as e:
Expand All @@ -153,10 +154,10 @@ def resolve(config, board_name, variant):
if 'device/usbtest' not in change_impact.board_tests(board):
raise Refused(f'{board_name} does not run device/usbtest in {config} (its "tests" entry); '
f'its device port may not reach this host')
flasher = board.get('flasher')
if not (isinstance(board.get('uid'), str) and board['uid'] and isinstance(flasher, dict)
and isinstance(flasher.get('name'), str) and flasher['name']):
raise Refused(f'{board_name} in {config} needs a "uid" and a "flasher" with a "name"')
try: # its flasher and flasher_recover are agentrc's, by its probe (`hil` skill, Board registry)
board = hil_util.hil_boards().resolve(board)
except ValueError as e:
raise Refused(f'{config}: {e}')
if why := hil_flash.openocd_esp_refused(board, retains_lock=False):
raise Refused(f'{board_name}: {why}')
try:
Expand Down
5 changes: 1 addition & 4 deletions .claude/skills/usbtest/scripts/wedge_drill.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,7 @@ def halt_board(board, rec_board, halt_args):
if halt_args is None:
raise Refused(f'{name}: the drill halts through openocd and this board recovers through '
f'{rec_board["flasher"]["name"]}; name the openocd halt with --halt-openocd')
uid = board['flasher'].get('uid')
if not uid:
raise Refused(f'{name}: no probe uid for openocd\'s adapter serial')
halt = {'name': 'openocd', 'uid': uid, 'args': halt_args}
halt = {'name': 'openocd', 'uid': board['flasher']['uid'], 'args': halt_args}
if not hil_flash.convoy_safe(halt):
raise Refused(f'{name}: --halt-openocd {halt_args!r} is not convoy-safe')
return {'name': name, 'flasher': halt}
Expand Down
26 changes: 18 additions & 8 deletions .claude/test/test_gen_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,21 @@ def fixture(root):
(root / 'docs' / 'reference').mkdir(parents=True)
(root / 'test' / 'hil').mkdir(parents=True)
(root / 'test' / 'hil' / 'tinyusb.json').write_text(json.dumps({'boards': [
{'name': 'acme_one', 'tests': {'device': True, 'host': True, 'dual': False},
'flasher': {'name': 'jlink'}, 'variant': [{'name': 'acme_one_hs'}],
'note': 'needs a | jumper \u00f8'},
{'name': 'acme_two', 'tests': {'only': ['host/cdc_msc', 'device/hid']}},
{'name': 'acme_one', 'uid': 'A1', 'probe': 'p1', 'tests': {'device': True, 'host': True, 'dual': False},
'variant': [{'name': 'acme_one_hs'}], 'note': 'needs a | jumper \u00f8'},
{'name': 'acme_two', 'uid': 'A2', 'probe': 'p2', 'tests': {'only': ['host/cdc_msc', 'device/hid']}},
]}, ensure_ascii=False), encoding='utf-8')
(root / 'test' / 'hil' / 'hfp.json').write_text(json.dumps({'boards': []}))


REGISTRY_FLASHERS = {'p1': {'name': 'jlink', 'uid': 'p1'}, 'p2': {'name': 'openocd', 'uid': 'p2'}}


def registry_stub():
"""agentrc's board registry with the fixture boards' flashers, which their roster entries do not carry."""
return mock.Mock(resolve=lambda entry: {**entry, 'flasher': REGISTRY_FLASHERS[entry['probe']]})


class MetadataTest(unittest.TestCase):
def test_block_values_are_stripped_and_missing_file_or_block_is_empty(self):
with tempfile.TemporaryDirectory() as d:
Expand Down Expand Up @@ -103,8 +110,11 @@ def test_writes_the_three_files_and_lists_what_it_left_out(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
fixture(root)
r = subprocess.run([sys.executable, str(GEN_DOC), '--root', str(root)], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
with mock.patch.object(sys, 'argv', ['gen_doc.py', '--root', str(root)]), \
mock.patch.object(gen_doc.hil_util, 'hil_boards', registry_stub), \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()) as err:
rc = gen_doc.main()
self.assertEqual(rc, 0, err.getvalue())
boards = (root / 'docs/reference/boards.rst').read_text(encoding='utf-8')
hil = (root / 'docs/reference/hil_boards.md').read_text(encoding='utf-8')
deps = (root / 'docs/reference/dependencies.rst').read_text()
Expand All @@ -113,15 +123,15 @@ def test_writes_the_three_files_and_lists_what_it_left_out(self):
self.assertNotIn('acme_bare', boards)
self.assertNotIn('nometa', boards)
self.assertTrue(boards.endswith('=\n'))
self.assertEqual(r.stderr.split('\n'), [
self.assertEqual(err.getvalue().split('\n'), [
'no metadata block, left out of boards.rst:',
' hw/bsp/acme/boards/acme_bare',
' hw/bsp/nometa',
'',
])
self.assertTrue(hil.startswith(gen_doc.HEADER + '\n\n### ci rig\n\n2 boards, from `test/hil/tinyusb.json`.'))
self.assertIn('| acme_one | device, host | jlink | acme_one_hs | needs a \\| jumper \u00f8 |', hil)
self.assertIn('| acme_two | device, host | | | |', hil)
self.assertIn('| acme_two | device, host | openocd | | |', hil)
self.assertNotIn('hfp rig', hil)
self.assertIn('lib/fatfs', deps)

Expand Down
28 changes: 22 additions & 6 deletions .claude/test/test_hil_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,21 @@
# the loader, the pin, hil_load, the core it loads and the notice the core loads; and, though
# the rig never runs it, the transport core hil_remote.py loads through that checkout's loader
REAL = ('test/hil/helper/hil_args.py', 'test/hil/helper/hil_report.py', 'test/hil/helper/agentrc.py',
'tools/get_deps.py', 'lib/agentrc/.git/HEAD', 'lib/agentrc/skills/hil/scripts/hil_load.py',
'lib/agentrc/skills/hil/scripts/hil_report_core.py', 'lib/agentrc/skills/hil/scripts/hil_report_notice.py')
'test/hil/helper/hil_util.py', 'tools/get_deps.py', 'lib/agentrc/.git/HEAD',
'lib/agentrc/skills/hil/scripts/hil_load.py', 'lib/agentrc/skills/hil/scripts/hil_report_core.py',
'lib/agentrc/skills/hil/scripts/hil_report_notice.py', 'lib/agentrc/skills/hil/scripts/hil_boards.py',
'lib/agentrc/skills/hil/boards.json', 'lib/agentrc/skills/hil/scripts/usb_observe.py')
REMOTE_CORE = 'lib/agentrc/skills/hil/scripts/hil_remote_core.py'

# gamma has no inline flasher: agentrc's board registry gives it, by probe, one alpha and beta lack
REGISTRY = json.loads((REPO / 'lib/agentrc/skills/hil/boards.json').read_text(encoding='utf-8'))['boards']
GAMMA_PROBE, GAMMA_FLASHER = next((probe, b['flasher']['name']) for probe, b in REGISTRY.items()
if b['flasher']['name'] not in ('jlink', 'openocd'))
CONFIG = {'boards': [
{'name': 'alpha', 'flasher': {'name': 'jlink'}},
{'name': 'beta', 'flasher': {'name': 'openocd'}, 'variant': [{'name': 'beta_one'}, {'name': 'beta_two'}]},
{'name': 'alpha', 'uid': 'A', 'flasher': {'name': 'jlink', 'uid': 'pa'}},
{'name': 'beta', 'uid': 'B', 'flasher': {'name': 'openocd', 'uid': 'pb'},
'variant': [{'name': 'beta_one'}, {'name': 'beta_two'}]},
{'name': 'gamma', 'uid': 'G', 'probe': GAMMA_PROBE},
]}


Expand Down Expand Up @@ -189,7 +197,7 @@ def test_build_on_the_rig(self):
# without -b the line names the boards the run would select
r = self.hil_remote('--build', '--exclude-flasher', 'openocd')
self.refused(r, '--build')
self.assertIn('check_build.py --board alpha --shared --variants', r.stderr)
self.assertIn('check_build.py --board alpha --board gamma --shared --variants', r.stderr)

def test_a_systemview_capture_is_ci_only(self):
self.build('alpha')
Expand All @@ -214,7 +222,7 @@ def test_every_unbuilt_board_at_once(self):
def test_all_boards_with_nothing_built(self):
r = self.hil_remote()
self.refused(r, 'nothing to test')
self.assertIn('--board alpha --board beta --shared --variants test/hil/tinyusb.json', r.stderr)
self.assertIn('--board alpha --board beta --board gamma --shared --variants test/hil/tinyusb.json', r.stderr)

def test_the_build_line_quotes_the_config(self):
(self.root / 'test/hil/my rig.json').write_text(json.dumps(CONFIG))
Expand All @@ -235,6 +243,14 @@ def test_a_board_the_flasher_filter_drops_satisfies_nothing(self):
self.build('beta_one')
self.refused(self.hil_remote('--exclude-flasher', 'openocd'), 'nothing to test')

def test_the_flasher_filter_takes_a_registered_board_flasher_from_the_registry(self):
r = self.hil_remote('--build', '--flasher', GAMMA_FLASHER)
self.refused(r, '--build')
self.assertIn('check_build.py --board gamma --shared --variants', r.stderr)
r = self.hil_remote('--build', '--exclude-flasher', GAMMA_FLASHER)
self.refused(r, '--build')
self.assertIn('check_build.py --board alpha --board beta --shared --variants', r.stderr)

def test_the_preset_layout_is_not_read(self):
self.build('alpha', root='examples')
self.refused(self.hil_remote('-b', 'alpha'), 'no build under cmake-build/ for:')
Expand Down
25 changes: 15 additions & 10 deletions .claude/test/test_usbtest_run_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,22 @@
'boards': [
{'name': 'solo', 'uid': 'UID1', 'flasher': {'name': 'jlink', 'uid': 'P1', 'args': '-device X'},
'tests': {'device': True, 'host': False}},
{'name': 'duo', 'uid': 'UID2', 'flasher': {'name': 'openocd', 'uid': 'P2', 'args': ''},
{'name': 'duo', 'uid': 'UID2', 'flasher': {'name': 'openocd', 'uid': 'P2'},
'variant': [{'name': 'duo-a', 'flags': ''}, {'name': 'duo-b', 'flags': ''}],
'tests': {'only': ['device/board_test', 'device/usbtest']}},
{'name': 'hostonly', 'uid': 'UID4', 'flasher': {'name': 'openocd'}, 'tests': {'device': False, 'host': True}},
{'name': 'skips', 'uid': 'UID5', 'flasher': {'name': 'jlink'},
{'name': 'hostonly', 'uid': 'UID4', 'flasher': {'name': 'openocd', 'uid': 'P1x'}, 'tests': {'device': False, 'host': True}},
{'name': 'skips', 'uid': 'UID5', 'flasher': {'name': 'jlink', 'uid': 'P2x'},
'tests': {'device': True, 'skip': ['device/usbtest']}},
{'name': 'notlisted', 'uid': 'UID6', 'flasher': {'name': 'jlink'}, 'tests': {'only': ['device/cdc_msc']}},
{'name': 'untested', 'uid': 'UID7', 'flasher': {'name': 'jlink'}},
{'name': 'malformed', 'uid': 'UID8', 'flasher': {'name': 'jlink'}, 'tests': {'device': True},
{'name': 'notlisted', 'uid': 'UID6', 'flasher': {'name': 'jlink', 'uid': 'P3x'}, 'tests': {'only': ['device/cdc_msc']}},
{'name': 'untested', 'uid': 'UID7', 'flasher': {'name': 'jlink', 'uid': 'P4x'}},
{'name': 'malformed', 'uid': 'UID8', 'flasher': {'name': 'jlink', 'uid': 'P5x'}, 'tests': {'device': True},
'variant': [{'name': ''}]},
{'name': 'noflasher', 'uid': 'UID9', 'tests': {'only': ['device/usbtest']}},
{'name': 'nouid', 'flasher': {'name': 'jlink'}, 'tests': {'only': ['device/usbtest']}},
{'name': 'nouid', 'flasher': {'name': 'jlink', 'uid': 'P6x'}, 'tests': {'only': ['device/usbtest']}},
{'name': 'esp', 'uid': 'UID10', 'tests': {'only': ['device/usbtest']},
'flasher': {'name': 'openocd_esp', 'uid': '30:ED:A0:E0:C9:02', 'args': '-f board/esp32p4-builtin.cfg'}},
],
'boards-skip': [{'name': 'parked', 'uid': 'UID3', 'flasher': {'name': 'jlink'}}],
'boards-skip': [{'name': 'parked', 'uid': 'UID3', 'flasher': {'name': 'jlink', 'uid': 'P7x'}}],
}
PASS_JSON = {'passed': 1, 'failed': 0, 'notrun': 0, 'wedged': False,
'cases': [{'num': 29, 'name': 'toggle clear', 'status': 'PASS'}]}
Expand Down Expand Up @@ -153,8 +153,8 @@ def test_board_and_variant_resolution(self):
(['--board', 'notlisted'], 'notlisted does not run device/usbtest'),
(['--board', 'untested'], 'untested does not run device/usbtest'),
(['--board', 'malformed'], 'malformed: board malformed variant'),
(['--board', 'noflasher'], 'noflasher in'),
(['--board', 'nouid'], 'nouid in'),
(['--board', 'noflasher'], 'noflasher: board UID9 names neither a registered probe nor a flasher'),
(['--board', 'nouid'], 'nouid: no uid'),
(['--board', 'esp'], 'esp: openocd_esp is refused to a caller that does not declare'),
(['--board', 'duo'], 'duo has variants duo-a, duo-b: pass --variant'),
(['--board', 'duo', '--variant', 'duo-c'], 'duo has no variant duo-c'),
Expand All @@ -163,6 +163,11 @@ def test_board_and_variant_resolution(self):
self.refused(rig, *argv, '--tests', '29', '--after', 'leave', says=says)
self.assertEqual(rig.calls, [])

def test_a_registered_board_takes_its_flashers_from_agentrc(self):
board, variant = run_case.resolve(REPO / 'test/hil/tinyusb.json', 'ek_tm4c123gxl', None)
self.assertEqual((variant, board['flasher']['name'], board['flasher_recover']['name']),
('ek_tm4c123gxl', 'lm4flash', 'openocd'))

def test_bad_case_lists(self):
for tests in ('29,29', '30', 'x', '29,'):
self.refused(Rig(self), '--board', 'solo', '--tests', tests, '--after', 'leave',
Expand Down
2 changes: 1 addition & 1 deletion .claude/test/test_usbtest_wedge_drill.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ def test_a_non_openocd_recovery_needs_a_halt_override(self):
def test_a_bad_halt_override(self):
self.refused('--board', 'st', '--halt-openocd', ' ', says='not convoy-safe')
self.refused('--board', 'st', '--halt-openocd', '-f interface/cmsis-dap.cfg', says='not convoy-safe')
self.refused('--board', 'st-nouid', '--halt-openocd', ST_HALT, says='no probe uid')
self.refused('--board', 'st-nouid', '--halt-openocd', ST_HALT, says='flasher: uid must be a non-empty string')

def test_an_override_on_an_openocd_recovery_board(self):
self.refused('--board', 'ok', '--halt-openocd', ST_HALT, says='halts through its own')
Expand Down
Loading
Loading