312312 --debug
313313 --no_cleanup
314314
315+ # Example : Command Line Interface to run the Test along with IOT without device list
316+ ./test_l3.py
317+ --lfmgr 192.168.207.78
318+ --test_duration 1m
319+ --polling_interval 5s
320+ --upstream_port eth1
321+ --endp_type mc_udp
322+ --rates_are_totals
323+ --side_b_min_bps=10000000
324+ --test_tag test_l3
325+ --use_existing_station_list
326+ --existing_station_list 1.20.en0
327+ --cleanup_cx
328+ --tos VO
329+ --test_name Multcast
330+ --iot_test
331+ --iot_testname "Multicast_IoT_Test"
332+
333+ # Example : Command Line Interface to run the Test along with IOT with device list
334+ ./test_l3.py
335+ --lfmgr 192.168.207.78
336+ --test_duration 1m
337+ --polling_interval 5s
338+ --upstream_port eth1
339+ --endp_type mc_udp
340+ --rates_are_totals
341+ --side_b_min_bps=10000000
342+ --test_tag test_l3
343+ --use_existing_station_list
344+ --existing_station_list 1.20.en0
345+ --cleanup_cx
346+ --tos VO
347+ --test_name Multcast
348+ --iot_test
349+ --iot_testname "Multicast_IoT_Test"
350+ --iot_device_list "switch.smart_plug_1_socket_1"
351+
315352
316353SCRIPT_CLASSIFICATION: Creation & Runs Traffic
317354
646683import json
647684import shutil
648685import threading
649-
686+ from collections import OrderedDict
650687
651688import asyncio
652689import copy
663700lf_logger_config = importlib .import_module ("py-scripts.lf_logger_config" )
664701LFUtils = importlib .import_module ("py-json.LANforge.LFUtils" )
665702realm = importlib .import_module ("py-json.realm" )
703+ LFCliBase = realm .LFCliBase
666704DeviceConfig = importlib .import_module ("py-scripts.DeviceConfig" )
667705lf_attenuator = importlib .import_module ("py-scripts.lf_atten_mod_test" )
668706lf_modify_radio = importlib .import_module ("py-scripts.lf_modify_radio" )
@@ -6014,11 +6052,31 @@ def add_live_view_images_to_report(self):
60146052 self .report .set_custom_html (f'<img src="file://{ rssi_image_path } "></img>' )
60156053 self .report .build_custom ()
60166054
6017- def generate_report (self , config_devices = None , group_device_map = None ):
6018- self .report .set_obj_html ("Objective" , "The Layer 3 Traffic Generation Test is designed to test the performance of the "
6019- "Access Point by running layer 3 Cross-Connect Traffic. Layer-3 Cross-Connects represent a stream "
6020- "of data flowing through the system under test. A Cross-Connect (CX) is composed of two Endpoints, "
6021- "each of which is associated with a particular Port (physical or virtual interface)." )
6055+ def generate_report (self , config_devices = None , group_device_map = None , iot_summary = None ):
6056+ if iot_summary :
6057+ self .report .set_obj_html (
6058+ "Objective" ,
6059+ "The Candela Multicast Test Including IoT Devices is designed to evaluate an Access "
6060+ "Point’s efficiency, reliability, and scalability in handling multicast communication "
6061+ "across both Real clients (Android, Windows, Linux, iOS) and IoT devices (controlled "
6062+ "via Home Assistant). "
6063+ "For Real clients, the test simulates multicast traffic and measures key metrics such "
6064+ "as performance, latency, and packet delivery to assess how well the AP sustains "
6065+ "multicast communication under real-world conditions. "
6066+ "For IoT clients, the test concurrently executes device-specific actions (e.g., camera "
6067+ "streaming, switch toggling, lock/unlock) while multicast traffic is active, monitoring "
6068+ "success rate, latency, and failure rate. The goal is to validate that the AP can "
6069+ "reliably manage multicast traffic for Real clients while ensuring consistent "
6070+ "responsiveness and control of IoT devices."
6071+ )
6072+ else :
6073+ self .report .set_obj_html (
6074+ "Objective" ,
6075+ "The Layer 3 Traffic Generation Test is designed to test the performance of the "
6076+ "Access Point by running layer 3 Cross-Connect Traffic. Layer-3 Cross-Connects represent a stream "
6077+ "of data flowing through the system under test. A Cross-Connect (CX) is composed of two Endpoints, "
6078+ "each of which is associated with a particular Port (physical or virtual interface)."
6079+ )
60226080
60236081 self .report .build_objective ()
60246082 test_setup_info = {
@@ -6030,6 +6088,8 @@ def generate_report(self, config_devices=None, group_device_map=None):
60306088
60316089 self .report .set_table_title ("Device Under Test Information" )
60326090 self .report .build_table_title ()
6091+ if iot_summary :
6092+ test_setup_info = with_iot_params_in_table (test_setup_info , iot_summary )
60336093 self .report .test_setup_table (value = "Device Under Test" ,
60346094 test_setup_data = test_setup_info )
60356095 # For real devices when groups specified for configuration
@@ -6416,6 +6476,8 @@ def generate_report(self, config_devices=None, group_device_map=None):
64166476 self .report .build_table_title ()
64176477 self .report .set_table_dataframe (last_row )
64186478 self .report .build_table ()
6479+ if iot_summary :
6480+ self .build_iot_report_section (self .report , iot_summary )
64196481
64206482 def write_report (self ):
64216483 """Write out HTML and PDF report as configured."""
@@ -6609,6 +6671,123 @@ def get_pass_fail_list(self, tos, up, down):
66096671 pass_fail_list .append ('FAIL' )
66106672 return test_input_list , pass_fail_list
66116673
6674+ def build_iot_report_section (self , report , iot_summary ):
6675+ """
6676+ Handles all IoT-related charts, tables, and increment-wise reports.
6677+ """
6678+ outdir = report .path_date_time
6679+ os .makedirs (outdir , exist_ok = True )
6680+
6681+ def copy_into_report (raw_path , new_name ):
6682+ """Resolve and copy image into report dir."""
6683+ if not raw_path :
6684+ return None
6685+
6686+ abs_src = os .path .abspath (raw_path )
6687+ if not os .path .exists (abs_src ):
6688+ # Search recursively under 'results' if absolute path missing
6689+ for root , _ , files in os .walk (os .path .join (os .getcwd (), "results" )):
6690+ if os .path .basename (raw_path ) in files :
6691+ abs_src = os .path .join (root , os .path .basename (raw_path ))
6692+ break
6693+ else :
6694+ return None
6695+
6696+ dst = os .path .join (outdir , new_name )
6697+ if os .path .abspath (abs_src ) != os .path .abspath (dst ):
6698+ shutil .copy2 (abs_src , dst )
6699+ return new_name
6700+
6701+ # section header
6702+ report .set_custom_html ('<div style="page-break-before: always;"></div>' )
6703+ report .build_custom ()
6704+ report .set_custom_html ('<h2><u>IoT Results</u></h2>' )
6705+ report .build_custom ()
6706+
6707+ # Statistics
6708+ stats_png = copy_into_report (iot_summary .get ("statistics_img" ), "iot_statistics.png" )
6709+ if stats_png :
6710+ report .build_chart_title ("Test Statistics" )
6711+ report .set_custom_html (f'<img src="{ stats_png } " style="width:100%; height:auto;">' )
6712+ report .build_custom ()
6713+
6714+ # Request vs Latency
6715+ rvl_png = copy_into_report (iot_summary .get ("req_vs_latency_img" ), "iot_request_vs_latency.png" )
6716+ if rvl_png :
6717+ report .build_chart_title ("Request vs Average Latency" )
6718+ report .set_custom_html (f'<img src="{ rvl_png } " style="width:100%;">' )
6719+ report .build_custom ()
6720+
6721+ # Overall results table
6722+ ort = iot_summary .get ("overall_result_table" ) or {}
6723+ if ort :
6724+ rows = [{
6725+ "Device" : dev ,
6726+ "Min Latency (ms)" : stats .get ("min_latency" ),
6727+ "Avg Latency (ms)" : stats .get ("avg_latency" ),
6728+ "Max Latency (ms)" : stats .get ("max_latency" ),
6729+ "Total Iterations" : stats .get ("total_iterations" ),
6730+ "Success Iters" : stats .get ("success_iterations" ),
6731+ "Failed Iters" : stats .get ("failed_iterations" ),
6732+ "No-Response Iters" : stats .get ("no_response_iterations" ),
6733+ } for dev , stats in ort .items ()]
6734+
6735+ df_overall = pd .DataFrame (rows ).round (2 )
6736+
6737+ report .set_custom_html ('<div style="page-break-inside: avoid;">' )
6738+ report .build_custom ()
6739+ report .set_obj_html (_obj_title = "Overall IoT Result Table" , _obj = " " )
6740+ report .build_objective ()
6741+ report .set_table_dataframe (df_overall )
6742+ report .build_table ()
6743+ report .set_custom_html ('</div>' )
6744+ report .build_custom ()
6745+
6746+ # Increment reports
6747+ inc = iot_summary .get ("increment_reports" ) or {}
6748+ if inc :
6749+ report .set_custom_html ('<h3>Reports by Increment Steps</h3>' )
6750+ report .build_custom ()
6751+
6752+ for step_name , rep in inc .items ():
6753+
6754+ report .set_custom_html (f'<h4><u>{ step_name .replace ("_" , " " )} </u></h4>' )
6755+ report .build_custom ()
6756+
6757+ # Latency graph
6758+ lat_png = copy_into_report (rep .get ("latency_graph" ), f"iot_{ step_name } _latency.png" )
6759+ if lat_png :
6760+ report .build_chart_title ("Average Latency" )
6761+ report .set_custom_html (f'<img src="{ lat_png } " style="width:100%; height:auto;">' )
6762+ report .build_custom ()
6763+
6764+ # Success count graph
6765+ res_png = copy_into_report (rep .get ("result_graph" ), f"iot_{ step_name } _results.png" )
6766+ if res_png :
6767+ report .build_chart_title ("Success Count" )
6768+ report .set_custom_html (f'<img src="{ res_png } " style="width:100%; height:auto;">' )
6769+ report .build_custom ()
6770+
6771+ # Tabular data for detailed iteration-level results
6772+ data_rows = rep .get ("data" ) or []
6773+ if data_rows :
6774+ df = pd .DataFrame (data_rows ).rename (
6775+ columns = {"latency__ms" : "Latency_ms" , "latency_ms" : "Latency_ms" }
6776+ )
6777+ if "Latency_ms" in df .columns :
6778+ df ["Latency_ms" ] = pd .to_numeric (df ["Latency_ms" ], errors = "coerce" ).round (3 )
6779+ if "Result" in df .columns :
6780+ df ["Result" ] = df ["Result" ].map (lambda x : "Success" if bool (x ) else "Failure" )
6781+
6782+ desired_cols = ["Iteration" , "Device" , "Current State" , "Latency_ms" , "Result" ]
6783+ df = df [[c for c in desired_cols if c in df .columns ]]
6784+
6785+ report .set_table_dataframe (df )
6786+ report .build_table ()
6787+
6788+ report .set_custom_html ('<hr>' )
6789+ report .build_custom ()
6790+
66126791
66136792# Converting the upstream_port to IP address for configuration purposes
66146793def change_port_to_ip (upstream_port , lfclient_host , lfclient_port ):
@@ -7968,6 +8147,38 @@ def parse_args():
79688147# https://stackoverflow.com/questions/37304799/cross-platform-safe-to-use-command-line-string-separator
79698148#
79708149# Safe to exit in this function, as this should only be called by this script
8150+ def with_iot_params_in_table (base : dict , iot_summary ) -> dict :
8151+ """
8152+ Append IoT params into the existing Throughput Input Parameters table.
8153+ Adds: IoT Test name, IoT Iterations, IoT Delay (s), IoT Increment.
8154+ Accepts dict or JSON string.
8155+ """
8156+ try :
8157+ if not iot_summary :
8158+ return base
8159+ if isinstance (iot_summary , str ):
8160+ try :
8161+ iot_summary = json .loads (iot_summary )
8162+ except Exception :
8163+ start = iot_summary .find ("{" )
8164+ end = iot_summary .rfind ("}" )
8165+ if start == - 1 or end == - 1 or end <= start :
8166+ return base
8167+ try :
8168+ iot_summary = json .loads (iot_summary [start :end + 1 ])
8169+ except Exception :
8170+ return base
8171+
8172+ ti = (iot_summary .get ("test_input_table" ) or {})
8173+ out = OrderedDict (base )
8174+ out ["Iot Device List" ] = ti .get ("Device List" , "" )
8175+ out ["IoT Iterations" ] = ti .get ("Iterations" , "" )
8176+ out ["IoT Delay (s)" ] = ti .get ("Delay (seconds)" , "" )
8177+ out ["IoT Increment" ] = ti .get ("Increment Pattern" , "" )
8178+ return out
8179+ except Exception :
8180+ return base
8181+
79718182
79728183def trigger_iot (ip , port , iterations , delay , device_list , testname , increment ):
79738184 """
@@ -8042,18 +8253,6 @@ async def run_iot(ip: str = '127.0.0.1',
80428253 logger .info ('Iot Test Completed.' )
80438254
80448255
8045- def duration_to_seconds (duration : str ) -> int :
8046- duration = duration .strip ().lower ()
8047- if duration .endswith ("s" ):
8048- return int (duration [:- 1 ])
8049- elif duration .endswith ("m" ):
8050- return int (duration [:- 1 ]) * 60
8051- elif duration .endswith ("h" ):
8052- return int (duration [:- 1 ]) * 3600
8053- else :
8054- return int (duration )
8055-
8056-
80578256def main ():
80588257 endp_types = "lf_udp"
80598258
@@ -8186,7 +8385,7 @@ def main():
81868385 thread = threading .Thread (target = trigger_iot , args = (iot_ip , iot_port , iot_iterations , iot_delay , iot_device_list , iot_testname , iot_increment ))
81878386 thread .start ()
81888387 else :
8189- total_secs = duration_to_seconds ( args .test_duration )
8388+ total_secs = int ( LFCliBase . parse_time ( args .test_duration ). total_seconds () )
81908389 iot_iterations = max (1 , total_secs // args .iot_delay )
81918390 iot_thread = threading .Thread (
81928391 target = trigger_iot ,
@@ -8769,12 +8968,19 @@ def main():
87698968 ip_var_test .set_report_obj (report = report )
87708969 if args .dowebgui :
87718970 ip_var_test .webgui_finalize ()
8971+ iot_summary = None
8972+ if args .iot_test and args .iot_testname :
8973+ base = os .path .join ("results" , args .iot_testname )
8974+ p = os .path .join (base , "iot_summary.json" )
8975+ if os .path .exists (p ):
8976+ with open (p ) as f :
8977+ iot_summary = json .load (f )
87728978 # Generate and write out test report
87738979 logger .info ("Generating test report" )
87748980 if args .real :
8775- ip_var_test .generate_report (config_devices , group_device_map )
8981+ ip_var_test .generate_report (config_devices , group_device_map , iot_summary = iot_summary )
87768982 else :
8777- ip_var_test .generate_report ()
8983+ ip_var_test .generate_report (iot_summary = iot_summary )
87788984 ip_var_test .write_report ()
87798985
87808986 # TODO move to after reporting
0 commit comments