From 9cba6559fa10412e73df33b74ca91f9eca45e17c Mon Sep 17 00:00:00 2001 From: Narayana-CT Date: Wed, 15 Jul 2026 18:42:48 +0530 Subject: [PATCH 1/5] lf_interop_ping_plotter.py: log endpoint status changes to a CSV Adds log_endpoint_status_change(), called from get_results() for every endpoint on each poll, which records a timestamp/resource_id/ endpoint_name/status row to endpoint_status_log.csv whenever an endpoint's status actually changes (including first-seen), rather than dumping full state every poll. Resource id is resolved from self.sta_list, branching on real vs. virtual naming since GenCXProfile.create() names them differently (full eid for real clients, port segment only for virtual). The CSV is created at startup (works for both CLI and WebUI runs) and moved into the report directory once generate_report()/generate_report_robo() establish it. Also dedupes the "missing endpoints" warning added earlier: each endpoint is now only logged once when it first goes missing, tracked via self.reported_missing_endpoints, instead of repeating the same warning on every poll for the rest of the test. get_results() now returns immediately when the manager gives no response, instead of crashing on results.keys(). Both polling loops (monitor() and the equivalent inline loop in main()) handle that None return by pacing with the loop's normal time.sleep(1) and loop_timer advance before continuing, so a transient API outage skips a poll instead of crashing the test or busy-looping. Signed-off-by: Narayana-CT --- py-scripts/lf_interop_ping_plotter.py | 57 +++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/py-scripts/lf_interop_ping_plotter.py b/py-scripts/lf_interop_ping_plotter.py index fc3f833a8..2a7e815e5 100755 --- a/py-scripts/lf_interop_ping_plotter.py +++ b/py-scripts/lf_interop_ping_plotter.py @@ -203,6 +203,11 @@ def __init__(self, self.windows = 0 self.mac = 0 self.result_json = {} + self.reported_missing_endpoints = set() + self.endpoint_status_history = {} + self.endpoint_status_csv_path = os.path.join(os.getcwd(), 'endpoint_status_log.csv') + with open(self.endpoint_status_csv_path, 'w', newline='') as f: + csv.writer(f).writerow(['timestamp', 'resource_id', 'endpoint_name', 'status']) self.generic_endps_profile = self.new_generic_endp_profile() self.generic_endps_profile.type = 'lfping' self.generic_endps_profile.dest = self.change_target_to_ip() @@ -459,6 +464,25 @@ def stop_generic(self): self.generic_endps_profile.stop_cx() self.stop_time = datetime.now() + def log_endpoint_status_change(self, endpoint_name, endpoint_data): + status = endpoint_data.get('status') + if self.endpoint_status_history.get(endpoint_name) == status: + return + self.endpoint_status_history[endpoint_name] = status + + resource_id = '' + prefix = f"{self.generic_endps_profile.name_prefix}-" + for station in self.sta_list: + expected_name = f"{prefix}{station}" if station in self.real_sta_list else f"{prefix}{station.split('.')[-1]}" + if endpoint_name == expected_name: + resource_id = station.split('.')[1] + break + + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(self.endpoint_status_csv_path, 'a', newline='') as f: + csv.writer(f).writerow([timestamp, resource_id, endpoint_name, status]) + logger.info(f"Endpoint {endpoint_name} (resource {resource_id}) status changed to {status}") + def get_results(self): endp = "/generic/{}".format(','.join(self.generic_endps_profile.created_endp)) logger.debug(f"GET {endp}") @@ -466,27 +490,38 @@ def get_results(self): if results is None: logger.error(f"GET {endp} returned no response for created endpoints: {self.generic_endps_profile.created_endp}") + return results multiple_endpoints_requested = len(self.generic_endps_profile.created_endp) > 1 if multiple_endpoints_requested and 'endpoints' in results.keys(): results = results['endpoints'] try: - returned_names = [list(d.keys())[0] for d in results] + returned_names = [] + for d in results: + endpoint_name = list(d.keys())[0] + self.log_endpoint_status_change(endpoint_name, d[endpoint_name]) + returned_names.append(endpoint_name) missing = [name for name in self.generic_endps_profile.created_endp if name not in returned_names] - if missing: - logger.error(f"GET {endp} response is missing results for endpoints: {missing}") + newly_missing = [name for name in missing if name not in self.reported_missing_endpoints] + if newly_missing: + logger.warning(f"GET {endp} response is missing results for endpoints: {newly_missing}") + logger.warning(json.dumps(results, indent=2)) + self.reported_missing_endpoints.update(newly_missing) except Exception: logger.error(f"GET {endp} 'endpoints' list is not in the expected format.") + return results return results try: results = results['endpoint'] + self.log_endpoint_status_change(results['name'], results) except Exception: logger.error(f"GET {endp} response does not contain the 'endpoint' or 'endpoints' key, or the data is not in the expected format.") overallres = self.json_get("/generic/all") logger.error("GET /generic/all response:") logger.error(json.dumps(overallres, indent=2)) + return results return results @@ -1015,6 +1050,9 @@ def generate_report(self, result_json=None, result_dir='Ping_Plotter_Test_Report logging.info('path: {}'.format(report_path)) logging.info('path_date_time: {}'.format(report_path_date_time)) + if os.path.exists(self.endpoint_status_csv_path): + shutil.move(self.endpoint_status_csv_path, os.path.join(report_path_date_time, 'endpoint_status_log.csv')) + # setting report title report.set_title('Ping Plotter Test Report') report.build_banner() @@ -1557,6 +1595,11 @@ def monitor(self, individual_df, Devices=None, rtts=None, rtts_list=None, ping_s t_init = datetime.now() try: result_data = self.get_results() + if result_data is None: + logger.warning("get_results() returned no data this poll; skipping to the next iteration.") + time.sleep(1) + loop_timer += abs(t_init - datetime.now()).total_seconds() + continue if isinstance(result_data, dict): if 'UNKNOWN' in result_data['name']: raise ValueError("There are no valid generic endpoints to run the test") @@ -2125,6 +2168,9 @@ def generate_report_robo(self, result_json=None, result_dir='Ping_Plotter_Test_R logging.info('path: {}'.format(report_path)) logging.info('path_date_time: {}'.format(report_path_date_time)) + if os.path.exists(self.endpoint_status_csv_path): + shutil.move(self.endpoint_status_csv_path, os.path.join(report_path_date_time, 'endpoint_status_log.csv')) + # setting report title report.set_title('Ping Plotter Test Report') report.build_banner() @@ -3258,6 +3304,11 @@ def main(): t_init = datetime.now() try: result_data = ping.get_results() + if result_data is None: + logger.warning("get_results() returned no data this poll; skipping to the next iteration.") + time.sleep(1) + loop_timer += abs(t_init - datetime.now()).total_seconds() + continue if isinstance(result_data, dict): if 'UNKNOWN' in result_data['name']: raise ValueError("There are no valid generic endpoints to run the test") From aa13b39ad1ed29ba4ef14ea4fd0afc2c89f77dfe Mon Sep 17 00:00:00 2001 From: Narayana-CT Date: Thu, 16 Jul 2026 13:05:25 +0530 Subject: [PATCH 2/5] lf_interop_ping_plotter.py: fix report-generation crashes and data misalignment Adds build_device_summary(), a shared helper that derives the per-device summary fields (packets sent/received/dropped, mac/ip/ bssid/ssid, channel, mode, packet loss %, min/avg/max RTT, report name) used by every table and graph in the report. The inline loops in generate_report() and generate_report_robo() previously accessed device_data fields directly with no fallback, so a single device with an empty ping_stats, a missing key, or an rtts dict made up entirely of the 0.11 "no data" placeholder would raise (including a latent ZeroDivisionError on sum()/len() of an emptied rtts list) and abort report generation for every device, not just the bad one. build_device_summary() now falls back to safe defaults (0 / 'NA' / 'Unknown') per field instead, keeping every device's contribution positionally aligned with self.result_json's iteration order, which generate_uptime_graph() depends on for per-device color mapping. Also drops a leftover debug print() from the generate_report_robo() copy of this loop. Fixes get_pass_fail_list(): GET /adb/ returning None was logged but then dereferenced anyway (adb_response["devices"]), crashing the whole report; it's now checked before use, matching the equivalent fix already applied elsewhere. Separately, and more importantly, res_list only appended an entry for an Android client when the /adb/ lookup found a match, so any unresolved Android device silently shifted every later entry in res_list/test_input_list one position out of alignment with self.report_names/self.packets_sent - producing either a pandas length-mismatch crash when the mismatched test_input_list was assigned onto individual_report_df, or worse, a PASS/FAIL verdict silently attributed to the wrong device. res_list now always appends exactly one entry per client (falling back to the client's own name when unresolved), and pass_fail_list is indexed by range(len(self.report_names)) instead of range(len(test_input_list)). Also hoists the /adb/ call out of the loop instead of re-fetching it once per Android client. Fixes generate_dataframe(): GET /adb/ returning None was logged but then dereferenced anyway, same crash as above - now returns None early. Also fixes an 'android' vs 'Android' case mismatch in the group-membership check: every place in this file that actually sets a device's os field uses the capitalized string "Android", so the lowercase comparison here could never match, meaning Android devices always fell through to the slower per-username /adb/ lookup branch instead of the direct name-match branch. Signed-off-by: Narayana-CT --- py-scripts/lf_interop_ping_plotter.py | 258 +++++++++++++++----------- 1 file changed, 150 insertions(+), 108 deletions(-) diff --git a/py-scripts/lf_interop_ping_plotter.py b/py-scripts/lf_interop_ping_plotter.py index 2a7e815e5..106b63963 100755 --- a/py-scripts/lf_interop_ping_plotter.py +++ b/py-scripts/lf_interop_ping_plotter.py @@ -904,24 +904,39 @@ def copy_reports(self, report_path): def get_pass_fail_list(self): # When csv_name is provided, for pass/fail criteria, respective values for each client will be used if not self.expected_passfail_val: + # Resolve /adb/ once (not once per Android client) and only if there's + # at least one Android client to resolve. + interop_tab_data = None + if any(client.split(' ')[1] == 'Android' for client in self.report_names): + logger.debug("GET /adb/") + adb_response = self.json_get('/adb/') + if adb_response is None: + logger.error("GET /adb/ returned no response while building the pass/fail device list.") + else: + interop_tab_data = adb_response["devices"] + res_list = [] - test_input_list = [] - pass_fail_list = [] for client in self.report_names: + device_name = client.split(' ')[2] # Check if the client type (second word in "1.15 android samsungmob") is 'android' - if client.split(' ')[1] != 'Android': - res_list.append(client.split(' ')[2]) - else: - logger.debug("GET /adb/") - adb_response = self.json_get('/adb/') - if adb_response is None: - logger.error("GET /adb/ returned no response while building the pass/fail device list.") - interop_tab_data = adb_response["devices"] + matched_name = None + if client.split(' ')[1] == 'Android' and interop_tab_data is not None: for dev in interop_tab_data: for item in dev.values(): # Extract the username from the client string (e.g., 'samsungmob' from "1.15 android samsungmob") - if item['user-name'] == client.split(' ')[2]: - res_list.append(item['name'].split('.')[2]) + if item['user-name'] == device_name: + matched_name = item['name'].split('.')[2] + break + if matched_name is not None: + break + # Always append exactly one entry per client, even when the ADB + # lookup can't resolve a match, so res_list stays positionally + # aligned with self.report_names/self.packets_sent/etc. — an + # unresolved Android client previously left a gap here, which + # shifted every pass/fail verdict after it onto the wrong device. + res_list.append(matched_name if matched_name is not None else device_name) + + test_input_list = [] with open(self.csv_name, mode='r') as file: reader = csv.DictReader(file) rows = list(reader) @@ -933,9 +948,10 @@ def get_pass_fail_list(self): found = True break if not found: - logging.info(f"Ping result for device {device} not found in CSV. Using default packet loss = 10%") + logging.warning(f"Ping Packet Loss % for device {device} not found in Threshold CSV. Using default packet loss = 10%") test_input_list.append(10) - for i in range(len(test_input_list)): + pass_fail_list = [] + for i in range(len(self.report_names)): if self.packets_sent[i] == 0.0: pass_fail_list.append('FAIL') elif float(test_input_list[i]) >= self.packet_loss_percent[i]: @@ -955,6 +971,84 @@ def get_pass_fail_list(self): pass_fail_list.append("FAIL") return pass_fail_list, test_input_list + def build_device_summary(self, device, device_data): + """ + Safely derives the summary fields used in report tables/graphs for one device. + + device_data can be sparse (empty ping_stats, rtts made up entirely of the + 0.11 "no data" placeholder, missing keys) if the endpoint never reported + anything, without the entry itself being absent from self.result_json. + Every field below falls back to a safe default (0 / 'NA' / 'Unknown') + instead of raising, so every device in self.result_json always + contributes exactly one entry to each summary list — keeping them + aligned with self.result_json's iteration order, which + generate_uptime_graph() relies on for per-device color mapping. + """ + device_data = device_data or {} + ping_stats = device_data.get('ping_stats') or {} + sent_list = ping_stats.get('sent') or [] + received_list = ping_stats.get('received') or [] + dropped_list = ping_stats.get('dropped') or [] + + def last_int(values): + try: + return int(values[-1]) + except (IndexError, TypeError, ValueError): + return 0 + + channel_value = str(device_data.get('channel', '')) + device_channel = 'NA' if channel_value in ('', '0', '-1') else device_data.get('channel', channel_value) + + try: + total_sent = float(device_data.get('sent', 0) or 0) + except (TypeError, ValueError): + total_sent = 0 + if total_sent == 0 or not sent_list: + packet_loss_percent = 0 + else: + try: + last_sent = float(sent_list[-1]) + packet_loss_percent = (float(dropped_list[-1]) / last_sent * 100) if last_sent != 0 else 0 + except (IndexError, TypeError, ValueError): + packet_loss_percent = 0 + + try: + t_rtt_values = sorted(v for v in (device_data.get('rtts') or {}).values() if v != 0.11) + except TypeError: + t_rtt_values = [] + if t_rtt_values: + device_avg = float(sum(t_rtt_values) / len(t_rtt_values)) + device_min = float(min(t_rtt_values)) + device_max = float(max(t_rtt_values)) + else: + device_avg = device_min = device_max = 0 + + device_name = device_data.get('name', device) + device_os = device_data.get('os', 'Unknown') + if device_os == 'Virtual': + report_name = '{} {}'.format(device, device_os)[0:25] + else: + report_name = '{} {} {}'.format(device, device_os, device_name) + + return { + 'packets_sent': last_int(sent_list), + 'packets_received': last_int(received_list), + 'packets_dropped': last_int(dropped_list), + 'device_name': device_name, + 'device_mode': device_data.get('mode', 'NA'), + 'device_channel': device_channel, + 'device_mac': device_data.get('mac', 'NA'), + 'device_ip': device_data.get('ip', 'NA'), + 'device_bssid': device_data.get('bssid', 'NA'), + 'device_ssid': device_data.get('ssid', 'NA'), + 'packet_loss_percent': packet_loss_percent, + 'device_avg': device_avg, + 'device_min': device_min, + 'device_max': device_max, + 'report_name': report_name, + 'remarks': device_data.get('remarks') or [], + } + def generate_report(self, result_json=None, result_dir='Ping_Plotter_Test_Report', report_path='', config_devices='', group_device_map=None): if group_device_map is None: group_device_map = {} @@ -986,56 +1080,30 @@ def generate_report(self, result_json=None, result_dir='Ping_Plotter_Test_Report del self.result_json['status'] for device, device_data in self.result_json.items(): - self.packets_sent.append(int(device_data['ping_stats']['sent'][-1])) - self.packets_received.append(int(device_data['ping_stats']['received'][-1])) - self.packets_dropped.append(int(device_data['ping_stats']['dropped'][-1])) - self.device_names.append(device_data['name']) - self.device_modes.append(device_data['mode']) - channel_value = str(device_data.get('channel', '')) - if channel_value in ('', '0', '-1'): - self.device_channels.append('NA') - else: - self.device_channels.append(device_data['channel']) - self.device_mac.append(device_data['mac']) - self.device_ips.append(device_data['ip']) - self.device_bssid.append(device_data['bssid']) - self.device_ssid.append(device_data['ssid']) - if float(device_data['sent']) == 0: - self.packet_loss_percent.append(0) - # self.client_unrechability_percent.append(0) - else: - if device_data['ping_stats']['sent'] == [] or float(device_data['ping_stats']['sent'][-1]) == 0: - self.packet_loss_percent.append(0) - else: - self.packet_loss_percent.append(float(device_data['ping_stats']['dropped'][-1]) / float(device_data['ping_stats']['sent'][-1]) * 100) - # self.client_unrechability_percent.append(float(device_data['dropped']) / (float(self.duration) * 60) * 100) - t_rtt_values = sorted(list(device_data['rtts'].values())) - if t_rtt_values != []: - while (0.11 in t_rtt_values): - t_rtt_values.remove(0.11) - self.device_avg.append(float(sum(t_rtt_values) / len(t_rtt_values))) - self.device_min.append(float(min(t_rtt_values))) - self.device_max.append(float(max(t_rtt_values))) - else: - self.device_avg.append(0) - self.device_min.append(0) - self.device_max.append(0) - # self.device_avg.append(float(sum(t_rtt_values) / len(t_rtt_values))) - # self.device_min.append(float(device_data['min_rtt'].replace(',', ''))) - # self.device_max.append(float(device_data['max_rtt'].replace(',', ''))) - # self.device_avg.append(float(device_data['avg_rtt'].replace(',', ''))) - if device_data['os'] == 'Virtual': - self.report_names.append('{} {}'.format(device, device_data['os'])[0:25]) - else: - self.report_names.append('{} {} {}'.format(device, device_data['os'], device_data['name'])) - if device_data['remarks'] != []: - self.device_names_with_errors.append(device_data['name']) + summary = self.build_device_summary(device, device_data) + self.packets_sent.append(summary['packets_sent']) + self.packets_received.append(summary['packets_received']) + self.packets_dropped.append(summary['packets_dropped']) + self.device_names.append(summary['device_name']) + self.device_modes.append(summary['device_mode']) + self.device_channels.append(summary['device_channel']) + self.device_mac.append(summary['device_mac']) + self.device_ips.append(summary['device_ip']) + self.device_bssid.append(summary['device_bssid']) + self.device_ssid.append(summary['device_ssid']) + self.packet_loss_percent.append(summary['packet_loss_percent']) + self.device_avg.append(summary['device_avg']) + self.device_min.append(summary['device_min']) + self.device_max.append(summary['device_max']) + self.report_names.append(summary['report_name']) + if summary['remarks']: + self.device_names_with_errors.append(summary['device_name']) self.devices_with_errors.append(device) - self.remarks.append(','.join(device_data['remarks'])) - logging.info('{} {} {}'.format(*self.packets_sent, + self.remarks.append(','.join(summary['remarks'])) + logging.debug('{} {} {}'.format(*self.packets_sent, *self.packets_received, *self.packets_dropped)) - logging.info('{} {} {}'.format(*self.device_min, + logging.debug('{} {} {}'.format(*self.device_min, *self.device_max, *self.device_avg)) @@ -1402,6 +1470,7 @@ def generate_dataframe(self, groupdevlist: List[str], report_names: List[str], d adb_response = self.json_get('/adb/') if adb_response is None: logger.error("GET /adb/ returned no response while building the device report list.") + return None interop_tab_data = adb_response["devices"] for i in range(len(report_names)): for j in groupdevlist: @@ -1409,7 +1478,7 @@ def generate_dataframe(self, groupdevlist: List[str], report_names: List[str], d # - report_names[i].split(" ")[2] gives 'test3' (device name) # - report_names[i].split(" ")[1] gives 'Lin' (OS type) # This condition filters out Android clients and matches device name with j - if j == report_names[i].split(" ")[2] and report_names[i].split(" ")[1] != 'android': + if j == report_names[i].split(" ")[2] and report_names[i].split(" ")[1] != 'Android': report_name.append(report_names[i]) macids.append(device_mac[i]) device_ip.append(device_ips[i]) @@ -2289,53 +2358,26 @@ def generate_report_robo(self, result_json=None, result_dir='Ping_Plotter_Test_R del self.result_json['status'] for device, device_data in self.result_json.items(): - print("deviceeee", device, "deviceedataaa", device_data) - self.packets_sent.append(int(device_data['ping_stats']['sent'][-1])) - self.packets_received.append(int(device_data['ping_stats']['received'][-1])) - self.packets_dropped.append(int(device_data['ping_stats']['dropped'][-1])) - self.device_names.append(device_data['name']) - self.device_modes.append(device_data['mode']) - channel_value = str(device_data.get('channel', '')) - if channel_value in ('', '0', '-1'): - self.device_channels.append('NA') - else: - self.device_channels.append(device_data['channel']) - self.device_mac.append(device_data['mac']) - self.device_ips.append(device_data['ip']) - self.device_bssid.append(device_data['bssid']) - self.device_ssid.append(device_data['ssid']) - if float(device_data['sent']) == 0: - self.packet_loss_percent.append(0) - # self.client_unrechability_percent.append(0) - else: - if device_data['ping_stats']['sent'] == [] or float(device_data['ping_stats']['sent'][-1]) == 0: - self.packet_loss_percent.append(0) - else: - self.packet_loss_percent.append(float(device_data['ping_stats']['dropped'][-1]) / float(device_data['ping_stats']['sent'][-1]) * 100) - # self.client_unrechability_percent.append(float(device_data['dropped']) / (float(self.duration) * 60) * 100) - t_rtt_values = sorted(list(device_data['rtts'].values())) - if t_rtt_values != []: - while (0.11 in t_rtt_values): - t_rtt_values.remove(0.11) - self.device_avg.append(float(sum(t_rtt_values) / len(t_rtt_values))) - self.device_min.append(float(min(t_rtt_values))) - self.device_max.append(float(max(t_rtt_values))) - else: - self.device_avg.append(0) - self.device_min.append(0) - self.device_max.append(0) - # self.device_avg.append(float(sum(t_rtt_values) / len(t_rtt_values))) - # self.device_min.append(float(device_data['min_rtt'].replace(',', ''))) - # self.device_max.append(float(device_data['max_rtt'].replace(',', ''))) - # self.device_avg.append(float(device_data['avg_rtt'].replace(',', ''))) - if device_data['os'] == 'Virtual': - self.report_names.append('{} {}'.format(device, device_data['os'])[0:25]) - else: - self.report_names.append('{} {} {}'.format(device, device_data['os'], device_data['name'])) - if device_data['remarks'] != []: - self.device_names_with_errors.append(device_data['name']) + summary = self.build_device_summary(device, device_data) + self.packets_sent.append(summary['packets_sent']) + self.packets_received.append(summary['packets_received']) + self.packets_dropped.append(summary['packets_dropped']) + self.device_names.append(summary['device_name']) + self.device_modes.append(summary['device_mode']) + self.device_channels.append(summary['device_channel']) + self.device_mac.append(summary['device_mac']) + self.device_ips.append(summary['device_ip']) + self.device_bssid.append(summary['device_bssid']) + self.device_ssid.append(summary['device_ssid']) + self.packet_loss_percent.append(summary['packet_loss_percent']) + self.device_avg.append(summary['device_avg']) + self.device_min.append(summary['device_min']) + self.device_max.append(summary['device_max']) + self.report_names.append(summary['report_name']) + if summary['remarks']: + self.device_names_with_errors.append(summary['device_name']) self.devices_with_errors.append(device) - self.remarks.append(','.join(device_data['remarks'])) + self.remarks.append(','.join(summary['remarks'])) logging.info('{} {} {}'.format(*self.packets_sent, *self.packets_received, *self.packets_dropped)) From f83156ef625d8454a5f21501c0e668fcf13bc54f Mon Sep 17 00:00:00 2001 From: Narayana-CT Date: Thu, 16 Jul 2026 13:32:22 +0530 Subject: [PATCH 3/5] lf_interop_ping_plotter.py: retry before giving up on total endpoint loss When every generic endpoint disappears from a poll response (all 'UNKNOWN' or no valid entries), both polling loops (monitor() and the equivalent inline loop in main()) previously called exit(0) immediately - killing the process before report generation ever ran, even if the loss was a transient LANforge hiccup and even though minutes or hours of prior polling data was already sitting in result_json/coordinate_json. Adds has_valid_endpoint_data(), extracted from the duplicated isinstance/UNKNOWN-filtering check in both loops, and wait_for_valid_endpoints(timeout=40), which retries get_results() every 2s for up to 40s before giving up. If valid endpoint data reappears during that window, the loop resumes normal polling; if the timeout elapses, the loop now breaks instead of exiting, falling through to the existing stop_generic() / report-generation / cleanup sequence in both call sites. Since report generation can now be reached with partial or even zero collected data (a device that never once returned data still never gets a result_json entry), generate_report() and generate_report_robo() each gained a guard at the top that logs and returns early if self.result_json / self.coordinate_json is empty, rather than building an empty or broken report. Signed-off-by: Narayana-CT --- py-scripts/lf_interop_ping_plotter.py | 72 ++++++++++++++++++++------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/py-scripts/lf_interop_ping_plotter.py b/py-scripts/lf_interop_ping_plotter.py index 106b63963..bf65b5e0b 100755 --- a/py-scripts/lf_interop_ping_plotter.py +++ b/py-scripts/lf_interop_ping_plotter.py @@ -525,6 +525,42 @@ def get_results(self): return results + def has_valid_endpoint_data(self, result_data): + """ + True if result_data (as returned by get_results()) contains at least + one endpoint whose name isn't the 'UNKNOWN' placeholder LANforge uses + when a generic endpoint couldn't be tied to a real device/CX. + """ + if result_data is None: + return False + if isinstance(result_data, dict): + return 'UNKNOWN' not in result_data.get('name', 'UNKNOWN') + keys = [list(d.keys())[0] for d in result_data] + return any('UNKNOWN' not in key for key in keys) + + def wait_for_valid_endpoints(self, timeout=40, poll_interval=2): + """ + Called when a poll comes back with no valid (non-UNKNOWN) generic + endpoints at all. Keeps retrying get_results() for up to `timeout` + seconds in case the loss is transient (e.g. a brief LANforge hiccup) + before giving up, instead of stopping the test immediately. + + Returns: + True if a retry during the wait found valid endpoint data + (caller should resume normal polling), False if the whole + timeout elapsed with nothing valid (caller should stop the + test and move on to report generation). + """ + logger.warning(f"No valid generic endpoints in the last poll. Waiting up to {timeout}s for them to recover before stopping the test.") + wait_start = datetime.now() + while (datetime.now() - wait_start).total_seconds() < timeout: + time.sleep(poll_interval) + if self.has_valid_endpoint_data(self.get_results()): + logger.info("Valid endpoint data recovered; resuming the test.") + return True + logger.error(f"No valid generic endpoints recovered after waiting {timeout}s.") + return False + def generate_remarks(self, station_ping_data): remarks = [] @@ -1054,6 +1090,9 @@ def generate_report(self, result_json=None, result_dir='Ping_Plotter_Test_Report group_device_map = {} if result_json is not None: self.result_json = result_json + if not self.result_json: + logger.error("No ping data was collected for any device; skipping report generation.") + return logging.info('Generating Report') # graph for the above self.packets_sent = [] @@ -1669,18 +1708,15 @@ def monitor(self, individual_df, Devices=None, rtts=None, rtts_list=None, ping_s time.sleep(1) loop_timer += abs(t_init - datetime.now()).total_seconds() continue - if isinstance(result_data, dict): - if 'UNKNOWN' in result_data['name']: - raise ValueError("There are no valid generic endpoints to run the test") - else: - keys = [list(d.keys())[0] for d in result_data] - keys = [key for key in keys if 'UNKNOWN' not in key] - if len(keys) == 0: - raise ValueError("There are no valid generic endpoints to run the test") + if not self.has_valid_endpoint_data(result_data): + raise ValueError("There are no valid generic endpoints to run the test") except ValueError as e: logger.info(result_data) logger.error(e) - exit(0) + if self.wait_for_valid_endpoints(timeout=40): + loop_timer += abs(t_init - datetime.now()).total_seconds() + continue + break # logging.info(result_data) # Robot battery management — check every 300 seconds if self.robot is not None and not self.do_bandsteering and monitor_charge_time is not None: @@ -2226,6 +2262,9 @@ def generate_report_robo(self, result_json=None, result_dir='Ping_Plotter_Test_R group_device_map = {} if result_json is not None: self.result_json = result_json + if not self.coordinate_json: + logger.error("No ping data was collected for any coordinate; skipping report generation.") + return logging.info('Generating Report') report = lf_report(_output_pdf='interop_ping.pdf', @@ -3351,18 +3390,15 @@ def main(): time.sleep(1) loop_timer += abs(t_init - datetime.now()).total_seconds() continue - if isinstance(result_data, dict): - if 'UNKNOWN' in result_data['name']: - raise ValueError("There are no valid generic endpoints to run the test") - else: - keys = [list(d.keys())[0] for d in result_data] - keys = [key for key in keys if 'UNKNOWN' not in key] - if len(keys) == 0: - raise ValueError("There are no valid generic endpoints to run the test") + if not ping.has_valid_endpoint_data(result_data): + raise ValueError("There are no valid generic endpoints to run the test") except ValueError as e: logger.info(result_data) logger.error(e) - exit(0) + if ping.wait_for_valid_endpoints(timeout=40): + loop_timer += abs(t_init - datetime.now()).total_seconds() + continue + break if args.virtual: logger.debug("GET /ports/all/") ports_response = ping.json_get('/ports/all/') From 7da8f64e418a7967f20bc0953908a412a5f39a57 Mon Sep 17 00:00:00 2001 From: Narayana-CT Date: Thu, 16 Jul 2026 13:52:30 +0530 Subject: [PATCH 4/5] lf_interop_ping_plotter.py: fix and clean up /ports/all/ handling Three call sites fetched GET /ports/all/ and dereferenced the response (ports_response['interfaces']) without checking for None first - a transient failure would raise TypeError. Two of the three built a ports_data dict that was never actually read afterward: the per-coordinate fetch in perform_robo(), and the one-time initial fetch in main() before the polling loop (its result was always superseded by the in-loop rebuild under `if args.virtual:` before ever being read, even on the very first iteration). Both were dead code as well as a crash risk, so removed outright rather than just guarded. The third site - the in-loop rebuild under `if args.virtual:` - is the one that matters: ports_data[station] is read a few lines later in the same iteration, so a transient failure there would abort the whole test run. Adds wait_for_ports_response(timeout=40), mirroring wait_for_valid_endpoints() added for the endpoint-loss case: retries GET /ports/all/ every 2s for up to 40s, and if it still hasn't recovered, stops the test with exit(1) rather than crashing on the None dereference. Signed-off-by: Narayana-CT --- py-scripts/lf_interop_ping_plotter.py | 49 +++++++++++++++------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/py-scripts/lf_interop_ping_plotter.py b/py-scripts/lf_interop_ping_plotter.py index bf65b5e0b..11f6fda57 100755 --- a/py-scripts/lf_interop_ping_plotter.py +++ b/py-scripts/lf_interop_ping_plotter.py @@ -561,6 +561,28 @@ def wait_for_valid_endpoints(self, timeout=40, poll_interval=2): logger.error(f"No valid generic endpoints recovered after waiting {timeout}s.") return False + def wait_for_ports_response(self, timeout=40, poll_interval=2): + """ + Called when GET /ports/all/ returns no response. Keeps retrying for + up to `timeout` seconds in case the failure is transient before + giving up. + + Returns: + The /ports/all/ response dict if a retry during the wait + succeeds, or None if the whole timeout elapsed with nothing + valid (caller should stop the test). + """ + logger.warning(f"GET /ports/all/ returned no response. Waiting up to {timeout}s for it to recover before stopping the test.") + wait_start = datetime.now() + while (datetime.now() - wait_start).total_seconds() < timeout: + time.sleep(poll_interval) + ports_response = self.json_get('/ports/all/') + if ports_response is not None: + logger.info("GET /ports/all/ succeeded; resuming the test.") + return ports_response + logger.error(f"GET /ports/all/ still returned no response after waiting {timeout}s.") + return None + def generate_remarks(self, station_ping_data): remarks = [] @@ -2101,16 +2123,6 @@ def perform_robo(self, Devices, config_devices, group_device_map): self.coordinates_completed.append(coord) self.currentcoordinate = coord - logger.debug("GET /ports/all/") - ports_response = self.json_get('/ports/all/') - if ports_response is None: - logger.error(f"GET /ports/all/ returned no response while updating port data at coordinate {coord}.") - ports_data_dict = ports_response['interfaces'] - ports_data = {} - for ports in ports_data_dict: - port, port_data = list(ports.keys())[0], list(ports.values())[0] - ports_data[port] = port_data - logging.info("rotationlist {}".format(self.angle_list)) for j in range(len(self.angle_list)): duration = initial_duration * 60 @@ -3357,15 +3369,6 @@ def main(): # start generate endpoint ping.start_generic() - logger.debug("GET /ports/all/") - ports_response = ping.json_get('/ports/all/') - if ports_response is None: - logger.error("GET /ports/all/ returned no response while fetching initial port data.") - ports_data_dict = ports_response['interfaces'] - ports_data = {} - for ports in ports_data_dict: - port, port_data = list(ports.keys())[0], list(ports.values())[0] - ports_data[port] = port_data duration = duration * 60 @@ -3403,10 +3406,12 @@ def main(): logger.debug("GET /ports/all/") ports_response = ping.json_get('/ports/all/') if ports_response is None: - logger.error("GET /ports/all/ returned no response while polling virtual station port data.") - ports_data_dict = ports_response['interfaces'] + ports_response = ping.wait_for_ports_response(timeout=40) + if ports_response is None: + logger.error("GET /ports/all/ returned no response while polling virtual station port data. Stopping the test.") + exit(1) ports_data = {} - for ports in ports_data_dict: + for ports in ports_response['interfaces']: port, port_data = list(ports.keys())[0], list(ports.values())[0] ports_data[port] = port_data if isinstance(result_data, dict): From 5366f42ff338cae33c86224865920d0a5dace1f5 Mon Sep 17 00:00:00 2001 From: Narayana-CT Date: Thu, 16 Jul 2026 14:45:03 +0530 Subject: [PATCH 5/5] lf_interop_ping_plotter.py: retry /adb/ before falling back on no response Adds wait_for_adb_response(timeout=40), mirroring wait_for_valid_endpoints()/wait_for_ports_response(): retries GET /adb/ every 2s for up to 40s when it returns no response, in case the failure is transient, before falling back to whatever the caller already did for a missing response. Unlike get_results()/ports/all/, /adb/ is only used as enrichment - matching Android clients to CSV pass/fail thresholds (get_pass_fail_list()) or filtering devices into group tables (generate_dataframe()) - so neither caller's fallback changes: an unresolved Android client in get_pass_fail_list() still falls back to its own name (already fixed in the res_list alignment work), and generate_dataframe() still returns None for that one group's table on a caller loop that already treats None as "skip this group, keep going" - confirmed by tracing group_device_map.items() in both generate_report() and generate_report_robo(), where the result is only consumed inside `if dataframe:`. So this only adds a wait before either fallback kicks in, without changing what happens after. Verified CLI : python3 lf_interop_ping_plotter.py --mgr 192.168.207.75 --real --ping_interval 1 --ping_duration 1m --target www.google.com --use_default_config Signed-off-by: Narayana-CT --- py-scripts/lf_interop_ping_plotter.py | 37 ++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/py-scripts/lf_interop_ping_plotter.py b/py-scripts/lf_interop_ping_plotter.py index 11f6fda57..e5b4c6d53 100755 --- a/py-scripts/lf_interop_ping_plotter.py +++ b/py-scripts/lf_interop_ping_plotter.py @@ -583,6 +583,28 @@ def wait_for_ports_response(self, timeout=40, poll_interval=2): logger.error(f"GET /ports/all/ still returned no response after waiting {timeout}s.") return None + def wait_for_adb_response(self, timeout=40, poll_interval=2): + """ + Called when GET /adb/ returns no response. Keeps retrying for up to + `timeout` seconds in case the failure is transient before giving up. + + Returns: + The /adb/ response dict if a retry during the wait succeeds, or + None if the whole timeout elapsed with nothing valid (caller + should fall back to whatever it already does for a missing + /adb/ response). + """ + logger.warning(f"GET /adb/ returned no response. Waiting up to {timeout}s for it to recover.") + wait_start = datetime.now() + while (datetime.now() - wait_start).total_seconds() < timeout: + time.sleep(poll_interval) + adb_response = self.json_get('/adb/') + if adb_response is not None: + logger.info("GET /adb/ succeeded.") + return adb_response + logger.error(f"GET /adb/ still returned no response after waiting {timeout}s.") + return None + def generate_remarks(self, station_ping_data): remarks = [] @@ -970,7 +992,8 @@ def get_pass_fail_list(self): adb_response = self.json_get('/adb/') if adb_response is None: logger.error("GET /adb/ returned no response while building the pass/fail device list.") - else: + adb_response = self.wait_for_adb_response(timeout=40) + if adb_response is not None: interop_tab_data = adb_response["devices"] res_list = [] @@ -1162,11 +1185,11 @@ def generate_report(self, result_json=None, result_dir='Ping_Plotter_Test_Report self.devices_with_errors.append(device) self.remarks.append(','.join(summary['remarks'])) logging.debug('{} {} {}'.format(*self.packets_sent, - *self.packets_received, - *self.packets_dropped)) + *self.packets_received, + *self.packets_dropped)) logging.debug('{} {} {}'.format(*self.device_min, - *self.device_max, - *self.device_avg)) + *self.device_max, + *self.device_avg)) logging.info('Generating Report') @@ -1531,7 +1554,9 @@ def generate_dataframe(self, groupdevlist: List[str], report_names: List[str], d adb_response = self.json_get('/adb/') if adb_response is None: logger.error("GET /adb/ returned no response while building the device report list.") - return None + adb_response = self.wait_for_adb_response(timeout=40) + if adb_response is None: + return None interop_tab_data = adb_response["devices"] for i in range(len(report_names)): for j in groupdevlist: