Skip to content

Commit f0431c1

Browse files
committed
Add BRS-100-GW1NR9 hardware integration test suite
Mirrors examples/arty_a7/test_hw_integration.py but over OpenOCD for the Gowin board, scoped to the BRS reference design (8b/64/6ch ELA + shared- chain EIO at chain 1/0x8000). 11 tests: - ELA: identity, basic value-match capture, channel-0 counter +1/sample, trigger value captured, PRETRIG_LEN register roundtrip, JSON/VCD export. - EIO: identity/widths, auto-discovery, LED output roundtrip, button reads. Opt-in and board-attached: skips when OpenOCD is unreachable, the fcapz design isn't loaded, or FPGACAP_SKIP_HW is set. Hardware-validated on a BRS-100-GW1NR9 (all 11 pass). README gains a 'Hardware tests' section.
1 parent 8d0ef5b commit f0431c1

2 files changed

Lines changed: 268 additions & 1 deletion

File tree

examples/brs_100_gw1nr9/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,29 @@ print(eio.read_inputs()) # buttons (probe_in[1:0])
168168
eio.write_outputs(0x3F) # all six LEDs on
169169
```
170170

171+
## Hardware tests
172+
173+
`test_hw_integration.py` is an opt-in regression that drives the board over
174+
OpenOCD. Build + load the bitstream, start OpenOCD, then:
175+
176+
```sh
177+
openocd -f examples/brs_100_gw1nr9/brs_100_gw1nr9.cfg &
178+
python -m pytest examples/brs_100_gw1nr9/test_hw_integration.py -v
179+
```
180+
181+
It covers the ELA (identity, a counter capture with `+1` per-sample checks,
182+
trigger match, register roundtrip, JSON/VCD export) and the shared-chain EIO
183+
(identity/widths, auto-discovery, LED output roundtrip, button reads). Without a
184+
board the tests **skip** (OpenOCD unreachable or no fcapz design loaded); set
185+
`FPGACAP_SKIP_HW=1` to skip unconditionally. Override the port/tap with
186+
`FPGACAP_OPENOCD_PORT` / `FPGACAP_OPENOCD_TAP`.
187+
171188
## Notes
172189

173190
- This example uses Gowin register-path ELA readback, not Xilinx-style burst
174191
readback.
175192
- OpenOCD must already be running; the fpgacapZero OpenOCD transport does not
176193
program the FPGA.
177-
- The example does not include hardware regression tests yet.
178194

179195
## More Detail
180196

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2026 Leonardo Capossio - bard0 design - <hello@bard0.com>
3+
4+
"""
5+
Integration tests for fpgacapZero on the Brisbane Silicon BRS-100-GW1NR9
6+
(Gowin GW1NR-9C) board, driven over OpenOCD.
7+
8+
Unlike the Arty hw_server flow, the OpenOCD transport does **not** program the
9+
FPGA, so before running you must have the board configured and OpenOCD up:
10+
11+
1. Build and load the bitstream (SRAM is fine; it is volatile):
12+
python examples/brs_100_gw1nr9/build.py
13+
# then program out/fcapz_brs_100_gw1nr9.fs with your Gowin flow
14+
2. Start OpenOCD with the checked-in board config:
15+
openocd -f examples/brs_100_gw1nr9/brs_100_gw1nr9.cfg
16+
17+
The reference design instantiates an 8-bit / 64-deep / 6-channel ELA and a
18+
shared-chain EIO (2 inputs = user buttons, 6 outputs = LEDs) muxed onto the ELA
19+
chain at offset 0x8000. Channel 0 of the ELA is a free-running 8-bit counter.
20+
21+
Environment variables
22+
---------------------
23+
FPGACAP_SKIP_HW=1 Skip all hardware tests (CI default).
24+
FPGACAP_OPENOCD_PORT=<n> OpenOCD TCL port. Defaults to 6666.
25+
FPGACAP_OPENOCD_TAP=<tap> TAP name. Defaults to ``GW1NR-9C.tap``; ``auto``
26+
also works (resolves via ``jtag names``).
27+
28+
Run:
29+
openocd -f examples/brs_100_gw1nr9/brs_100_gw1nr9.cfg &
30+
python -m pytest examples/brs_100_gw1nr9/test_hw_integration.py -v
31+
32+
Skip if no hardware:
33+
FPGACAP_SKIP_HW=1 python -m pytest examples/brs_100_gw1nr9/test_hw_integration.py -v
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import json
39+
import os
40+
import tempfile
41+
import unittest
42+
from pathlib import Path
43+
44+
_SKIP = os.environ.get("FPGACAP_SKIP_HW", "")
45+
_PORT = int(os.environ.get("FPGACAP_OPENOCD_PORT", "6666"))
46+
_TAP = os.environ.get("FPGACAP_OPENOCD_TAP", "GW1NR-9C.tap")
47+
48+
# Shape of the BRS-100-GW1NR9 reference design.
49+
SAMPLE_W = 8
50+
DEPTH = 64
51+
NUM_CHANNELS = 6
52+
COUNTER_CHANNEL = 0 # free-running 8-bit counter
53+
EIO_CHAIN = 1 # EIO shares the ELA chain (ER1)
54+
EIO_BASE = 0x8000 # register-bus mux offset for the shared-chain EIO
55+
EIO_IN_W = 2 # user buttons
56+
EIO_OUT_W = 6 # board LEDs
57+
58+
59+
def _make_transport():
60+
from fcapz.transport import OpenOcdTransport
61+
62+
return OpenOcdTransport(
63+
host="127.0.0.1",
64+
port=_PORT,
65+
tap=_TAP,
66+
ir_table=OpenOcdTransport.IR_TABLE_GOWIN,
67+
)
68+
69+
70+
def _connect_analyzer_or_skip():
71+
"""Return a connected, identity-verified Analyzer, or raise SkipTest.
72+
73+
Skips (rather than fails) when OpenOCD is not running or the board is not
74+
configured with the fcapz design, so the suite is a no-op without hardware.
75+
"""
76+
from fcapz.analyzer import Analyzer
77+
78+
a = Analyzer(_make_transport(), chain=1)
79+
try:
80+
a.connect()
81+
a.probe() # raises RuntimeError if the ELA identity magic is wrong
82+
except OSError as exc:
83+
a.close()
84+
raise unittest.SkipTest(
85+
f"OpenOCD not reachable on port {_PORT} ({exc}); "
86+
f"start: openocd -f examples/brs_100_gw1nr9/brs_100_gw1nr9.cfg"
87+
)
88+
except RuntimeError as exc:
89+
a.close()
90+
raise unittest.SkipTest(
91+
f"fcapz ELA not found on the board ({exc}); "
92+
f"load out/fcapz_brs_100_gw1nr9.fs first"
93+
)
94+
return a
95+
96+
97+
@unittest.skipIf(_SKIP, "FPGACAP_SKIP_HW is set")
98+
class TestProbe(unittest.TestCase):
99+
"""Basic connectivity: read ELA identity registers."""
100+
101+
def test_probe_returns_valid_identity(self):
102+
from fcapz import _version_tuple
103+
from fcapz.analyzer import ELA_CORE_ID
104+
105+
a = _connect_analyzer_or_skip()
106+
try:
107+
info = a.probe()
108+
major, minor, _patch = _version_tuple()
109+
self.assertEqual(info["version_major"], major)
110+
self.assertEqual(info["version_minor"], minor)
111+
self.assertEqual(info["core_id"], ELA_CORE_ID)
112+
self.assertEqual(info["sample_width"], SAMPLE_W)
113+
self.assertEqual(info["depth"], DEPTH)
114+
self.assertEqual(info["num_channels"], NUM_CHANNELS)
115+
finally:
116+
a.close()
117+
118+
119+
@unittest.skipIf(_SKIP, "FPGACAP_SKIP_HW is set")
120+
class TestCapture(unittest.TestCase):
121+
"""End-to-end ELA capture over the Gowin register-path readback."""
122+
123+
def setUp(self):
124+
self.a = _connect_analyzer_or_skip()
125+
self.t = self.a.transport
126+
127+
def tearDown(self):
128+
self.a.close()
129+
130+
def _capture(self, pretrig, posttrig, channel=COUNTER_CHANNEL,
131+
trig_val=0, trig_mask=0xFF, mode="value_match"):
132+
from fcapz.analyzer import CaptureConfig, TriggerConfig
133+
134+
cfg = CaptureConfig(
135+
pretrigger=pretrig,
136+
posttrigger=posttrig,
137+
trigger=TriggerConfig(mode=mode, value=trig_val, mask=trig_mask),
138+
sample_width=SAMPLE_W,
139+
depth=DEPTH,
140+
channel=channel,
141+
)
142+
self.a.configure(cfg)
143+
self.a.arm()
144+
return self.a.capture(timeout=5.0)
145+
146+
def test_basic_capture_value_match(self):
147+
"""Trigger on value=0, capture 4 + 1 + 8 samples."""
148+
result = self._capture(pretrig=4, posttrig=8)
149+
self.assertEqual(len(result.samples), 4 + 1 + 8)
150+
self.assertFalse(result.overflow)
151+
152+
def test_counter_channel_increments(self):
153+
"""Channel 0 is a free-running 8-bit counter; adjacent samples differ by +1."""
154+
result = self._capture(pretrig=4, posttrig=16, channel=COUNTER_CHANNEL)
155+
samples = [s & 0xFF for s in result.samples]
156+
errors = [
157+
(i - 1, samples[i - 1], samples[i])
158+
for i in range(1, len(samples))
159+
if ((samples[i] - samples[i - 1]) & 0xFF) != 1
160+
]
161+
self.assertEqual(errors, [], f"counter errors in samples={samples}")
162+
163+
def test_trigger_value_is_captured(self):
164+
"""value_match on 0x20 — the committed window contains 0x20."""
165+
result = self._capture(pretrig=4, posttrig=8, trig_val=0x20, trig_mask=0xFF)
166+
self.assertIn(0x20, [s & 0xFF for s in result.samples])
167+
168+
def test_register_roundtrip(self):
169+
"""PRETRIG_LEN read/writes back on silicon."""
170+
self.t.write_reg(0x0014, 7)
171+
self.assertEqual(self.t.read_reg(0x0014) & 0xFFFF, 7)
172+
self.t.write_reg(0x0014, 0)
173+
174+
def test_json_export(self):
175+
result = self._capture(pretrig=4, posttrig=8)
176+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
177+
path = f.name
178+
try:
179+
self.a.write_json(result, path)
180+
obj = json.loads(Path(path).read_text())
181+
self.assertEqual(obj["sample_width"], SAMPLE_W)
182+
self.assertEqual(len(obj["samples"]), len(result.samples))
183+
finally:
184+
Path(path).unlink(missing_ok=True)
185+
186+
def test_vcd_export(self):
187+
result = self._capture(pretrig=4, posttrig=8)
188+
with tempfile.NamedTemporaryFile(suffix=".vcd", delete=False) as f:
189+
path = f.name
190+
try:
191+
self.a.write_vcd(result, path)
192+
self.assertIn("$enddefinitions", Path(path).read_text())
193+
finally:
194+
Path(path).unlink(missing_ok=True)
195+
196+
197+
@unittest.skipIf(_SKIP, "FPGACAP_SKIP_HW is set")
198+
class TestEio(unittest.TestCase):
199+
"""EIO on the shared Gowin chain (chain 1, mux offset 0x8000)."""
200+
201+
def setUp(self):
202+
from fcapz.eio import EioController
203+
204+
self.a = _connect_analyzer_or_skip()
205+
self.t = self.a.transport
206+
self.eio = EioController(self.t, chain=EIO_CHAIN, base_addr=EIO_BASE)
207+
try:
208+
self.eio.attach()
209+
except RuntimeError as exc:
210+
self.a.close()
211+
raise unittest.SkipTest(
212+
f"shared-chain EIO not found at chain {EIO_CHAIN}/0x{EIO_BASE:04X} "
213+
f"({exc}); is the EIO-enabled bitstream loaded?"
214+
)
215+
216+
def tearDown(self):
217+
self.eio.write_outputs(0) # leave the LEDs off
218+
self.a.close()
219+
220+
def test_identity_and_widths(self):
221+
from fcapz.eio import EIO_CORE_ID
222+
223+
self.assertEqual(self.eio.core_id, EIO_CORE_ID)
224+
self.assertEqual(self.eio.in_w, EIO_IN_W)
225+
self.assertEqual(self.eio.out_w, EIO_OUT_W)
226+
227+
def test_discovery_finds_shared_chain(self):
228+
"""discover_eio locates the EIO without being told the chain/offset."""
229+
from fcapz.eio import discover_eio
230+
231+
found = discover_eio(self.t, chains=(1, 2))
232+
self.assertIsNotNone(found)
233+
self.assertEqual(found.bscan_chain, EIO_CHAIN)
234+
self.assertEqual(found._base_addr, EIO_BASE) # noqa: SLF001 - asserting discovered location
235+
236+
def test_output_roundtrip(self):
237+
"""Driving probe_out (the LEDs) reads back exactly."""
238+
for pat in (0x00, 0x3F, 0x15, 0x2A, 0x01, 0x20):
239+
self.eio.write_outputs(pat)
240+
self.assertEqual(
241+
self.eio.read_outputs(), pat, f"LED readback mismatch for 0x{pat:02X}"
242+
)
243+
244+
def test_read_inputs_in_range(self):
245+
"""probe_in (the 2 buttons) reads without error and within IN_W bits."""
246+
value = self.eio.read_inputs()
247+
self.assertEqual(value, value & ((1 << EIO_IN_W) - 1))
248+
249+
250+
if __name__ == "__main__":
251+
unittest.main()

0 commit comments

Comments
 (0)