Skip to content

Commit ce3b4d9

Browse files
authored
Split TC_MCORE_FS_1_3 to align with the Test Spec (project-chip#35274)
* [TC_MCORE_FS_1_3] Fix test script according to test plan update * Separate storage for all used components * Open commissioning window on TH_FSA_BRIDGE * Python wrapper for running fabric-admin and fabric-bridge together * Customize fabric-admin and fabric-bridge RPC ports * Create storage directory * Use fabric-sync-app in the TC-MCORE-FS-1.3 script * Use CommissionerControlCluster to commission TH_SERVER onto DUT * Auto-link bridge with admin * Test automation setup * Terminate apps on SIGTERM and SIGINT * Open commissioning window on fabric-bridge after adding to FSA * Commissioning TH_FSA_BRIDGE to DUT_FSA fabric * Synchronize server from TH to DUT * Start another instance of app server * Test if unique ID was synced * Allow customization for fabric-sync app components * Final cleanup * Split test case into two test cases * Simplify TC_MCORE_FS_1_3 script * Simplify TC_MCORE_FS_1_4 steps * Use volatile storage for fabric-sync-app by default * Add TC_MCORE_FS_1_4 to exceptions * Get rid of defaults * Document used options in open commissioning window * Speed up the pipe read busy loop * Refactor local output processing * Improve wait for output * Add FS-sync tests to CI * Improve Python code style * Fix wait for fabric-sync-app start * Fix asyncio forwarder * Fixes for review comments
1 parent 50cfda6 commit ce3b4d9

File tree

5 files changed

+981
-83
lines changed

5 files changed

+981
-83
lines changed

.github/workflows/tests.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,9 @@ jobs:
486486
--target linux-x64-microwave-oven-ipv6only-no-ble-no-wifi-tsan-clang-test \
487487
--target linux-x64-rvc-ipv6only-no-ble-no-wifi-tsan-clang-test \
488488
--target linux-x64-network-manager-ipv6only-no-ble-no-wifi-tsan-clang-test \
489+
--target linux-x64-fabric-admin-rpc-ipv6only-clang \
490+
--target linux-x64-fabric-bridge-rpc-ipv6only-no-ble-no-wifi-clang \
491+
--target linux-x64-light-data-model-no-unique-id-ipv6only-no-ble-no-wifi-clang \
489492
--target linux-x64-python-bindings \
490493
build \
491494
--copy-artifacts-to objdir-clone \
@@ -500,6 +503,9 @@ jobs:
500503
echo "CHIP_MICROWAVE_OVEN_APP: out/linux-x64-microwave-oven-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-microwave-oven-app" >> /tmp/test_env.yaml
501504
echo "CHIP_RVC_APP: out/linux-x64-rvc-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-rvc-app" >> /tmp/test_env.yaml
502505
echo "NETWORK_MANAGEMENT_APP: out/linux-x64-network-manager-ipv6only-no-ble-no-wifi-tsan-clang-test/matter-network-manager-app" >> /tmp/test_env.yaml
506+
echo "FABRIC_ADMIN_APP: out/linux-x64-fabric-admin-rpc-ipv6only-clang/fabric-admin" >> /tmp/test_env.yaml
507+
echo "FABRIC_BRIDGE_APP: out/linux-x64-fabric-bridge-rpc-ipv6only-no-ble-no-wifi-clang/fabric-bridge-app" >> /tmp/test_env.yaml
508+
echo "LIGHTING_APP_NO_UNIQUE_ID: out/linux-x64-light-data-model-no-unique-id-ipv6only-no-ble-no-wifi-clang/chip-lighting-app" >> /tmp/test_env.yaml
503509
echo "TRACE_APP: out/trace_data/app-{SCRIPT_BASE_NAME}" >> /tmp/test_env.yaml
504510
echo "TRACE_TEST_JSON: out/trace_data/test-{SCRIPT_BASE_NAME}" >> /tmp/test_env.yaml
505511
echo "TRACE_TEST_PERFETTO: out/trace_data/test-{SCRIPT_BASE_NAME}" >> /tmp/test_env.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright (c) 2024 Project CHIP Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import asyncio
18+
import contextlib
19+
import os
20+
import signal
21+
import sys
22+
from argparse import ArgumentParser
23+
from tempfile import TemporaryDirectory
24+
25+
26+
async def asyncio_stdin() -> asyncio.StreamReader:
27+
"""Wrap sys.stdin in an asyncio StreamReader."""
28+
loop = asyncio.get_event_loop()
29+
reader = asyncio.StreamReader()
30+
protocol = asyncio.StreamReaderProtocol(reader)
31+
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
32+
return reader
33+
34+
35+
async def asyncio_stdout(file=sys.stdout) -> asyncio.StreamWriter:
36+
"""Wrap an IO stream in an asyncio StreamWriter."""
37+
loop = asyncio.get_event_loop()
38+
transport, protocol = await loop.connect_write_pipe(
39+
lambda: asyncio.streams.FlowControlMixin(loop=loop),
40+
os.fdopen(file.fileno(), 'wb'))
41+
return asyncio.streams.StreamWriter(transport, protocol, None, loop)
42+
43+
44+
async def forward_f(prefix: bytes, f_in: asyncio.StreamReader,
45+
f_out: asyncio.StreamWriter, cb=None):
46+
"""Forward f_in to f_out with a prefix attached.
47+
48+
This function can optionally feed received lines to a callback function.
49+
"""
50+
while True:
51+
line = await f_in.readline()
52+
if not line:
53+
break
54+
if cb is not None:
55+
cb(line)
56+
f_out.write(prefix)
57+
f_out.write(line)
58+
await f_out.drain()
59+
60+
61+
async def forward_pipe(pipe_path: str, f_out: asyncio.StreamWriter):
62+
"""Forward named pipe to f_out.
63+
64+
Unfortunately, Python does not support async file I/O on named pipes. This
65+
function performs busy waiting with a short asyncio-friendly sleep to read
66+
from the pipe.
67+
"""
68+
fd = os.open(pipe_path, os.O_RDONLY | os.O_NONBLOCK)
69+
while True:
70+
try:
71+
data = os.read(fd, 1024)
72+
if data:
73+
f_out.write(data)
74+
if not data:
75+
await asyncio.sleep(0.1)
76+
except BlockingIOError:
77+
await asyncio.sleep(0.1)
78+
79+
80+
async def forward_stdin(f_out: asyncio.StreamWriter):
81+
"""Forward stdin to f_out."""
82+
reader = await asyncio_stdin()
83+
while True:
84+
line = await reader.readline()
85+
if not line:
86+
# Exit on Ctrl-D (EOF).
87+
sys.exit(0)
88+
f_out.write(line)
89+
90+
91+
class Subprocess:
92+
93+
def __init__(self, tag: str, program: str, *args, stdout_cb=None):
94+
self.event = asyncio.Event()
95+
self.tag = tag.encode()
96+
self.program = program
97+
self.args = args
98+
self.stdout_cb = stdout_cb
99+
self.expected_output = None
100+
101+
def _check_output(self, line: bytes):
102+
if self.expected_output is not None and self.expected_output in line:
103+
self.event.set()
104+
105+
async def run(self):
106+
self.p = await asyncio.create_subprocess_exec(self.program, *self.args,
107+
stdin=asyncio.subprocess.PIPE,
108+
stdout=asyncio.subprocess.PIPE,
109+
stderr=asyncio.subprocess.PIPE)
110+
# Add the stdout and stderr processing to the event loop.
111+
asyncio.create_task(forward_f(
112+
self.tag,
113+
self.p.stderr,
114+
await asyncio_stdout(sys.stderr)))
115+
asyncio.create_task(forward_f(
116+
self.tag,
117+
self.p.stdout,
118+
await asyncio_stdout(sys.stdout),
119+
cb=self._check_output))
120+
121+
async def send(self, message: str, expected_output: str = None, timeout: float = None):
122+
"""Send a message to a process and optionally wait for a response."""
123+
124+
if expected_output is not None:
125+
self.expected_output = expected_output.encode()
126+
self.event.clear()
127+
128+
self.p.stdin.write((message + "\n").encode())
129+
await self.p.stdin.drain()
130+
131+
if expected_output is not None:
132+
await asyncio.wait_for(self.event.wait(), timeout=timeout)
133+
self.expected_output = None
134+
135+
async def wait(self):
136+
await self.p.wait()
137+
138+
def terminate(self):
139+
self.p.terminate()
140+
141+
142+
async def run_admin(program, stdout_cb=None, storage_dir=None,
143+
rpc_admin_port=None, rpc_bridge_port=None,
144+
paa_trust_store_path=None, commissioner_name=None,
145+
commissioner_node_id=None, commissioner_vendor_id=None):
146+
args = []
147+
if storage_dir is not None:
148+
args.extend(["--storage-directory", storage_dir])
149+
if rpc_admin_port is not None:
150+
args.extend(["--local-server-port", str(rpc_admin_port)])
151+
if rpc_bridge_port is not None:
152+
args.extend(["--fabric-bridge-server-port", str(rpc_bridge_port)])
153+
if paa_trust_store_path is not None:
154+
args.extend(["--paa-trust-store-path", paa_trust_store_path])
155+
if commissioner_name is not None:
156+
args.extend(["--commissioner-name", commissioner_name])
157+
if commissioner_node_id is not None:
158+
args.extend(["--commissioner-nodeid", str(commissioner_node_id)])
159+
if commissioner_vendor_id is not None:
160+
args.extend(["--commissioner-vendor-id", str(commissioner_vendor_id)])
161+
p = Subprocess("[FS-ADMIN]", program, "interactive", "start", *args,
162+
stdout_cb=stdout_cb)
163+
await p.run()
164+
return p
165+
166+
167+
async def run_bridge(program, storage_dir=None, rpc_admin_port=None,
168+
rpc_bridge_port=None, discriminator=None, passcode=None,
169+
secured_device_port=None):
170+
args = []
171+
if storage_dir is not None:
172+
args.extend(["--KVS",
173+
os.path.join(storage_dir, "chip_fabric_bridge_kvs")])
174+
if rpc_admin_port is not None:
175+
args.extend(["--fabric-admin-server-port", str(rpc_admin_port)])
176+
if rpc_bridge_port is not None:
177+
args.extend(["--local-server-port", str(rpc_bridge_port)])
178+
if discriminator is not None:
179+
args.extend(["--discriminator", str(discriminator)])
180+
if passcode is not None:
181+
args.extend(["--passcode", str(passcode)])
182+
if secured_device_port is not None:
183+
args.extend(["--secured-device-port", str(secured_device_port)])
184+
p = Subprocess("[FS-BRIDGE]", program, *args)
185+
await p.run()
186+
return p
187+
188+
189+
async def main(args):
190+
191+
# Node ID of the bridge on the fabric.
192+
bridge_node_id = 1
193+
194+
if args.commissioner_node_id == bridge_node_id:
195+
raise ValueError(f"NodeID={bridge_node_id} is reserved for the local fabric-bridge")
196+
197+
storage_dir = args.storage_dir
198+
if storage_dir is not None:
199+
os.makedirs(storage_dir, exist_ok=True)
200+
else:
201+
storage = TemporaryDirectory(prefix="fabric-sync-app")
202+
storage_dir = storage.name
203+
204+
pipe = args.stdin_pipe
205+
if pipe and not os.path.exists(pipe):
206+
os.mkfifo(pipe)
207+
208+
def terminate(signum, frame):
209+
admin.terminate()
210+
bridge.terminate()
211+
sys.exit(0)
212+
213+
signal.signal(signal.SIGINT, terminate)
214+
signal.signal(signal.SIGTERM, terminate)
215+
216+
admin, bridge = await asyncio.gather(
217+
run_admin(
218+
args.app_admin,
219+
storage_dir=storage_dir,
220+
rpc_admin_port=args.app_admin_rpc_port,
221+
rpc_bridge_port=args.app_bridge_rpc_port,
222+
paa_trust_store_path=args.paa_trust_store_path,
223+
commissioner_name=args.commissioner_name,
224+
commissioner_node_id=args.commissioner_node_id,
225+
commissioner_vendor_id=args.commissioner_vendor_id,
226+
),
227+
run_bridge(
228+
args.app_bridge,
229+
storage_dir=storage_dir,
230+
rpc_admin_port=args.app_admin_rpc_port,
231+
rpc_bridge_port=args.app_bridge_rpc_port,
232+
secured_device_port=args.secured_device_port,
233+
discriminator=args.discriminator,
234+
passcode=args.passcode,
235+
))
236+
237+
# Wait a bit for apps to start.
238+
await asyncio.sleep(1)
239+
240+
try:
241+
# Check whether the bridge is already commissioned. If it is,
242+
# we will get the response, otherwise we will hit timeout.
243+
await admin.send(
244+
f"descriptor read device-type-list {bridge_node_id} 1 --timeout 1",
245+
# Log message which should appear in the fabric-admin output if
246+
# the bridge is already commissioned.
247+
expected_output="Reading attribute: Cluster=0x0000_001D Endpoint=0x1 AttributeId=0x0000_0000",
248+
timeout=1.5)
249+
except asyncio.TimeoutError:
250+
# Commission the bridge to the admin.
251+
cmd = f"fabricsync add-local-bridge {bridge_node_id}"
252+
if args.passcode is not None:
253+
cmd += f" --setup-pin-code {args.passcode}"
254+
if args.secured_device_port is not None:
255+
cmd += f" --local-port {args.secured_device_port}"
256+
await admin.send(
257+
cmd,
258+
# Wait for the log message indicating that the bridge has been
259+
# added to the fabric.
260+
f"Commissioning complete for node ID {bridge_node_id:#018x}: success")
261+
262+
# Open commissioning window with original setup code for the bridge.
263+
cw_endpoint_id = 0
264+
cw_option = 0 # 0: Original setup code, 1: New setup code
265+
cw_timeout = 600
266+
cw_iteration = 1000
267+
cw_discriminator = 0
268+
await admin.send(f"pairing open-commissioning-window {bridge_node_id} {cw_endpoint_id}"
269+
f" {cw_option} {cw_timeout} {cw_iteration} {cw_discriminator}")
270+
271+
try:
272+
await asyncio.gather(
273+
forward_pipe(pipe, admin.p.stdin) if pipe else forward_stdin(admin.p.stdin),
274+
admin.wait(),
275+
bridge.wait(),
276+
)
277+
except SystemExit:
278+
admin.terminate()
279+
bridge.terminate()
280+
except Exception:
281+
admin.terminate()
282+
bridge.terminate()
283+
raise
284+
285+
286+
if __name__ == "__main__":
287+
parser = ArgumentParser(description="Fabric-Sync Example Application")
288+
parser.add_argument("--app-admin", metavar="PATH",
289+
default="out/linux-x64-fabric-admin-rpc/fabric-admin",
290+
help="path to the fabric-admin executable; default=%(default)s")
291+
parser.add_argument("--app-bridge", metavar="PATH",
292+
default="out/linux-x64-fabric-bridge-rpc/fabric-bridge-app",
293+
help="path to the fabric-bridge executable; default=%(default)s")
294+
parser.add_argument("--app-admin-rpc-port", metavar="PORT", type=int,
295+
help="fabric-admin RPC server port")
296+
parser.add_argument("--app-bridge-rpc-port", metavar="PORT", type=int,
297+
help="fabric-bridge RPC server port")
298+
parser.add_argument("--stdin-pipe", metavar="PATH",
299+
help="read input from a named pipe instead of stdin")
300+
parser.add_argument("--storage-dir", metavar="PATH",
301+
help=("directory to place storage files in; by default "
302+
"volatile storage is used"))
303+
parser.add_argument("--paa-trust-store-path", metavar="PATH",
304+
help="path to directory holding PAA certificates")
305+
parser.add_argument("--commissioner-name", metavar="NAME",
306+
help="commissioner name to use for the admin")
307+
parser.add_argument("--commissioner-node-id", metavar="NUM", type=int,
308+
help="commissioner node ID to use for the admin")
309+
parser.add_argument("--commissioner-vendor-id", metavar="NUM", type=int,
310+
help="commissioner vendor ID to use for the admin")
311+
parser.add_argument("--secured-device-port", metavar="NUM", type=int,
312+
help="secure messages listen port to use for the bridge")
313+
parser.add_argument("--discriminator", metavar="NUM", type=int,
314+
help="discriminator to use for the bridge")
315+
parser.add_argument("--passcode", metavar="NUM", type=int,
316+
help="passcode to use for the bridge")
317+
with contextlib.suppress(KeyboardInterrupt):
318+
asyncio.run(main(parser.parse_args()))

0 commit comments

Comments
 (0)