1
+ #!/usr/bin/env -S python3 -B
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 logging
18
+ import os
19
+ import pdb
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ from typing import List , Optional
25
+
26
+ import click
27
+
28
+ # Configure logging format.
29
+ logging .basicConfig (level = logging .INFO , format = '%(levelname)s - %(message)s' )
30
+
31
+ # The maximum amount of time to wait for the Linux tv-app to start before timeout.
32
+ TV_APP_MAX_START_WAIT_SEC = 2
33
+
34
+ # File names of logs for the Linux tv-casting-app and the Linux tv-app.
35
+ LINUX_TV_APP_LOGS = 'Linux-tv-app-logs.txt'
36
+ LINUX_TV_CASTING_APP_LOGS = 'Linux-tv-casting-app-logs.txt'
37
+
38
+ # Values that identify the Linux tv-app and are noted in the 'Device Configuration' in the Linux tv-app output
39
+ # as well as under the 'Discovered Commissioner' details in the Linux tv-casting-app output.
40
+ VENDOR_ID = 65521 # Test vendor id
41
+ PRODUCT_ID = 32769 # Test product id
42
+ DEVICE_TYPE = 35 # Casting video player
43
+
44
+
45
+ class LogFileManager :
46
+ """
47
+ A context manager for managing log files.
48
+
49
+ This class provides a context manager for safely opening and closing log files.
50
+ It ensures that log files are properly managed, allowing reading, writing, or
51
+ both, depending on the specified mode.
52
+ """
53
+
54
+ # Initialize LogFileManager.
55
+ # log_file_path (str): The path to the log file.
56
+ # mode (str): The file mode for opening the log file (default is 'w+' for read/write mode).
57
+ def __init__ (self , log_file_path : str , mode : str = 'w+' ):
58
+ self .log_file_path = log_file_path
59
+ self .mode = mode
60
+
61
+ # Enter the context manager to open and return the log file.
62
+ def __enter__ (self ):
63
+ try :
64
+ self .file = open (self .log_file_path , self .mode )
65
+ except FileNotFoundError :
66
+ # Handle file not found error
67
+ raise FileNotFoundError (f"Log file '{ self .log_file_path } ' not found." )
68
+ except IOError :
69
+ # Handle IO error
70
+ raise IOError (f"Error opening log file '{ self .log_file_path } '." )
71
+ return self .file
72
+
73
+ # Exit the context manager, closing and removing the log file created.
74
+ # exception_type: The type of exception that occurred, if any.
75
+ # exception_value: The value of the exception, if any.
76
+ # traceback: The traceback of the exception.
77
+ def __exit__ (self , exception_type , exception_value , traceback ):
78
+ self .file .close ()
79
+
80
+ if os .path .exists (self .log_file_path ):
81
+ os .remove (self .log_file_path )
82
+
83
+
84
+ class ProcessManager :
85
+ """
86
+ A context manager for managing subprocesses.
87
+
88
+ This class provides a context manager for safely starting and stopping a subprocess.
89
+ """
90
+
91
+ # Initialize ProcessManager.
92
+ # command (list): The command to execute as a subprocess.
93
+ # stdout (file): File-like object to which the subprocess's standard output will be redirected.
94
+ # stderr (file): File-like object to which the subprocess's standard error will be redirected.
95
+ def __init__ (self , command : List [str ], stdout , stderr ):
96
+ self .command = command
97
+ self .stdout = stdout
98
+ self .stderr = stderr
99
+
100
+ # Enter the context manager to start the subprocess and return it.
101
+ def __enter__ (self ):
102
+ self .process = subprocess .Popen (self .command , stdout = self .stdout , stderr = self .stderr , text = True )
103
+ return self .process
104
+
105
+ # Exit the context manager, terminating the subprocess.
106
+ # exception_type: The type of exception that occurred, if any.
107
+ # exception_value: The value of the exception, if any.
108
+ # traceback: The traceback of the exception.
109
+ def __exit__ (self , exception_type , exception_value , traceback ):
110
+ self .process .terminate ()
111
+ self .process .wait ()
112
+
113
+
114
+ # Dump the contents of a log file to the console.
115
+ # log_file_path: The path to the log file.
116
+ def dump_logs_to_console (log_file_path : str ):
117
+ print ('\n Dumping logs from: ' , log_file_path )
118
+
119
+ with LogFileManager (log_file_path , 'r' ) as file :
120
+ for line in file :
121
+ print (line .rstrip ())
122
+
123
+
124
+ # Log 'Discovery failed!' as an error, dump the contents of the log files
125
+ # to the console, exit on error.
126
+ # log_file_paths: A list of paths to the log files, i.e. the path to the
127
+ # Linux tv-casting-app logs and the tv-app logs.
128
+ def handle_discovery_failure (log_file_paths : List [str ]):
129
+ logging .error ('Discovery failed!' )
130
+
131
+ for log_file_path in log_file_paths :
132
+ dump_logs_to_console (log_file_path )
133
+
134
+ sys .exit (1 )
135
+
136
+
137
+ # Extract and return an integer value from a given output string.
138
+ # line: The string containing the integer value.
139
+ #
140
+ # The string is expected to be in the following format as it is received
141
+ # from the Linux tv-casting-app output:
142
+ # \x1b[0;34m[1713741926895] [7276:9521344] [DIS] Vendor ID: 65521\x1b[0m
143
+ # The integer value to be extracted here is 65521.
144
+ def extract_value_from_string (line : str ) -> int :
145
+ value = line .split (':' )[- 1 ].strip ().replace ('\x1b [0m' , '' )
146
+ value = int (value )
147
+
148
+ return value
149
+
150
+
151
+ # Validate if the discovered value matches the expected value.
152
+ # expected_value: The expected integer value, i.e. any of the VENDOR_ID,
153
+ # PRODUCT_ID, or DEVICE_TYPE constants.
154
+ # line: The string containing the value of interest that will be compared
155
+ # to the expected value.
156
+ # value_name: The name of the discovered value, i.e. 'Vendor ID', 'Product ID',
157
+ # or 'Device Type'.
158
+ # Return False if the discovered value does not match, True otherwise.
159
+ def validate_value (expected_value : int , line : str , value_name : str ) -> bool :
160
+ # Extract the integer value from the string.
161
+ value = extract_value_from_string (line )
162
+
163
+ # If the discovered value does not match the expected value,
164
+ # log the error and return False.
165
+ if value != expected_value :
166
+ logging .error (f'{ value_name } does not match the expected value!' )
167
+ logging .error (f'Expected { value_name } : { expected_value } ' )
168
+ logging .error (line .rstrip ('\n ' ))
169
+ return False
170
+
171
+ # Return True if the value matches the expected value.
172
+ return True
173
+
174
+
175
+ # Test if the Linux tv-casting-app is able to discover the Linux tv-app. Both will
176
+ # run separately as subprocesses, with their outputs written to respective log files.
177
+ # Default paths for the executables are provided but can be overridden via command line
178
+ # arguments. For example: python3 run_tv_casting_test.py --tv-app-rel-path=path/to/tv-app
179
+ # --tv-casting-app-rel-path=path/to/tv-casting-app
180
+ @click .command ()
181
+ @click .option ('--tv-app-rel-path' , type = str , default = 'out/tv-app/chip-tv-app' , help = 'Path to the Linux tv-app executable.' )
182
+ @click .option ('--tv-casting-app-rel-path' , type = str , default = 'out/tv-casting-app/chip-tv-casting-app' , help = 'Path to the Linux tv-casting-app executable.' )
183
+ def test_discovery_fn (tv_app_rel_path , tv_casting_app_rel_path ):
184
+
185
+ # Store the log files to a temporary directory.
186
+ with tempfile .TemporaryDirectory () as temp_dir :
187
+ linux_tv_app_log_path = os .path .join (temp_dir , LINUX_TV_APP_LOGS )
188
+ linux_tv_casting_app_log_path = os .path .join (temp_dir , LINUX_TV_CASTING_APP_LOGS )
189
+
190
+ print (temp_dir ) # SHAO
191
+
192
+ # Open and write to the log file for the Linux tv-app.
193
+ with LogFileManager (linux_tv_app_log_path , 'w' ) as linux_tv_app_log_file :
194
+ tv_app_abs_path = os .path .abspath (tv_app_rel_path )
195
+
196
+ if sys .platform == 'darwin' :
197
+ # Try to avoid any stdout buffering in our tests.
198
+ cmd = ['stdbuf' , '-o0' , '-i0' ]
199
+
200
+ # Run the Linux tv-app subprocess.
201
+ with ProcessManager (cmd + [tv_app_abs_path ], stdout = subprocess .PIPE , stderr = subprocess .PIPE ) as tv_app_process :
202
+ start_wait_time = time .time ()
203
+
204
+ # Loop until either the subprocess starts successfully or timeout occurs.
205
+ while True :
206
+ # Check if the time elapsed since the start wait time exceeds the maximum allowed startup time for the TV app.
207
+ if time .time () - start_wait_time > TV_APP_MAX_START_WAIT_SEC :
208
+ logging .error ("The Linux tv-app process did not start successfully within the timeout." )
209
+ handle_discovery_failure ([linux_tv_app_log_path ])
210
+
211
+ # Read one line of output at a time.
212
+ tv_app_output_line = tv_app_process .stdout .readline ()
213
+
214
+ # Write the output to the file.
215
+ linux_tv_app_log_file .write (tv_app_output_line )
216
+ linux_tv_app_log_file .flush ()
217
+
218
+ # Check if the Linux tv-app started successfully.
219
+ if "Started commissioner" in tv_app_output_line :
220
+ logging .info ('Linux tv-app is up and running!' )
221
+
222
+ # If the string is found, then break out of the loop and go ahead with running the Linux tv-casting-app.
223
+ break
224
+
225
+ # Open and write to the log file for the Linux tv-casting-app.
226
+ with LogFileManager (linux_tv_casting_app_log_path , 'w' ) as linux_tv_casting_app_log_file :
227
+ tv_casting_app_abs_path = os .path .abspath (tv_casting_app_rel_path )
228
+
229
+ # Run the Linux tv-casting-app subprocess.
230
+ with ProcessManager (cmd + [tv_casting_app_abs_path ], stdout = subprocess .PIPE , stderr = subprocess .PIPE ) as tv_casting_app_process :
231
+ # Initialize variables.
232
+ continue_parsing = False
233
+ valid_discovered_commissioner = ''
234
+
235
+ # Read the output as we receive it from the tv-casting-app subprocess.
236
+ for line in tv_casting_app_process .stdout :
237
+ # Write to the Linux tv-casting-app log file.
238
+ linux_tv_casting_app_log_file .write (line )
239
+ linux_tv_casting_app_log_file .flush ()
240
+
241
+ # Fail fast if "No commissioner discovered" string found.
242
+ if "No commissioner discovered" in line :
243
+ logging .error (line .rstrip ('\n ' ))
244
+ handle_discovery_failure ([linux_tv_app_log_path , linux_tv_casting_app_log_path ])
245
+
246
+ # Look for 'Discovered Commissioner'.
247
+ if "Discovered Commissioner" in line :
248
+ valid_discovered_commissioner = line .rstrip ('\n ' )
249
+
250
+ # Continue parsing the content that belongs to the "Discovered Commissioner".
251
+ continue_parsing = True
252
+
253
+ # Initialize variables to store the information of interest.
254
+ valid_vendor_id : Optional [str ] = None
255
+ valid_product_id : Optional [str ] = None
256
+ valid_device_type : Optional [str ] = None
257
+
258
+ if continue_parsing :
259
+ # Check if the Vendor ID, Product ID, and Device Type match the expected constant values.
260
+ # If they do not match, then handle the discovery failure.
261
+ if 'Vendor ID:' in line :
262
+ valid_vendor_id = validate_value (VENDOR_ID , line , 'Vendor ID' )
263
+
264
+ if not valid_vendor_id :
265
+ handle_discovery_failure ([linux_tv_app_log_path , linux_tv_casting_app_log_path ])
266
+ else :
267
+ valid_vendor_id = line .rstrip ('\n ' )
268
+
269
+ elif 'Product ID:' in line :
270
+ valid_product_id = validate_value (PRODUCT_ID , line , 'Product ID' )
271
+
272
+ if not valid_product_id :
273
+ handle_discovery_failure ([linux_tv_app_log_path , linux_tv_casting_app_log_path ])
274
+ else :
275
+ valid_product_id = line .rstrip ('\n ' )
276
+
277
+ elif 'Device Type:' in line :
278
+ valid_device_type = validate_value (DEVICE_TYPE , line , 'Device Type' )
279
+
280
+ if not valid_device_type :
281
+ handle_discovery_failure ([linux_tv_app_log_path , linux_tv_casting_app_log_path ])
282
+ else :
283
+ valid_device_type = line .rstrip ('\n ' )
284
+
285
+ # At this point, all values of interest are valid, so we stop parsing.
286
+ continue_parsing = False
287
+
288
+ # Only a discovered commissioner that has valid vendor id, product id,
289
+ # and device type will allow for 'Discovery success!'.
290
+ if valid_vendor_id and valid_product_id and valid_device_type :
291
+ logging .info ('Found a valid commissioner in the Linux tv-casting-app logs:' )
292
+ logging .info (valid_discovered_commissioner )
293
+ logging .info (valid_vendor_id )
294
+ logging .info (valid_product_id )
295
+ logging .info (valid_device_type )
296
+ logging .info ('Discovery success!' )
297
+ # pdb.set_trace() # SHAO
298
+ return
299
+
300
+
301
+ if __name__ == '__main__' :
302
+
303
+ # Start with a clean slate by removing any previously cached entries.
304
+ os .system ('rm -f /tmp/chip_*' )
305
+
306
+ # Test discovery between the Linux tv-casting-app and the tv-app.
307
+ test_discovery_fn ()
0 commit comments