Skip to content

Commit db58ea8

Browse files
committed
[CI] Skip pairing if possible when using chip-tool or darwin-framework-tool to make CI faster
1 parent d761e1b commit db58ea8

File tree

3 files changed

+465
-300
lines changed

3 files changed

+465
-300
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#
2+
# Copyright (c) 2025 Project CHIP Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
from dataclasses import dataclass, field
17+
import os
18+
import typing
19+
20+
21+
@dataclass
22+
class ApplicationPaths:
23+
chip_tool: typing.List[str]
24+
all_clusters_app: typing.List[str]
25+
lock_app: typing.List[str]
26+
fabric_bridge_app: typing.List[str]
27+
ota_provider_app: typing.List[str]
28+
ota_requestor_app: typing.List[str]
29+
tv_app: typing.List[str]
30+
bridge_app: typing.List[str]
31+
lit_icd_app: typing.List[str]
32+
microwave_oven_app: typing.List[str]
33+
chip_repl_yaml_tester_cmd: typing.List[str]
34+
chip_tool_with_python_cmd: typing.List[str]
35+
rvc_app: typing.List[str]
36+
network_manager_app: typing.List[str]
37+
38+
def items(self):
39+
return [self.chip_tool, self.all_clusters_app, self.lock_app,
40+
self.fabric_bridge_app, self.ota_provider_app, self.ota_requestor_app,
41+
self.tv_app, self.bridge_app, self.lit_icd_app,
42+
self.microwave_oven_app, self.chip_repl_yaml_tester_cmd,
43+
self.chip_tool_with_python_cmd, self.rvc_app, self.network_manager_app]
44+
45+
def items_with_key(self):
46+
"""
47+
Returns all path items and also the corresponding "Application Key" which
48+
is the typical application name.
49+
50+
This is to provide scripts a consistent way to reference a path, even if
51+
the paths used for individual appplications contain different names
52+
(e.g. they could be wrapper scripts).
53+
"""
54+
return [
55+
(self.chip_tool, "chip-tool"),
56+
(self.all_clusters_app, "chip-all-clusters-app"),
57+
(self.lock_app, "chip-lock-app"),
58+
(self.fabric_bridge_app, "fabric-bridge-app"),
59+
(self.ota_provider_app, "chip-ota-provider-app"),
60+
(self.ota_requestor_app, "chip-ota-requestor-app"),
61+
(self.tv_app, "chip-tv-app"),
62+
(self.bridge_app, "chip-bridge-app"),
63+
(self.lit_icd_app, "lit-icd-app"),
64+
(self.microwave_oven_app, "chip-microwave-oven-app"),
65+
(self.chip_repl_yaml_tester_cmd, "yamltest_with_chip_repl_tester.py"),
66+
(
67+
# This path varies, however it is a fixed python tool so it may be ok
68+
self.chip_tool_with_python_cmd,
69+
os.path.basename(self.chip_tool_with_python_cmd[-1]),
70+
),
71+
(self.rvc_app, "chip-rvc-app"),
72+
(self.network_manager_app, "matter-network-manager-app"),
73+
]
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
#
2+
# Copyright (c) 2025 Project CHIP Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import logging
17+
import os
18+
import shutil
19+
import stat
20+
import subprocess
21+
import tempfile
22+
import time
23+
import threading
24+
from pathlib import Path
25+
26+
from .application_paths import ApplicationPaths
27+
28+
TEST_NODE_ID = '0x12344321'
29+
30+
31+
def copy_with_fsync(src, dst):
32+
with open(src, 'rb') as fsrc, open(dst, 'wb') as fdst:
33+
while chunk := fsrc.read(4096):
34+
fdst.write(chunk)
35+
fdst.flush()
36+
os.fsync(fdst.fileno())
37+
38+
39+
class CommissioneeApp:
40+
def __init__(self, app_id, runner, command):
41+
self.id = app_id
42+
self.process = None
43+
self.outpipe = None
44+
self.runner = runner
45+
self.command = command
46+
self.cv_stopped = threading.Condition()
47+
self.stopped = True
48+
self.lastLogIndex = 0
49+
self.kvsPathSet = {f"/tmp/chip_kvs_{app_id}"}
50+
self.snapshot_dir = os.path.join(tempfile.gettempdir(), f"chip_snapshot_{app_id}")
51+
self.options = None
52+
self.killed = False
53+
self.isPaired = False
54+
55+
def start(self, options=None):
56+
if not self.process:
57+
# Cache command line options to be used for reboots
58+
if options:
59+
self.options = options
60+
# Make sure to assign self.process before we do any operations that
61+
# might fail, so attempts to kill us on failure actually work.
62+
self.process, self.outpipe, errpipe = self.__startServer(
63+
self.runner, self.command)
64+
self.waitForAnyAdvertisement()
65+
self.__updateSetUpCode()
66+
with self.cv_stopped:
67+
self.stopped = False
68+
self.cv_stopped.notify()
69+
return True
70+
return False
71+
72+
def stop(self):
73+
if self.process:
74+
with self.cv_stopped:
75+
self.stopped = True
76+
self.cv_stopped.notify()
77+
self.__terminateProcess()
78+
return True
79+
return False
80+
81+
def factoryReset(self):
82+
wasRunning = (not self.killed) and self.stop()
83+
84+
for kvs in self.kvsPathSet:
85+
if os.path.exists(kvs):
86+
os.unlink(kvs)
87+
88+
if wasRunning:
89+
return self.start()
90+
91+
return True
92+
93+
def factoryResetOrRestoreState(self):
94+
if not self.restoreState():
95+
self.factoryReset()
96+
97+
def saveState(self):
98+
os.makedirs(self.snapshot_dir, exist_ok=True)
99+
100+
for kvs in self.kvsPathSet:
101+
if os.path.exists(kvs):
102+
snapshot_file = os.path.join(self.snapshot_dir, os.path.basename(kvs))
103+
copy_with_fsync(kvs, snapshot_file)
104+
105+
def restoreState(self):
106+
restored = False
107+
108+
for kvs in self.kvsPathSet:
109+
snapshot_file = os.path.join(self.snapshot_dir, os.path.basename(kvs))
110+
if os.path.exists(snapshot_file):
111+
copy_with_fsync(snapshot_file, kvs)
112+
os.chmod(kvs, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH)
113+
restored = True
114+
115+
self.isPaired = restored
116+
return restored
117+
118+
def waitForAnyAdvertisement(self):
119+
self.__waitFor("mDNS service published:", self.process, self.outpipe)
120+
121+
def waitForMessage(self, message, timeoutInSeconds=10):
122+
self.__waitFor(message, self.process, self.outpipe, timeoutInSeconds)
123+
return True
124+
125+
def kill(self):
126+
self.__terminateProcess()
127+
self.killed = True
128+
129+
def wait(self, timeout=None):
130+
while True:
131+
# If the App was never started, AND was killed, exit immediately
132+
if self.killed:
133+
return 0
134+
# If the App was never started, wait cannot be called on the process
135+
if self.process is None:
136+
time.sleep(0.1)
137+
continue
138+
code = self.process.wait(timeout)
139+
with self.cv_stopped:
140+
if not self.stopped:
141+
return code
142+
# When the server is manually stopped, process waiting is
143+
# overridden so the other processes that depends on the
144+
# accessory beeing alive does not stop.
145+
while self.stopped:
146+
self.cv_stopped.wait()
147+
148+
def __startServer(self, runner, command):
149+
app_cmd = command + ['--interface-id', str(-1)]
150+
151+
if not self.options:
152+
logging.debug('Executing application under test with default args')
153+
app_cmd = app_cmd + ['--KVS', next(iter(self.kvsPathSet))]
154+
else:
155+
logging.debug('Executing application under test with the following args:')
156+
for key, value in self.options.items():
157+
logging.debug(' %s: %s' % (key, value))
158+
app_cmd = app_cmd + [key, value]
159+
if key == '--KVS':
160+
self.kvsPathSet.add(value)
161+
return runner.RunSubprocess(app_cmd, name='APP ', wait=False)
162+
163+
def __waitFor(self, waitForString, server_process, outpipe, timeoutInSeconds=10):
164+
logging.debug('Waiting for %s' % waitForString)
165+
166+
start_time = time.monotonic()
167+
ready, self.lastLogIndex = outpipe.CapturedLogContains(
168+
waitForString, self.lastLogIndex)
169+
if ready:
170+
self.lastLogIndex += 1
171+
172+
while not ready:
173+
if server_process.poll() is not None:
174+
died_str = ('Server died while waiting for %s, returncode %d' %
175+
(waitForString, server_process.returncode))
176+
logging.error(died_str)
177+
raise Exception(died_str)
178+
if time.monotonic() - start_time > timeoutInSeconds:
179+
raise Exception('Timeout while waiting for %s' % waitForString)
180+
time.sleep(0.1)
181+
ready, self.lastLogIndex = outpipe.CapturedLogContains(
182+
waitForString, self.lastLogIndex)
183+
if ready:
184+
self.lastLogIndex += 1
185+
186+
logging.debug('Success waiting for: %s' % waitForString)
187+
188+
def __updateSetUpCode(self):
189+
qrLine = self.outpipe.FindLastMatchingLine('.*SetupQRCode: *\\[(.*)]')
190+
if not qrLine:
191+
raise Exception("Unable to find QR code")
192+
self.setupCode = qrLine.group(1)
193+
194+
def __terminateProcess(self):
195+
if self.process:
196+
self.process.terminate() # sends SIGTERM
197+
try:
198+
exit_code = self.process.wait(10)
199+
if exit_code:
200+
raise Exception('Subprocess failed with exit code: %d' % exit_code)
201+
except subprocess.TimeoutExpired:
202+
logging.debug('Subprocess did not terminate on SIGTERM, killing it now')
203+
self.process.kill()
204+
# The exit code when using Python subprocess will be the signal used to kill it.
205+
# Ideally, we would recover the original exit code, but the process was already
206+
# ignoring SIGTERM, indicating something was already wrong.
207+
self.process.wait(10)
208+
self.process = None
209+
self.outpipe = None
210+
211+
212+
class CommissionerApp():
213+
def __init__(self, runner, apps_register, paths: ApplicationPaths, dry_run):
214+
self.runner = runner
215+
self.apps_register = apps_register
216+
self.paths = paths
217+
self.dry_run = dry_run
218+
219+
def pair(self, setup_code, extra_args):
220+
pass
221+
222+
def test(self, yaml_file, pics_file, timeout_seconds, extra_args):
223+
pass
224+
225+
def run(self, cmd, name, timeout_seconds=None):
226+
if self.dry_run:
227+
# Some of our command arguments have spaces in them, so if we are
228+
# trying to log commands people can run we should quote those.
229+
def quoter(arg): return f"'{arg}'" if ' ' in arg else arg
230+
logging.info(" ".join(map(quoter, cmd)))
231+
return
232+
233+
self.runner.RunSubprocess(cmd, name=name,
234+
dependencies=[self.apps_register], timeout_seconds=timeout_seconds)
235+
236+
def factoryReset(self):
237+
pass
238+
239+
240+
class ChipReplCommissionerApp(CommissionerApp):
241+
def __init__(self, runner, apps_register, paths: ApplicationPaths, dry_run):
242+
super().__init__(runner, apps_register, paths, dry_run)
243+
244+
def pair(self, setup_code, extra_args):
245+
self.setup_code = setup_code
246+
247+
def test(self, yaml_file, pics_file, timeout_seconds, extra_args):
248+
cmd = paths.chip_repl_yaml_tester_cmd.copy()
249+
cmd += ['--setup-code', self.setup_code]
250+
cmd += ['--yaml-path', yaml_file]
251+
cmd += ["--pics-file", pics_file]
252+
253+
self.run(cmd, 'CHIP_REPL_YAML_TESTER', timeout_seconds)
254+
255+
256+
class ChipToolCommissionerApp(CommissionerApp):
257+
def __init__(self, runner, apps_register, paths: ApplicationPaths, dry_run):
258+
super().__init__(runner, apps_register, paths, dry_run)
259+
260+
app_id = os.path.basename(self.paths.chip_tool_with_python_cmd[-1])
261+
self.__storage_dir = tempfile.mkdtemp()
262+
self.__snapshot_dir = os.path.join(tempfile.gettempdir(), f"chip_snapshot_{app_id}")
263+
264+
def pair(self, setup_code, extra_args):
265+
cmd = self.paths.chip_tool_with_python_cmd.copy()
266+
cmd += ['pairing', 'code', TEST_NODE_ID, setup_code]
267+
cmd += extra_args
268+
cmd += ['--server_path', self.paths.chip_tool[-1]]
269+
cmd += ['--server_arguments', f'interactive server --storage-directory {self.__storage_dir}']
270+
271+
if self.dry_run:
272+
self.run(cmd, 'PAIR')
273+
else:
274+
# If the app is already paired, restore previous state
275+
app = self.apps_register.get('default')
276+
if app.isPaired:
277+
self.restoreState()
278+
else:
279+
self.run(cmd, 'PAIR')
280+
app.saveState()
281+
self.saveState()
282+
283+
def test(self, yaml_file, pics_file, timeout_seconds, extra_args):
284+
cmd = self.paths.chip_tool_with_python_cmd.copy()
285+
cmd += ['tests', yaml_file]
286+
cmd += ['--PICS', pics_file]
287+
cmd += extra_args
288+
cmd += ['--server_path', self.paths.chip_tool[-1]]
289+
cmd += ['--server_arguments', f'interactive server --storage-directory {self.__storage_dir}']
290+
291+
self.run(cmd, 'TEST', timeout_seconds)
292+
293+
def factoryReset(self):
294+
shutil.rmtree(self.__storage_dir, ignore_errors=True)
295+
296+
def saveState(self):
297+
os.makedirs(self.__snapshot_dir, exist_ok=True)
298+
shutil.copytree(self.__storage_dir, self.__snapshot_dir, dirs_exist_ok=True)
299+
300+
def restoreState(self):
301+
if not os.path.exists(self.__snapshot_dir):
302+
return False
303+
304+
shutil.copytree(self.__snapshot_dir, self.__storage_dir, dirs_exist_ok=True)
305+
return True

0 commit comments

Comments
 (0)