ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_c6_pin_config.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: the C6 Kconfig defaults agree with the pin map they are derived from.
5
6Why two files exist
7-------------------
8``coprocessor/esp32c6/pins.env`` is the SINGLE SOURCE OF TRUTH for the
9RA8-host <-> ESP32-C6 SPI wiring. It is a plain ``KEY=value`` fragment because
10its consumers are shell (``build.sh``, ``flash.sh``) and, on the RA8 side,
11humans reading one file to learn the pinout.
12
13``coprocessor/esp32c6/sdkconfig.defaults`` carries the SAME numbers again in
14Kconfig syntax, because that is the only form esp-idf reads. It is a DERIVED
15artifact, and it is deliberately byte-stable: the bench-proven C6 image was
16built from exactly these lines, so it is verified against pins.env rather than
17regenerated from it.
18
19Two files holding one fact drift silently, and a drift here is expensive: the
20build succeeds, the firmware flashes, and the SPI link simply never comes up
21because the C6 is driving a different pin than the RA8 is. Nothing downstream
22of the mistake can detect it -- which is what makes this a gate and not a
23convention.
24
25What is compared
26----------------
27Every SPI signal (CS, COPI/CIPO, SCK, DATA_READY, HANDSHAKE, RESET), the chip
28target, the flash size, and the dev-board preset. The last two are the
29interesting ones: esp-idf and esp-hosted-mcu encode both in the KEY
30(``CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y``, ``CONFIG_ESP_HOST_DEV_BOARD_NONE=y``)
31rather than in the value, so each is parsed out of the key name and compared
32against ``C6_FLASH_SIZE`` / ``C6_DEV_BOARD``.
33
34The dev-board preset is load-bearing and easy to miss. esp-hosted-mcu ships
35presets for Espressif's own dev boards, and selecting one OVERRIDES the SPI pin
36leaves -- so with any board but ``NONE`` selected, every pin this gate compares
37is silently ignored by the build and the C6 comes up on the preset's pins
38instead of ours. Comparing the enabled symbol's SUFFIX rather than merely
39asserting the symbol is present catches the drift in both of its shapes: the
40selection vanishing, and the selection moving to a different board.
41
42Two of the data signals carry Espressif's legacy names in their Kconfig symbols.
43Those symbols belong to upstream and cannot be renamed from here; the mapping
44table is where they are tied to this project's COPI/CIPO vocabulary.
45
46What is checked on the RA8 side
47-------------------------------
48pins.env also records where each signal LANDS on the EK-RA8D2 (MCU pin and
49J26 hole) and which SW4 DIP positions the link needs. That map IS restated in
50one other place -- ``port/esp-hosted/inc/ra8_esp_hosted_pins.h``, which the
51esp-hosted port compiles against -- so the two are diffed here. They have
52already drifted once: the port header was first written from the probe's
53candidate list while the module was disconnected, and the rebuilt harness was
54later characterised with HANDSHAKE and DATA_READY the other way round. A pin
55map that only one file knows is a pin map that will be wrong again.
56
57Independently of that diff, the map is checked for the failures that actually
58happen to a pin map: a signal named at one end and not the other, a pin or
59hole name that is not a pin or hole name, two signals silently claiming the
60same pin after a copy-paste, and a missing SW4 position. That bank is not
61incidental: SW4-4 ON with SW4-3 OFF holds the
62Pmod1 bus switches open, so J26-1..J26-4 never reach the MCU, and misreading
63it cost a full bench day chasing a harness that was fine.
64
65This is a pure text comparison of two committed files -- no esp-idf, no
66toolchain, no hardware -- so it runs in CI on any box. ``build.sh`` invokes
67this same script before it builds, so the bench and CI apply one rule.
68
69Non-vacuity
70-----------
71``--selftest`` drives the comparator with crafted file bodies: an agreeing
72pair must be silent, and a disagreeing pair, a missing key on either side, a
73missing flash-size key, and a dev-board preset that either moved to another
74board or stopped being selected must each be reported.
75
76Run::
77
78 check_c6_pin_config.py # gate (fail on any drift)
79 check_c6_pin_config.py --selftest # prove the comparator both ways
80
81Exit 0 when the two files agree, 1 on drift or a failing selftest, 2 when a
82file cannot be read.
83"""
84
85from __future__ import annotations
86
87import re
88import sys
89from pathlib import Path
90
91REPO_ROOT = Path(__file__).resolve().parents[2]
92C6_DIR = REPO_ROOT / "coprocessor" / "esp32c6"
93PINS_ENV = C6_DIR / "pins.env"
94SDKCONFIG = C6_DIR / "sdkconfig.defaults"
95HOST_FW_VER = (
96 REPO_ROOT / "libs" / "third_party" / "esp-hosted" / "host" / "esp_hosted_host_fw_ver.h"
97)
98
99EXIT_OK = 0
100EXIT_FAIL = 1
101EXIT_CONFIG = 2
102
103# (Kconfig symbol, pins.env key, human label for the failure text).
104# The two data-signal symbols keep Espressif's legacy spelling because they are
105# upstream esp-hosted-mcu Kconfig names and cannot be renamed from this
106# repository; our own signal names are COPI/CIPO, which is what the labels use.
107PIN_PAIRS: tuple[tuple[str, str, str], ...] = (
108 ("CONFIG_ESP_SPI_HSPI_GPIO_CS", "C6_PIN_CS", "CS (Chip Select)"),
109 ("CONFIG_ESP_SPI_HSPI_GPIO_MOSI", "C6_PIN_COPI", "COPI (Controller Out)"),
110 ("CONFIG_ESP_SPI_HSPI_GPIO_MISO", "C6_PIN_CIPO", "CIPO (Controller In)"),
111 ("CONFIG_ESP_SPI_HSPI_GPIO_CLK", "C6_PIN_SCK", "SCK (clock)"),
112 ("CONFIG_ESP_SPI_GPIO_DATA_READY", "C6_PIN_DATA_READY", "DATA_READY"),
113 ("CONFIG_ESP_SPI_GPIO_HANDSHAKE", "C6_PIN_HANDSHAKE", "HANDSHAKE"),
114 ("CONFIG_ESP_SPI_GPIO_RESET", "C6_PIN_RESET", "RESET"),
115)
116
117# Same shape, for the non-pin settings that are also stated twice.
118VALUE_PAIRS: tuple[tuple[str, str, str], ...] = (
119 ("CONFIG_IDF_TARGET", "ESP_TARGET", "chip target"),
120)
121
122# esp-idf encodes the flash size in the SYMBOL NAME, not the value.
123_FLASHSIZE_RE = re.compile(r"^CONFIG_ESPTOOLPY_FLASHSIZE_([0-9]+MB)$")
124FLASH_SIZE_KEY = "C6_FLASH_SIZE"
125
126# esp-hosted-mcu encodes the dev-board preset in the SYMBOL NAME too. Selecting
127# a board OVERRIDES the SPI pin leaves above, which would make every pin
128# comparison in this gate describe a build that does not happen.
129_DEV_BOARD_RE = re.compile(r"^CONFIG_ESP_HOST_DEV_BOARD_([A-Z0-9_]+)$")
130DEV_BOARD_KEY = "C6_DEV_BOARD"
131
132# (label, C6 GPIO key, RA8 landing-pin key, RA8 J26 hole key). The RA8 side of
133# the harness is recorded in pins.env too, because a pin map is only useful if
134# it names BOTH ends: "GPIO4" alone does not tell anyone which MCU pin to read.
135RA8_TRIPLES: tuple[tuple[str, str, str, str], ...] = (
136 ("CS (Chip Select)", "C6_PIN_CS", "RA8_PIN_CS", "RA8_J26_CS"),
137 ("COPI (Controller Out)", "C6_PIN_COPI", "RA8_PIN_COPI", "RA8_J26_COPI"),
138 ("CIPO (Controller In)", "C6_PIN_CIPO", "RA8_PIN_CIPO", "RA8_J26_CIPO"),
139 ("SCK (clock)", "C6_PIN_SCK", "RA8_PIN_SCK", "RA8_J26_SCK"),
140 ("DATA_READY", "C6_PIN_DATA_READY", "RA8_PIN_DATA_READY", "RA8_J26_DATA_READY"),
141 ("HANDSHAKE", "C6_PIN_HANDSHAKE", "RA8_PIN_HANDSHAKE", "RA8_J26_HANDSHAKE"),
142 ("RESET", "C6_PIN_RESET", "RA8_PIN_RESET", "RA8_J26_RESET"),
143)
144
145# The four EK-RA8D2 DIP switches that decide whether J26-1..J26-4 reach the MCU
146# at all. Misreading this bank was the whole 2026-07-26 C6 outage, so the
147# required positions are recorded as data rather than as prose in one doc.
148SW4_KEYS: tuple[str, ...] = ("RA8_SW4_1", "RA8_SW4_2", "RA8_SW4_3", "RA8_SW4_4")
149SW4_VALUES: tuple[str, ...] = ("ON", "OFF")
150
151# A signal with no wire: "none" on the RA8 side must pair with -1 on the C6
152# side, in both directions. Half a disconnection recorded is a pin map that
153# claims a link nobody built.
154UNWIRED_RA8 = "none"
155UNWIRED_C6 = "-1"
156
157_RA8_PIN_RE = re.compile(r"^P[0-9]{3}$")
158
159PORT_PIN_HEADER = REPO_ROOT / "port" / "esp-hosted" / "inc" / "ra8_esp_hosted_pins.h"
160"""The esp-hosted port's copy of the RA8-side map, diffed against pins.env."""
161
162PORT_PIN_ROWS: tuple[tuple[str, str, str], ...] = (
163 ("CS", "k_ra8_esp_hosted_pin_chip_select", "RA8_PIN_CS"),
164 ("COPI", "k_ra8_esp_hosted_pin_copi", "RA8_PIN_COPI"),
165 ("CIPO", "k_ra8_esp_hosted_pin_cipo", "RA8_PIN_CIPO"),
166 ("SCK", "k_ra8_esp_hosted_pin_sck", "RA8_PIN_SCK"),
167 ("HANDSHAKE", "k_ra8_esp_hosted_pin_handshake", "RA8_PIN_HANDSHAKE"),
168 ("DATA_READY", "k_ra8_esp_hosted_pin_data_ready", "RA8_PIN_DATA_READY"),
169 ("RESET", "k_ra8_esp_hosted_pin_reset", "RA8_PIN_RESET"),
170)
171"""Signal, the port header's enumerator, and the pins.env key it must match."""
172
173BOARD_SYMBOL_TO_PIN: dict[str, str] = {
174 "k_ra8_board_pmod1_spi_cs": "P804",
175 "k_ra8_board_pmod1_spi_copi": "P801",
176 "k_ra8_board_pmod1_spi_cipo": "P802",
177 "k_ra8_board_pmod1_spi_sck": "P803",
178 "k_ra8_board_pmod1_irq": "P006",
179 "k_ra8_board_pmod1_reset": "P402",
180 "k_ra8_board_pmod1_gpio_a": "P412",
181 "k_ra8_board_pmod1_gpio_b": "P413",
182 "k_ra8_pin_none": "none",
183}
184"""Board-layer Pmod1 enumerators and the MCU pin each names.
185
186The port header cites board symbols rather than pin numbers -- that is the
187point of the board layer -- so resolving them is what lets the two files be
188compared at all. The values come from
189``libs/ra8_board_ek_ra8d2/inc/ra8_board_ek_ra8d2_connectors.h``, which carries
190the board User's Manual citation for every row.
191"""
192
193_PORT_ROW_RE = re.compile(
194 r"(?P<enum>k_ra8_esp_hosted_pin_[a-z_]+)\s*=\s*\‍(uint16_t\‍)(?P<sym>k_ra8_[a-z0-9_]+)"
195)
196
197
198def parse_port_header(text: str) -> dict[str, str]:
199 """Return {enumerator: board symbol} for every row of the port pin map."""
200 return {m.group("enum"): m.group("sym") for m in _PORT_ROW_RE.finditer(text)}
201
202
203def check_port_header(pins: dict[str, str], header: str) -> list[str]:
204 """Return one message per disagreement with the port's pin map.
205
206 Args:
207 pins: Parsed pins.env assignments (the source of truth).
208 header: Text of ``ra8_esp_hosted_pins.h``.
209
210 Returns:
211 Human-readable findings; empty when the header agrees with pins.env.
212 """
213 rows = parse_port_header(header)
214 findings: list[str] = []
215 for label, enum_name, pin_key in PORT_PIN_ROWS:
216 if enum_name not in rows:
217 findings.append(f"{label}: the port pin header does not define {enum_name}")
218 continue
219 symbol = rows[enum_name]
220 if symbol not in BOARD_SYMBOL_TO_PIN:
221 findings.append(
222 f"{label}: the port pin header names {symbol}, which this gate cannot "
223 f"resolve to an MCU pin; add it to BOARD_SYMBOL_TO_PIN"
224 )
225 continue
226 if pin_key not in pins:
227 findings.append(f"{label}: pins.env is missing {pin_key}")
228 continue
229 resolved = BOARD_SYMBOL_TO_PIN[symbol]
230 if resolved != pins[pin_key]:
231 findings.append(
232 f"{label}: the port pin header says {symbol} ({resolved}) "
233 f"but pins.env says {pin_key}={pins[pin_key]}"
234 )
235 return findings
236
237
238_RA8_HOLE_RE = re.compile(r"^J26-([0-9]{1,2})$")
239
240_ASSIGN_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$")
241
242# Shortest value that can carry a matched pair of surrounding quotes ("" is 2).
243_MIN_QUOTED_LEN = 2
244
245
246def parse_assignments(text: str) -> dict[str, str]:
247 """Parse ``KEY=value`` lines, ignoring comments and blanks.
248
249 Handles both file formats: the shell fragment and Kconfig defaults share
250 this syntax, and Kconfig's quoted string values are unquoted here so
251 ``CONFIG_IDF_TARGET="esp32c6"`` compares equal to ``ESP_TARGET=esp32c6``.
252
253 Args:
254 text: File contents.
255
256 Returns:
257 Mapping of key to unquoted value.
258 """
259 out: dict[str, str] = {}
260 for raw in text.splitlines():
261 line = raw.strip()
262 if not line or line.startswith("#"):
263 continue
264 match = _ASSIGN_RE.match(line)
265 if match is None:
266 continue
267 key, value = match.group(1), match.group(2)
268 if len(value) >= _MIN_QUOTED_LEN and value[0] == value[-1] and value[0] in "\"'":
269 value = value[1:-1]
270 out[key] = value
271 return out
272
273
274def _flash_size(sdk: dict[str, str]) -> str | None:
275 """Return the flash size encoded in an enabled FLASHSIZE symbol.
276
277 Args:
278 sdk: Parsed sdkconfig assignments.
279
280 Returns:
281 The size text (e.g. "16MB"), or None when no such symbol is enabled.
282 """
283 for key, value in sdk.items():
284 match = _FLASHSIZE_RE.match(key)
285 if match is not None and value == "y":
286 return match.group(1)
287 return None
288
289
290def _dev_board(sdk: dict[str, str]) -> str | None:
291 """Return the dev-board preset encoded in an enabled DEV_BOARD symbol.
292
293 Args:
294 sdk: Parsed sdkconfig assignments.
295
296 Returns:
297 The board name lower-cased (e.g. "none"), or None when no such symbol
298 is enabled -- which is itself a finding, because the preset decides
299 whether our pin leaves are honoured at all.
300 """
301 for key, value in sdk.items():
302 match = _DEV_BOARD_RE.match(key)
303 if match is not None and value == "y":
304 return match.group(1).lower()
305 return None
306
307
308def _check_one_signal(label: str, c6: str, ra8_pin: str, ra8_hole: str) -> list[str]:
309 """Return findings for one signal's RA8-side entry.
310
311 Args:
312 label: Human-readable signal name.
313 c6: C6 GPIO number as written in pins.env.
314 ra8_pin: RA8 landing pin, or "none".
315 ra8_hole: J26 hole, or "none".
316
317 Returns:
318 Human-readable findings; empty when the entry is well-formed.
319 """
320 unwired_c6 = c6 == UNWIRED_C6
321 unwired_ra8 = ra8_pin == UNWIRED_RA8 and ra8_hole == UNWIRED_RA8
322 if unwired_c6 != unwired_ra8:
323 return [
324 f"{label}: half-recorded connection -- C6 side is "
325 f"{'disconnected' if unwired_c6 else c6} but RA8 side is "
326 f"{ra8_pin}/{ra8_hole}"
327 ]
328 if unwired_ra8:
329 return []
330
331 findings: list[str] = []
332 if _RA8_PIN_RE.match(ra8_pin) is None:
333 findings.append(f"{label}: RA8 pin {ra8_pin!r} is not a Pnnn pin name or 'none'")
334 if _RA8_HOLE_RE.match(ra8_hole) is None:
335 findings.append(f"{label}: J26 hole {ra8_hole!r} is not a J26-n hole or 'none'")
336 return findings
337
338
339def _check_uniqueness(pins: dict[str, str]) -> list[str]:
340 """Return findings for any RA8 pin or J26 hole claimed by two signals.
341
342 Args:
343 pins: Parsed pins.env assignments.
344
345 Returns:
346 Human-readable findings; empty when every wired entry is unique.
347 """
348 findings: list[str] = []
349 for what, index in (("RA8 pin", 2), ("J26 hole", 3)):
350 seen: dict[str, str] = {}
351 for triple in RA8_TRIPLES:
352 value = pins.get(triple[index], UNWIRED_RA8)
353 if value == UNWIRED_RA8:
354 continue
355 if value in seen:
356 findings.append(f"{what} {value} is claimed by both {seen[value]} and {triple[0]}")
357 seen[value] = triple[0]
358 return findings
359
360
361def check_ra8_side(pins: dict[str, str]) -> list[str]:
362 """Return one message per defect in the RA8-side half of the pin map.
363
364 The C6-side numbers are checked against sdkconfig.defaults by ``compare``;
365 nothing downstream can check the RA8 side that way, because no second file
366 restates it. What is checkable is that the map is COMPLETE and INTERNALLY
367 CONSISTENT: every signal names both ends or neither, the names are
368 well-formed, no two signals claim one pin or one hole, and the SW4
369 positions the link depends on are recorded.
370
371 Args:
372 pins: Parsed pins.env assignments.
373
374 Returns:
375 Human-readable findings; empty when the RA8 side is well-formed.
376 """
377 findings: list[str] = []
378 for label, c6_key, pin_key, hole_key in RA8_TRIPLES:
379 missing = [k for k in (c6_key, pin_key, hole_key) if k not in pins]
380 if missing:
381 findings.extend(f"{label}: pins.env is missing {k}" for k in missing)
382 continue
383 findings.extend(_check_one_signal(label, pins[c6_key], pins[pin_key], pins[hole_key]))
384
385 findings.extend(_check_uniqueness(pins))
386
387 for key in SW4_KEYS:
388 if key not in pins:
389 findings.append(f"SW4 positions: pins.env is missing {key}")
390 elif pins[key] not in SW4_VALUES:
391 findings.append(f"SW4 positions: {key}={pins[key]!r} is not one of {SW4_VALUES}")
392 return findings
393
394
395FW_VERSION_KEY = "ESP_HOSTED_MCU_FW_VERSION"
396
397# The three #defines the vendored host driver states its own version with.
398_FW_VER_MACROS: tuple[str, str, str] = (
399 "ESP_HOSTED_VERSION_MAJOR_1",
400 "ESP_HOSTED_VERSION_MINOR_1",
401 "ESP_HOSTED_VERSION_PATCH_1",
402)
403
404
405def parse_host_fw_version(text: str) -> str | None:
406 """Return the vendored host driver's own version as ``major.minor.patch``.
407
408 Args:
409 text: Body of ``host/esp_hosted_host_fw_ver.h`` from the vendor tree.
410
411 Returns:
412 The dotted version, or ``None`` when any of the three macros is absent
413 (which means the vendored header changed shape and this check can no
414 longer speak for it -- reported, never silently skipped).
415 """
416 parts: list[str] = []
417 for macro in _FW_VER_MACROS:
418 match = re.search(rf"^\s*#define\s+{re.escape(macro)}\s+(\d+)\s*$", text, re.MULTILINE)
419 if match is None:
420 return None
421 parts.append(match.group(1))
422 return ".".join(parts)
423
424
425def check_fw_version_lock(pins: dict[str, str], header_text: str) -> list[str]:
426 """Return findings when the co-processor image and the host driver disagree.
427
428 ``pins.env`` names the esp-hosted-mcu firmware the CO-PROCESSOR image is
429 built from; ``esp_hosted_host_fw_ver.h`` states the version of the HOST
430 driver vendored into this tree. Both come from one pinned upstream commit,
431 so they must be identical -- and upstream itself compares them at run time
432 and warns about "RPC timeouts" when they are not.
433
434 This is a gate rather than a convention because the mismatch is otherwise
435 silent in CI and expensive on the bench:
436 ``examples/ek_ra8d2/hw_validated/c6/c6_fw_version`` asserts the
437 co-processor's reported version against these very macros, so bumping the
438 vendor pin without reflashing the C6 turns a hardware test red with no
439 hint, from here, of why.
440
441 Args:
442 pins: Parsed pins.env assignments.
443 header_text: Body of the vendored host firmware-version header.
444
445 Returns:
446 Human-readable findings; empty when the two agree.
447 """
448 findings: list[str] = []
449 host = parse_host_fw_version(header_text)
450 if host is None:
451 findings.append(
452 "firmware version: could not read "
453 f"{', '.join(_FW_VER_MACROS)} out of {HOST_FW_VER.name} "
454 "-- the vendored header changed shape"
455 )
456 return findings
457 if FW_VERSION_KEY not in pins:
458 findings.append(f"firmware version: pins.env is missing {FW_VERSION_KEY}")
459 return findings
460 if pins[FW_VERSION_KEY] != host:
461 findings.append(
462 f"firmware version: pins.env {FW_VERSION_KEY}={pins[FW_VERSION_KEY]} "
463 f"but the vendored host driver is {host} "
464 "-- host and co-processor must come from one upstream commit"
465 )
466 return findings
467
468
469def compare(pins: dict[str, str], sdk: dict[str, str]) -> list[str]:
470 """Return one message per disagreement between the two parsed files.
471
472 Args:
473 pins: Parsed pins.env assignments (the source of truth).
474 sdk: Parsed sdkconfig.defaults assignments (the derived artifact).
475
476 Returns:
477 Human-readable findings; empty when the two agree.
478 """
479 findings: list[str] = []
480
481 for sdk_key, pin_key, label in PIN_PAIRS + VALUE_PAIRS:
482 if pin_key not in pins:
483 findings.append(f"{label}: pins.env is missing {pin_key}")
484 continue
485 if sdk_key not in sdk:
486 findings.append(f"{label}: sdkconfig.defaults is missing {sdk_key}")
487 continue
488 if pins[pin_key] != sdk[sdk_key]:
489 findings.append(
490 f"{label}: pins.env {pin_key}={pins[pin_key]} "
491 f"but sdkconfig.defaults {sdk_key}={sdk[sdk_key]}"
492 )
493
494 size = _flash_size(sdk)
495 if FLASH_SIZE_KEY not in pins:
496 findings.append(f"flash size: pins.env is missing {FLASH_SIZE_KEY}")
497 elif size is None:
498 findings.append(
499 "flash size: sdkconfig.defaults enables no CONFIG_ESPTOOLPY_FLASHSIZE_<n>MB symbol"
500 )
501 elif size != pins[FLASH_SIZE_KEY]:
502 findings.append(
503 f"flash size: pins.env {FLASH_SIZE_KEY}={pins[FLASH_SIZE_KEY]} "
504 f"but sdkconfig.defaults enables CONFIG_ESPTOOLPY_FLASHSIZE_{size}"
505 )
506
507 board = _dev_board(sdk)
508 if DEV_BOARD_KEY not in pins:
509 findings.append(f"dev board: pins.env is missing {DEV_BOARD_KEY}")
510 elif board is None:
511 findings.append(
512 "dev board: sdkconfig.defaults enables no CONFIG_ESP_HOST_DEV_BOARD_<X> "
513 "symbol -- without an explicit selection the pin leaves above may be "
514 "overridden by whatever preset upstream defaults to"
515 )
516 elif board != pins[DEV_BOARD_KEY]:
517 findings.append(
518 f"dev board: pins.env {DEV_BOARD_KEY}={pins[DEV_BOARD_KEY]} "
519 f"but sdkconfig.defaults enables CONFIG_ESP_HOST_DEV_BOARD_{board.upper()} "
520 "-- a dev-board preset OVERRIDES the SPI pin leaves, so every pin "
521 "compared above would be ignored by the build"
522 )
523
524 findings.extend(check_ra8_side(pins))
525
526 try:
527 header = PORT_PIN_HEADER.read_text(encoding="utf-8")
528 except OSError as exc:
529 findings.append(f"port pin header: cannot read {PORT_PIN_HEADER}: {exc}")
530 else:
531 findings.extend(check_port_header(pins, header))
532 return findings
533
534
535# ---------------------------------------------------------------------------
536# Selftest -- the comparator is driven with crafted bodies, so nothing is
537# written into the tree and the real files are never modified.
538# ---------------------------------------------------------------------------
539
540_GOOD_PINS = """
541# comment ignored
542C6_PIN_CS=0
543C6_PIN_COPI=1
544C6_PIN_CIPO=2
545C6_PIN_SCK=3
546C6_PIN_DATA_READY=4
547C6_PIN_HANDSHAKE=6
548C6_PIN_RESET=-1
549RA8_PIN_CS=P804
550RA8_PIN_COPI=P801
551RA8_PIN_CIPO=P802
552RA8_PIN_SCK=P803
553RA8_PIN_DATA_READY=P402
554RA8_PIN_HANDSHAKE=P006
555RA8_PIN_RESET=none
556RA8_J26_CS=J26-1
557RA8_J26_COPI=J26-2
558RA8_J26_CIPO=J26-3
559RA8_J26_SCK=J26-4
560RA8_J26_DATA_READY=J26-8
561RA8_J26_HANDSHAKE=J26-7
562RA8_J26_RESET=none
563RA8_SW4_1=OFF
564RA8_SW4_2=OFF
565RA8_SW4_3=ON
566RA8_SW4_4=OFF
567C6_FLASH_SIZE=16MB
568C6_DEV_BOARD=none
569ESP_TARGET=esp32c6
570"""
571
572_GOOD_SDK = """
573CONFIG_IDF_TARGET="esp32c6"
574CONFIG_ESP_HOST_DEV_BOARD_NONE=y
575CONFIG_ESP_SPI_HSPI_GPIO_CS=0
576CONFIG_ESP_SPI_HSPI_GPIO_MOSI=1
577CONFIG_ESP_SPI_HSPI_GPIO_MISO=2
578CONFIG_ESP_SPI_HSPI_GPIO_CLK=3
579CONFIG_ESP_SPI_GPIO_DATA_READY=4
580CONFIG_ESP_SPI_GPIO_HANDSHAKE=6
581CONFIG_ESP_SPI_GPIO_RESET=-1
582CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
583"""
584
585
586def _selftest_cases_kconfig() -> list[tuple[str, str, str, bool]]:
587 """Return the crafted cases for the pins.env <-> sdkconfig.defaults half.
588
589 Returns:
590 One case per drift shape between the two files, plus the agreeing
591 control that must stay silent. The dev-board preset has its own group
592 (``_selftest_cases_dev_board``).
593 """
594 return [
595 ("agreeing pair", _GOOD_PINS, _GOOD_SDK, False),
596 (
597 "a pin number drifted",
598 _GOOD_PINS,
599 _GOOD_SDK.replace("GPIO_CLK=3", "GPIO_CLK=7"),
600 True,
601 ),
602 (
603 "COPI drifted (legacy upstream symbol)",
604 _GOOD_PINS.replace("C6_PIN_COPI=1", "C6_PIN_COPI=9"),
605 _GOOD_SDK,
606 True,
607 ),
608 (
609 "sdkconfig lost a symbol",
610 _GOOD_PINS,
611 _GOOD_SDK.replace("CONFIG_ESP_SPI_GPIO_HANDSHAKE=6\n", ""),
612 True,
613 ),
614 (
615 "pins.env lost a key",
616 _GOOD_PINS.replace("C6_PIN_DATA_READY=4\n", ""),
617 _GOOD_SDK,
618 True,
619 ),
620 (
621 "flash size drifted",
622 _GOOD_PINS,
623 _GOOD_SDK.replace("FLASHSIZE_16MB=y", "FLASHSIZE_4MB=y"),
624 True,
625 ),
626 (
627 "no flash size enabled at all",
628 _GOOD_PINS,
629 _GOOD_SDK.replace(
630 "CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y", "CONFIG_ESPTOOLPY_FLASHSIZE_16MB=n"
631 ),
632 True,
633 ),
634 (
635 "chip target drifted",
636 _GOOD_PINS,
637 _GOOD_SDK.replace('CONFIG_IDF_TARGET="esp32c6"', 'CONFIG_IDF_TARGET="esp32c3"'),
638 True,
639 ),
640 ]
641
642
643def _selftest_cases_dev_board() -> list[tuple[str, str, str, bool]]:
644 """Return the crafted cases for the dev-board preset.
645
646 Its two drift shapes are distinct and only one of them is obvious. A
647 DIFFERENT board silently overrides every SPI pin leaf, so the build still
648 succeeds and the C6 comes up on the preset's pins; NO board at all leaves
649 the choice to whatever upstream happens to default to. Asserting merely
650 that the symbol is present would miss the first, which is the worse one.
651
652 Returns:
653 One case per drift shape. There is no agreeing control here: the
654 agreeing pair in ``_selftest_cases_kconfig`` already carries a matching
655 ``C6_DEV_BOARD`` / ``CONFIG_ESP_HOST_DEV_BOARD_NONE`` pair, so this
656 rule's quiet direction is proven there rather than restated.
657 """
658 return [
659 (
660 "dev board drifted to another preset",
661 _GOOD_PINS,
662 _GOOD_SDK.replace(
663 "CONFIG_ESP_HOST_DEV_BOARD_NONE=y",
664 "CONFIG_ESP_HOST_DEV_BOARD_ESP32_C6_DEVKITC=y",
665 ),
666 True,
667 ),
668 (
669 "dev board selection turned off",
670 _GOOD_PINS,
671 _GOOD_SDK.replace(
672 "CONFIG_ESP_HOST_DEV_BOARD_NONE=y", "CONFIG_ESP_HOST_DEV_BOARD_NONE=n"
673 ),
674 True,
675 ),
676 (
677 "sdkconfig selects no dev board at all",
678 _GOOD_PINS,
679 _GOOD_SDK.replace("CONFIG_ESP_HOST_DEV_BOARD_NONE=y\n", ""),
680 True,
681 ),
682 (
683 "pins.env stopped declaring the dev board",
684 _GOOD_PINS.replace("C6_DEV_BOARD=none\n", ""),
685 _GOOD_SDK,
686 True,
687 ),
688 ]
689
690
691def _selftest_cases_ra8_signals() -> list[tuple[str, str, str, bool]]:
692 """Return the crafted cases for one signal's RA8-side entry.
693
694 Returns:
695 One case per way a single signal can be recorded wrongly: named at
696 one end only, disconnected on one side but not the other, or given a
697 pin or hole name that is not one.
698 """
699 return [
700 (
701 "RA8 landing pin never recorded",
702 _GOOD_PINS.replace("RA8_PIN_HANDSHAKE=P006\n", ""),
703 _GOOD_SDK,
704 True,
705 ),
706 (
707 "RA8 J26 hole never recorded",
708 _GOOD_PINS.replace("RA8_J26_SCK=J26-4\n", ""),
709 _GOOD_SDK,
710 True,
711 ),
712 (
713 "wired on the C6 side, 'none' on the RA8 side",
714 _GOOD_PINS.replace("RA8_PIN_DATA_READY=P402", "RA8_PIN_DATA_READY=none").replace(
715 "RA8_J26_DATA_READY=J26-8", "RA8_J26_DATA_READY=none"
716 ),
717 _GOOD_SDK,
718 True,
719 ),
720 (
721 "RESET given an RA8 pin while the C6 side stays -1",
722 _GOOD_PINS.replace("RA8_PIN_RESET=none", "RA8_PIN_RESET=P412").replace(
723 "RA8_J26_RESET=none", "RA8_J26_RESET=J26-9"
724 ),
725 _GOOD_SDK,
726 True,
727 ),
728 (
729 "RA8 pin name malformed",
730 _GOOD_PINS.replace("RA8_PIN_CS=P804", "RA8_PIN_CS=P80_4"),
731 _GOOD_SDK,
732 True,
733 ),
734 (
735 "J26 hole malformed",
736 _GOOD_PINS.replace("RA8_J26_CS=J26-1", "RA8_J26_CS=pin1"),
737 _GOOD_SDK,
738 True,
739 ),
740 ]
741
742
743def _selftest_cases_ra8_map() -> list[tuple[str, str, str, bool]]:
744 """Return the crafted cases for whole-map RA8-side defects.
745
746 Returns:
747 One case per defect that only shows up across signals -- two of them
748 claiming one pin or one hole after a copy-paste -- plus the SW4 bank
749 the link depends on.
750 """
751 return [
752 (
753 "two signals claim one RA8 pin",
754 _GOOD_PINS.replace("RA8_PIN_COPI=P801", "RA8_PIN_COPI=P804"),
755 _GOOD_SDK,
756 True,
757 ),
758 (
759 "two signals claim one J26 hole",
760 _GOOD_PINS.replace("RA8_J26_CIPO=J26-3", "RA8_J26_CIPO=J26-2"),
761 _GOOD_SDK,
762 True,
763 ),
764 (
765 "SW4 position never recorded",
766 _GOOD_PINS.replace("RA8_SW4_3=ON\n", ""),
767 _GOOD_SDK,
768 True,
769 ),
770 (
771 "SW4 position is not ON or OFF",
772 _GOOD_PINS.replace("RA8_SW4_4=OFF", "RA8_SW4_4=maybe"),
773 _GOOD_SDK,
774 True,
775 ),
776 ]
777
778
779_GOOD_PORT_HEADER = """
780 k_ra8_esp_hosted_pin_chip_select = (uint16_t)k_ra8_board_pmod1_spi_cs,
781 k_ra8_esp_hosted_pin_copi = (uint16_t)k_ra8_board_pmod1_spi_copi,
782 k_ra8_esp_hosted_pin_cipo = (uint16_t)k_ra8_board_pmod1_spi_cipo,
783 k_ra8_esp_hosted_pin_sck = (uint16_t)k_ra8_board_pmod1_spi_sck,
784 k_ra8_esp_hosted_pin_handshake = (uint16_t)k_ra8_board_pmod1_irq,
785 k_ra8_esp_hosted_pin_data_ready = (uint16_t)k_ra8_board_pmod1_reset,
786 k_ra8_esp_hosted_pin_reset = (uint16_t)k_ra8_pin_none,
787"""
788"""A port pin map that agrees with ``_GOOD_PINS``."""
789
790
791def _selftest_cases_port_header() -> list[tuple[str, str, str, bool]]:
792 """Return crafted cases for the port-header half of the comparator.
793
794 The port header is a second statement of the RA8-side map, so the cases
795 here are the ways two copies drift: a swapped pair (the drift that really
796 happened), a signal the header does not define, and a board symbol the
797 gate cannot resolve.
798
799 Returns:
800 ``(label, header_text, expect_findings)`` triples, widened to the
801 four-tuple shape the shared driver consumes.
802 """
803 swapped = _GOOD_PORT_HEADER.replace(
804 "k_ra8_esp_hosted_pin_handshake = (uint16_t)k_ra8_board_pmod1_irq",
805 "k_ra8_esp_hosted_pin_handshake = (uint16_t)k_ra8_board_pmod1_reset",
806 )
807 return [
808 ("port header agrees", _GOOD_PORT_HEADER, "", False),
809 ("port header swaps HANDSHAKE onto the DATA_READY pin", swapped, "", True),
810 (
811 "port header omits a signal",
812 _GOOD_PORT_HEADER.replace(
813 " k_ra8_esp_hosted_pin_sck = (uint16_t)k_ra8_board_pmod1_spi_sck,\n", ""
814 ),
815 "",
816 True,
817 ),
818 (
819 "port header names an unresolvable board symbol",
820 _GOOD_PORT_HEADER.replace("k_ra8_board_pmod1_spi_cs", "k_ra8_board_pmod9_spi_cs"),
821 "",
822 True,
823 ),
824 ]
825
826
827_GOOD_FW_VER_HEADER = """
828#define ESP_HOSTED_VERSION_MAJOR_1 2
829#define ESP_HOSTED_VERSION_MINOR_1 12
830#define ESP_HOSTED_VERSION_PATCH_1 11
831"""
832"""A vendored host firmware-version header stating 2.12.11."""
833
834
835def _selftest_cases_fw_version() -> list[tuple[str, str, bool]]:
836 """Return crafted cases for the host / co-processor version lock.
837
838 The ways two statements of one version drift: a vendor bump with no
839 co-processor reflash, a co-processor reflash with no vendor bump, a
840 pins.env that stopped declaring the version at all, and a vendored header
841 whose macros were renamed out from under the parser.
842
843 Returns:
844 ``(label, header_text, expect_findings)`` triples. The pins.env side is
845 held at 2.12.11 except where the label says otherwise.
846 """
847 bumped = _GOOD_FW_VER_HEADER.replace("MINOR_1 12", "MINOR_1 13")
848 renamed = _GOOD_FW_VER_HEADER.replace("ESP_HOSTED_VERSION_PATCH_1", "ESP_HOSTED_VER_PATCH")
849 return [
850 ("firmware version agrees", _GOOD_FW_VER_HEADER, False),
851 ("vendored host bumped without a co-processor reflash", bumped, True),
852 ("vendored header macros renamed", renamed, True),
853 ]
854
855
856def _selftest_fw_version_failures() -> list[str]:
857 """Return one message per version-lock selftest case that misbehaved."""
858 # Built here rather than by widening the shared _GOOD_PINS fixture, which
859 # exists to exercise the PIN comparator: a version key bolted onto it would
860 # couple two independent halves of this gate for no benefit.
861 good = parse_assignments(_GOOD_PINS)
862 good[FW_VERSION_KEY] = "2.12.11"
863 out: list[str] = []
864 for label, header, expect in _selftest_cases_fw_version():
865 findings = check_fw_version_lock(good, header)
866 if bool(findings) != expect:
867 verb = "reported nothing" if expect else f"reported {findings}"
868 out.append(f" {label}: {verb}")
869 # The other direction: the co-processor reflashed past the vendored host.
870 stale = dict(good)
871 stale[FW_VERSION_KEY] = "2.13.0"
872 if not check_fw_version_lock(stale, _GOOD_FW_VER_HEADER):
873 out.append(" co-processor reflashed past the vendored host: reported nothing")
874 missing = {k: v for k, v in good.items() if k != FW_VERSION_KEY}
875 if not check_fw_version_lock(missing, _GOOD_FW_VER_HEADER):
876 out.append(f" pins.env missing {FW_VERSION_KEY}: reported nothing")
877 return out
878
879
880def _selftest_cases() -> list[tuple[str, str, str, bool]]:
881 """Return every crafted case, across both halves of the comparator.
882
883 Returns:
884 The concatenation of the four case groups.
885 """
886 return (
887 _selftest_cases_kconfig()
888 + _selftest_cases_dev_board()
889 + _selftest_cases_ra8_signals()
890 + _selftest_cases_ra8_map()
891 )
892
893
894def _selftest_port_failures() -> list[str]:
895 """Return one message per port-header selftest case that misbehaved."""
896 good = parse_assignments(_GOOD_PINS)
897 out: list[str] = []
898 for label, header, _unused, expect in _selftest_cases_port_header():
899 findings = check_port_header(good, header)
900 if bool(findings) != expect:
901 verb = "reported nothing" if expect else f"reported {findings}"
902 out.append(f" {label}: {verb}")
903 return out
904
905
906def selftest() -> int:
907 """Prove the comparator reports drift and stays silent on agreement.
908
909 Returns:
910 EXIT_OK when every crafted case yields the expected verdict, else
911 EXIT_FAIL.
912 """
913 failures: list[str] = []
914 for label, pins_text, sdk_text, expect in _selftest_cases():
915 findings = compare(parse_assignments(pins_text), parse_assignments(sdk_text))
916 if bool(findings) != expect:
917 verb = "reported nothing" if expect else f"reported {findings}"
918 failures.append(f" {label}: {verb}")
919 failures.extend(_selftest_port_failures())
920 failures.extend(_selftest_fw_version_failures())
921
922 if failures:
923 sys.stderr.write("check_c6_pin_config.py --selftest: FAILED\n\n")
924 sys.stderr.write("\n".join(failures) + "\n")
925 sys.stderr.write("\nThe comparator does not detect drift as claimed.\n")
926 return EXIT_FAIL
927
928 cases = _selftest_cases() + _selftest_cases_port_header()
929 cases += [(a, b, "", c) for a, b, c in _selftest_cases_fw_version()]
930 fires = sum(1 for c in cases if c[3])
931 print(
932 f"check_c6_pin_config.py --selftest: OK "
933 f"({len(cases)} cases: {fires} must fire, {len(cases) - fires} must stay quiet)."
934 )
935 return EXIT_OK
936
937
938def main(argv: list[str]) -> int:
939 """Compare the committed pin map against the committed Kconfig defaults.
940
941 Returns:
942 EXIT_OK when they agree, EXIT_FAIL on drift or a failing selftest,
943 EXIT_CONFIG when either file is missing.
944 """
945 if "--selftest" in argv[1:]:
946 return selftest()
947
948 for path in (PINS_ENV, SDKCONFIG, HOST_FW_VER):
949 if not path.is_file():
950 sys.stderr.write(f"check_c6_pin_config.py: FATAL -- missing {path}\n")
951 return EXIT_CONFIG
952
953 pins = parse_assignments(PINS_ENV.read_text(encoding="utf-8"))
954 sdk = parse_assignments(SDKCONFIG.read_text(encoding="utf-8"))
955 findings = compare(pins, sdk)
956 findings.extend(check_fw_version_lock(pins, HOST_FW_VER.read_text(encoding="utf-8")))
957
958 if not findings:
959 # The three settings compared outside the two PAIR tables: the flash
960 # size, the dev-board preset (both encoded in a symbol NAME) and the
961 # host / co-processor firmware version lock.
962 key_encoded_and_version = 3
963 checked = len(PIN_PAIRS) + len(VALUE_PAIRS) + key_encoded_and_version
964 ra8 = (len(RA8_TRIPLES) * 2) + len(SW4_KEYS)
965 print(
966 f"check_c6_pin_config.py: pins.env and sdkconfig.defaults agree "
967 f"({checked} settings); dev board "
968 f"{pins.get(DEV_BOARD_KEY, '?')} (no preset overriding our pins); "
969 f"RA8-side map well-formed ({ra8} entries); "
970 f"co-processor firmware {pins.get(FW_VERSION_KEY, '?')} matches the "
971 f"vendored host driver."
972 )
973 return EXIT_OK
974
975 sys.stderr.write(f"check_c6_pin_config.py: {len(findings)} C6 config drift(s):\n")
976 for message in findings:
977 sys.stderr.write(f" {message}\n")
978 sys.stderr.write(
979 "\ncoprocessor/esp32c6/pins.env is the source of truth. Update\n"
980 "sdkconfig.defaults to match it (and reflash the C6 -- a stale image on\n"
981 "the board still carries the old pins).\n"
982 )
983 return EXIT_FAIL
984
985
986if __name__ == "__main__":
987 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298