From 547201d8181d7cc5b8c86523dfeb1e70289bb575 Mon Sep 17 00:00:00 2001 From: David Lang Date: Fri, 6 Mar 2026 16:15:17 -0800 Subject: [PATCH 1/3] update lldp map generation scripts --- .../config/scripts/gather_lldp_map_data | 23 ++ .../config/scripts/make_lldp_map | 107 ++-------- .../config/scripts/make_lldp_map.py | 196 ++++++++++++++++++ .../config/scripts/make_lldp_map.sed | 3 + 4 files changed, 242 insertions(+), 87 deletions(-) create mode 100755 switch-configuration/config/scripts/gather_lldp_map_data mode change 100755 => 100644 switch-configuration/config/scripts/make_lldp_map create mode 100755 switch-configuration/config/scripts/make_lldp_map.py create mode 100644 switch-configuration/config/scripts/make_lldp_map.sed diff --git a/switch-configuration/config/scripts/gather_lldp_map_data b/switch-configuration/config/scripts/gather_lldp_map_data new file mode 100755 index 000000000..7948590a7 --- /dev/null +++ b/switch-configuration/config/scripts/gather_lldp_map_data @@ -0,0 +1,23 @@ +#!/bin/bash +#This goes through all switches in switchtypes, connects to them and gets a LLDP dump of what's connected to them. + +shopt -s lastpipe + +( + grep -v ipv6 ../../facts/routers/routerlist.csv |sed s/,/' '/ |while read name ip + do + ssh -n -o ConnectTimeout=10 $ip 'networkctl lldp |grep -v -e SYSTEM-NAME -e "^Capability" -e "- Bridge;" -e "neighbor.s. listed" -e "^$" |while read iface server junk; do echo "$iface $server"; done' |while read iface neighbor + do + echo "$name $iface $neighbor" + done + done + + grep -v "^/" switchtypes |while read name num m ip type h n model mac + do + ssh -n -o ConnectTimeout=10 $ip 'show lldp neighbors' |grep "^g" |while read line + do + echo "$name $line" + done + done |sed s/"\/"/-/g| tr "[A-Z]" "[a-z]" | while read host if p id rest; do name=`echo "$rest"|sed s/"^.* "//`; notname=`echo "$rest" |sed s/" [^ ]*$"//`; echo "$host $if $name"; done +) |sed -f scripts/make_lldp_map.sed | tee map.parts + diff --git a/switch-configuration/config/scripts/make_lldp_map b/switch-configuration/config/scripts/make_lldp_map old mode 100755 new mode 100644 index e2081a24e..184cf1874 --- a/switch-configuration/config/scripts/make_lldp_map +++ b/switch-configuration/config/scripts/make_lldp_map @@ -1,91 +1,24 @@ #!/bin/bash -#This goes through all switches in switchtypes, connects to them and gets a LLDP dump of what's connected to them. +# +# This script creates maps that show the network connectivity. +# it is designed to be run from scale-network/swich-configuration/configs +# it runs scripts/gather_lldp_map_data to the file map.parts +# map.parts contains distilled lldp data showing what each device can hear +# This includes a fixup defined in scripts/make_lldp_map.sed that should not be needed if the csv router definitions match their hostnames +# There is also currently a bug in that the pi kiosk images don't set their hostnames via dhcp so they all show up as pi or pi-monitoring. they need to all get a unique name for the diagram +# +# The format is: +# host interface remote +# +# scripts/make_lldp_map.py then takes this data and creates a .diag file +# that can then be used by blockdiag to create the images. +# you tell it which node to start with (the border router) and things external to that are shown as clouds +# -shopt -s lastpipe +scripts/gather_lldp_map_data -ls ../../router-configuration/backups/ |while read ip -do - ssh -o ConnectTimeout=10 $ip.scale.lan 'show lldp neighbors' map.parts - -cp map.parts map.parts.w -while [ -s map.parts.w ] -do - head -1 map.parts.w |read a if b - grep "^$a " map.parts >map.parts.$a - grep -v -e "^$a " -e " $a$" map.parts.w >map.parts.r - mv map.parts.r map.parts.w -done -( echo "blockdiag { orientation = portrait " -echo "node_width = 80" -echo "br-mdf-01 [ shape = ellipse ];" -grep "^br-mdf-01 " map.parts.br-mdf-01 |grep -v "01$" |while read a if b -do - echo "$b [ shape = ellipse ];" - echo "$b -- $a;" -done -grep "^br-mdf-01 " map.parts.br-mdf-01 |grep "01$" |while read a if b -do - echo "$b [ shape = ellipse ];" - echo "$a -- $b;" - if [ -s map.parts.$b ] - then - echo "group { orientation = portrait color=white " - grep "^$b " map.parts.$b |grep -v " $a$" |while read c if2 d - do - echo "$c -- $d" - if [ -s map.parts.$d ] - then - echo "group { orientation = landscape color = lightgreen " - grep "^$d " map.parts.$d |grep -v " $c$" |while read e if3 f - do - echo "$e -- $f" - if [[ $f == "pi"* ]] - then - echo "$f [shape = flowchart.input, color=lightblue ];" - elif [[ $f == *"-"? ]] - then - echo "$f [shape = ellipse, color=lightblue ];" - fi - if [ -s map.parts.$f ] - then - echo "group { orientation = portrait " - grep "^$f " map.parts.$f |grep -v " $e$" |while read g if4 h - do - if [[ $h == "pi"* ]] - then - echo "$h [shape = flowchart.input, color=lightblue ];" - elif [[ $h == *"-"? || $h == *"-"?? ]] - then - echo "$h [shape = ellipse, color=lightblue ];" - fi - echo "$g -- $h" - done - echo "}" - fi - done - echo "}" - fi - done - echo "}" - fi -done -echo "}" )|sed -e s/"->,"/"->"/ -e s/" ->;"/";"/ > map.diag -rm map.parts* - - -/usr/bin/blockdiag3 map.diag -T png -o map.png -/usr/bin/blockdiag3 map.diag -T svg -o map.svg +scripts/make_lldp_map.py br-mdf-01 map.parts +blockdiag network.diag -T svg -o map.svg +blockdiag network.diag -T png -o map.png diff --git a/switch-configuration/config/scripts/make_lldp_map.py b/switch-configuration/config/scripts/make_lldp_map.py new file mode 100755 index 000000000..4c35bf88c --- /dev/null +++ b/switch-configuration/config/scripts/make_lldp_map.py @@ -0,0 +1,196 @@ +#!/usr/bin/python3 +import sys +import argparse +import networkx as nx +from collections import deque, defaultdict +import os + +# This script generates a blockdiag .diag file from a data file containing network "hearing" events. +# Each line in the input file is expected to be in the format "A C B", meaning system A hears system B on interface C. +# The script ignores the interface (C) for graph construction and edge labels to avoid clutter. +# It builds an undirected graph using NetworkX. +# - Node names are treated case-insensitively (e.g., "confidf" and "ConfIDF" are merged). +# - Node display names are normalized: find longest common prefix among all nodes, capitalize its first letter (rest lower), +# then append the suffix in all uppercase (non-letters unchanged). +# Example: nodes "ballrooma", "ballrooma-1", "ballroomb", "ballroomb-1" -> common prefix "ballroom" -> +# display: "BallroomA", "BallroomA-1", "BallroomB", "BallroomB-1". +# - External nodes: Connected only to the starting node with degree 1 (shaped as clouds). +# - Starting node: Specified by user (shaped as ellipse). +# - Internal nodes directly connected to starting: Shaped as ellipses. +# - Internal leaf nodes: Nodes that are "heard" but do not "hear" anything else (i.e., not reporters; shaped as circles). +# - Other internal nodes: Shaped as boxes. +# The script uses BFS from the starting node to assign levels and build a spanning tree for hierarchical layout. +# To prevent the diagram from becoming too wide, it creates nested groups with orientations based on presence of leaves: +# - If a group has leaf nodes, use portrait (vertical) layout. +# - If a group has no leaf nodes, use landscape (horizontal) layout. +# Leaf children are listed directly in the group with the parent, without a sub-group. +# Orientation statements are only included for the top two levels of groups; deeper groups have no orientation specified. +# Externals are grouped with portrait orientation to stack vertically if many, reducing width. +# The overall diagram is portrait-oriented, with externals above starting, and internal branches below, potentially stacked. +# Output: network.diag (run `blockdiag network.diag -T svg -o network.svg` or similar to generate image). +# If data_file is "-", input is read from stdin instead of a file. + +# Parse command-line arguments +parser = argparse.ArgumentParser(description='Generate a blockdiag .diag file from network hearing data.') +parser.add_argument('starting_node', help='The name of the starting node.') +parser.add_argument('data_file', help='Path to the data file, or "-" to read from stdin.') +args = parser.parse_args() + +starting = args.starting_node.lower() # Normalize to lower +filename = args.data_file + +# Prepare input iterator (file or stdin) +if filename == '-': + input_iter = sys.stdin +else: + input_iter = open(filename, 'r') + +# Initialize graph and data structures +G = nx.Graph() # Undirected graph for connections +reporters = set() # Set of nodes that report hearing others (A in "A C B") +edges = [] # List of (A, B) pairs for later processing + +# Read and process input lines, normalizing to lowercase +for line in input_iter: + parts = line.strip().split() + if len(parts) == 3: + A, C, B = parts + a_lower = A.lower() + b_lower = B.lower() + G.add_edge(a_lower, b_lower) # Add undirected edge (ignores C) + reporters.add(a_lower) # A is a reporter + edges.append((a_lower, b_lower)) # Store directed pair if needed later + +# Close file if not stdin +if filename != '-': + input_iter.close() + +# Compute display names: find common prefix and normalize +all_nodes = list(G.nodes) +if all_nodes: + common_prefix = os.path.commonprefix(all_nodes) + display_map = {} + for node in all_nodes: + prefix_cap = common_prefix.capitalize() + suffix_upper = node[len(common_prefix):].upper() + display_map[node] = prefix_cap + suffix_upper +else: + display_map = {} + +# Identify external nodes: neighbors of starting with degree 1 (only connected to starting) +externals = [nb for nb in G.neighbors(starting) if G.degree(nb) == 1] + +# Internal starting points: other neighbors of starting (not externals) +internal_starts = [nb for nb in G.neighbors(starting) if nb not in externals] + +# All internal nodes: all nodes except starting and externals +internals = list(set(G.nodes) - set(externals) - {starting}) + +# Build spanning tree, levels, and tree children using BFS (excluding externals) +tree_children = defaultdict(list) +levels = {starting: 0} +queue = deque(internal_starts) +visited = set([starting]) +for n in internal_starts: + levels[n] = 1 + visited.add(n) + queue.append(n) + tree_children[starting].append(n) # Starting's children are internal_starts + +while queue: + u = queue.popleft() + for v in G.neighbors(u): + if v not in visited and v not in externals: + visited.add(v) + levels[v] = levels[u] + 1 + queue.append(v) + tree_children[u].append(v) + +# Function to write nested subtree groups with orientations based on presence of leaves +def write_subtree(out, node, level, tree_children): + children = tree_children[node] + leaf_children = [c for c in children if len(tree_children[c]) == 0] + non_leaf_children = [c for c in children if len(tree_children[c]) > 0] + has_leaves = len(leaf_children) > 0 + ori = 'portrait' if has_leaves else 'landscape' + group_name = f'group_{node.replace("-", "_").replace(".", "_")}' # Sanitize node name for group + out.write(f' group {group_name} {{\n') + if level <= 1: + out.write(f' orientation = {ori};\n') + out.write(f' "{display_map[node]}";\n') + for child in sorted(leaf_children): + out.write(f' "{display_map[child]}";\n') + for child in sorted(non_leaf_children): + write_subtree(out, child, level + 1, tree_children) + out.write(' }\n') + +# Write .diag file +with open('network.diag', 'w') as out: + out.write('blockdiag {\n') + out.write(' orientation = portrait;\n') + + # Define all nodes with shapes first (to avoid duplication issues), using quoted display names + out.write(' // Node definitions\n') + for node in sorted(G.nodes): + disp = display_map.get(node, node) # Fallback to node if no map + if node in externals: + shape = "cloud" + elif node == starting or node in internal_starts: + shape = "ellipse" + elif node not in reporters: + shape = "circle" + else: + shape = "box" + out.write(f' "{disp}" [shape = {shape}];\n') + + # External group (vertical stack to reduce width) + if externals: + out.write(' group externals {\n') + out.write(' orientation = portrait;\n') + out.write(' label = "Externals";\n') + out.write(' color = "#FFFFCC";\n') + for e in sorted(externals): + out.write(f' "{display_map[e]}";\n') + out.write(' }\n') + + # Internal group (vertical overall, containing alternating subtrees) + if internals: + out.write(' group internals {\n') + out.write(' orientation = portrait;\n') + out.write(' label = "Internals";\n') + out.write(' color = "#CCFFCC";\n') + for i in sorted(internal_starts): + write_subtree(out, i, 1, tree_children) + out.write(' }\n') + + # Edges: externals -> starting (directed inward), using display names + starting_disp = display_map.get(starting, starting) + for e in sorted(externals): + e_disp = display_map.get(e, e) + out.write(f' "{e_disp}" -> "{starting_disp}";\n') + + # All other edges, directed based on levels (lower level -> higher level for downward flow) + # Include all edges (tree and non-tree), deduplicated + all_edges = [(u, v) for u, v in G.edges] + seen = set() + for u, v in all_edges: + edge = frozenset([u, v]) + if edge in seen: + continue + seen.add(edge) + u_disp = display_map.get(u, u) + v_disp = display_map.get(v, v) + lu = levels.get(u, -1) + lv = levels.get(v, -1) + if lu < lv: + out.write(f' "{u_disp}" -> "{v_disp}";\n') + elif lv < lu: + out.write(f' "{v_disp}" -> "{u_disp}";\n') + else: + # Same level or unassigned (e.g., externals or cross): arbitrary direction (alphabetical on display) + src, dst = (u_disp, v_disp) if u_disp < v_disp else (v_disp, u_disp) + out.write(f' "{src}" -> "{dst}";\n') + + out.write('}\n') + +print("Generated network.diag. Run 'blockdiag network.diag -T svg -o network.svg' or similar for PDF/SVG.") diff --git a/switch-configuration/config/scripts/make_lldp_map.sed b/switch-configuration/config/scripts/make_lldp_map.sed new file mode 100644 index 000000000..d62fa1d85 --- /dev/null +++ b/switch-configuration/config/scripts/make_lldp_map.sed @@ -0,0 +1,3 @@ +s/router-conf/cf-mdf-01/g +s/router-expo/ex-mdf-01/g +s/router-border/br-mdf-01/g From d08f97cf2af08c645906673f3ce3da64d5b6b61d Mon Sep 17 00:00:00 2001 From: David Lang Date: Fri, 6 Mar 2026 17:00:06 -0800 Subject: [PATCH 2/3] updated versions of scan/analyze scripts, misc fixes --- facts/aps/analyze-scan.py | 500 +++++++++++++++++++++++++++++++++ facts/aps/gather-wifi-scans.py | 257 +++++++++++++++++ 2 files changed, 757 insertions(+) create mode 100755 facts/aps/analyze-scan.py create mode 100755 facts/aps/gather-wifi-scans.py diff --git a/facts/aps/analyze-scan.py b/facts/aps/analyze-scan.py new file mode 100755 index 000000000..aa30b6c3a --- /dev/null +++ b/facts/aps/analyze-scan.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +#!/usr/bin/env python3 +# WiFi Channel Planner - Effective dBm Power-Sum Method (with real Noise Floor) +# +# PURPOSE +# Analyzes iwinfo + iwinfo scan output from multiple APs to recommend channels +# for a given SSID, minimizing real co-channel interference. +# Uses physically correct power addition (not naive dBm summing). +# Now uses the actual "Noise: -xx dBm" value reported by each AP as the base +# interference floor instead of the arbitrary -120 dBm. +# +# USAGE +# python3 channel_planner.py [options] +# +# EXAMPLES +# # Show plan + before/after statistics +# python3 channel_planner.py "scale-public-slow" scan-results.txt +# +# # Generate plan and update CSV with suggested channels +# python3 channel_planner.py "scale-public-slow" scan-results.txt --update apuse.csv +# +# # Show this help +# python3 channel_planner.py --help +# +# OUTPUT +# - Current (Before) interference stats based on existing channels from data file +# - Suggested (After) channel assignments and interference stats +# - Before/after histograms of individual co-channel signal strengths +# - If --update used: confirmation of new .auto-channel file +# +# INTERFERENCE MATH EXPLANATION +# dBm is logarithmic. Interference adds in linear power domain, NOT dBm. +# +# Correct combination (effective_dBm): +# 1. Convert each dBm → mW: power = 10^(dBm / 10) +# 2. Sum powers: total_mW = Σ power_i +# 3. Back to dBm: effective_dBm = 10 * log10(total_mW) +# +# Now includes the real "Noise: -xx dBm" from iwinfo as the base floor for each AP. +# When co-channel signals are present, they are added to the noise floor in the +# power domain before converting back to dBm. This gives the true total noise+interference +# each AP experiences. +# +# Examples: +# Noise -95 dBm + one -70 dBm co-channel → effective ≈ -69.6 dBm +# Noise -95 dBm + two -70 dBm → effective ≈ -66.8 dBm +# +# ---------------------------------------------------------------------- + +import sys +import re +import math +import csv +import os +import statistics +from collections import defaultdict +import random +import argparse + +def parse_own_ap(lines, start): + ap = {} + line = lines[start] + m = re.search(r'(phy\d+-ap\d+) ESSID: "(.*?)"', line) + if m: + ap['interface'] = m.group(1) + ap['ssid'] = m.group(2) + i = start + 1 + while i < len(lines) and not (lines[i].startswith('phy') and 'ESSID:' in lines[i]) and not lines[i].startswith('Cell '): + l = lines[i].strip() + if 'Access Point:' in l: + m = re.search(r'Access Point: ([\w:]+)', l) + if m: + ap['bssid'] = m.group(1).lower() + elif 'Channel:' in l: + m = re.search(r'Channel: (\d+)', l) + if m: + ap['channel'] = int(m.group(1)) + ch = ap['channel'] + if ch <= 14: + ap['band'] = '2.4' + else: + ap['band'] = '5' + elif 'Noise:' in l: + m = re.search(r'Noise: (-?\d+) dBm', l) + if m: + ap['noise_floor'] = int(m.group(1)) + i += 1 + ap['end_index'] = i + return ap + +def parse_scan(lines, start): + scan = {} + line = lines[start] + m = re.search(r'Cell \d+ - Address: ([\w:]+)', line) + if m: + scan['bssid'] = m.group(1).lower() + i = start + 1 + while i < len(lines) and not (lines[i].startswith('phy') and 'ESSID:' in lines[i]) and not lines[i].startswith('Cell '): + l = lines[i].strip() + if 'ESSID:' in l: + m = re.search(r'ESSID: "(.*?)"', l) + if m: + scan['ssid'] = m.group(1) + elif 'Channel:' in l: + m = re.search(r'Channel: (\d+)', l) + if m: + scan['channel'] = int(m.group(1)) + ch = scan['channel'] + if ch <= 14: + scan['band'] = '2.4' + else: + scan['band'] = '5' + elif 'Signal:' in l: + m = re.search(r'Signal: (-?\d+) dBm', l) + if m: + scan['signal'] = int(m.group(1)) + i += 1 + scan['end_index'] = i + return scan + +def effective_dbm(signals_list): + if not signals_list: + return -120.0 + total_power = sum(10 ** (sig / 10.0) for sig in signals_list) + return 10 * math.log10(total_power) + +def compute_total_effective(assignment, signals, devs): + per_channel = defaultdict(list) + for dev1 in devs: + for dev2 in signals.get(dev1, {}): + if assignment.get(dev1) == assignment.get(dev2): + per_channel[assignment[dev1]].append(signals[dev1][dev2]) + total_eff = 0 + for ch_signals in per_channel.values(): + total_eff += effective_dbm(ch_signals) + return total_eff + +def compute_per_channel_stats(assignment, signals, devs, avail, noise_floors): + per_ch = {} + for ch in avail: + aps_on_ch = [dev for dev in devs if assignment.get(dev) == ch] + + # Channel-wide total (all directed pairs) + all_signals_on_ch = [] + for dev1 in aps_on_ch: + for dev2 in signals.get(dev1, {}): + if assignment.get(dev2) == ch: + all_signals_on_ch.append(signals[dev1][dev2]) + channel_eff = effective_dbm(all_signals_on_ch) + + # Per-AP effective interference (noise + co-channel signals received by this AP) + per_ap_eff = [] + for ap in aps_on_ch: + ap_signals = [] + # Add co-channel signals this AP hears + for other in aps_on_ch: + if other != ap and other in signals.get(ap, {}): + ap_signals.append(signals[ap][other]) + # Combine with this AP's own noise floor + noise = noise_floors.get(ap, -95) + total_power = 10 ** (noise / 10.0) + for sig in ap_signals: + total_power += 10 ** (sig / 10.0) + per_ap_eff.append(10 * math.log10(total_power)) + + if per_ap_eff: + avg = statistics.mean(per_ap_eff) + med = statistics.median(per_ap_eff) + worst = max(per_ap_eff) + else: + avg = med = worst = None + + per_ch[ch] = { + 'effective_dbm': channel_eff, + 'ap_count': len(aps_on_ch), + 'average_per_ap_dbm': avg, + 'median_per_ap_dbm': med, + 'worst_per_ap_dbm': worst, + 'pair_count': len(all_signals_on_ch) + } + return per_ch + +def compute_histogram(cochannel_signals): + bins = { + '>= -50': 0, + '-51 to -60': 0, + '-61 to -70': 0, + '-71 to -80': 0, + '-81 to -90': 0, + '-91 to -100': 0, + '<= -101': 0 + } + for sig in cochannel_signals: + if sig >= -50: + bins['>= -50'] += 1 + elif -60 <= sig <= -51: + bins['-51 to -60'] += 1 + elif -70 <= sig <= -61: + bins['-61 to -70'] += 1 + elif -80 <= sig <= -71: + bins['-71 to -80'] += 1 + elif -90 <= sig <= -81: + bins['-81 to -90'] += 1 + elif -100 <= sig <= -91: + bins['-91 to -100'] += 1 + else: + bins['<= -101'] += 1 + return bins + +def get_cochannel_signals(assignment, signals, devs): + co_signals = [] + for dev1 in devs: + for dev2 in signals.get(dev1, {}): + if assignment.get(dev1) == assignment.get(dev2): + co_signals.append(signals[dev1][dev2]) + return co_signals + +def assign_channels(devs, avail, signals, incoming): + assignment = {} + assigned_to = defaultdict(list) + order = list(devs) + random.shuffle(order) + for dev in order: + costs = {} + for ch in avail: + signals_on_ch = [] + for u in assigned_to[ch]: + if u in signals.get(dev, {}): + signals_on_ch.append(signals[dev][u]) + if u in incoming.get(dev, {}): + signals_on_ch.append(incoming[dev][u]) + costs[ch] = effective_dbm(signals_on_ch) + min_cost = min(costs.values()) + best_chs = [ch for ch, c in costs.items() if c == min_cost] + best_chs = sorted(best_chs, key=lambda c: len(assigned_to[c])) + chosen_ch = best_chs[0] + assignment[dev] = chosen_ch + assigned_to[chosen_ch].append(dev) + return assignment + +def update_csv(csv_path, assignment, band, verbose=False): + output_path = csv_path + ".auto-channel" + if not os.path.exists(csv_path): + print(f"Error: CSV file not found: {csv_path}", file=sys.stderr) + return + + with open(csv_path, 'r', newline='') as f_in: + reader = csv.DictReader(f_in) + fieldnames = reader.fieldnames + rows = list(reader) + + # Build IP → suggested channel map + ip_to_ch = {dev: ch for dev, ch in assignment.items()} + + updated_count = 0 + changed_count = 0 + + with open(output_path, 'w', newline='') as f_out: + writer = csv.DictWriter(f_out, fieldnames=fieldnames, lineterminator='\n') + writer.writeheader() + + for row in rows: + ip = row.get('ipv4', '').strip() + if not ip: + writer.writerow(row) + continue + + if ip in ip_to_ch: + new_ch = ip_to_ch[ip] + if band == '2.4': + col = '2.4Ghz_chan' + if col in row: + old_val = row[col].strip() + row[col] = str(new_ch) + updated_count += 1 + if old_val != str(new_ch): + changed_count += 1 + if verbose: + name = row.get('name', ip) + print(f" Updated {name} (IP {ip}): {old_val} → {new_ch} ({col})", file=sys.stderr) + else: + col = '5Ghz_chan' + if col in row: + old_val = row[col].strip() + row[col] = str(new_ch) + updated_count += 1 + if old_val != str(new_ch): + changed_count += 1 + if verbose: + name = row.get('name', ip) + print(f" Updated {name} (IP {ip}): {old_val} → {new_ch} ({col})", file=sys.stderr) + writer.writerow(row) + + print(f"Output CSV written to: {output_path}") + print(f"Rows matched and updated: {updated_count}") + print(f"Rows with actual channel value change: {changed_count}") + + if verbose and changed_count == 0 and updated_count > 0: + print(" (No value changes — suggested channels matched current values in CSV)", file=sys.stderr) + +# ────────────────────────────────────────────────────────────────────────────── +# Command-line parsing +# ────────────────────────────────────────────────────────────────────────────── + +parser = argparse.ArgumentParser( + description="WiFi channel planner using effective dBm power-sum interference model", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Show plan and statistics only + python3 channel_planner.py "scale-public-slow" scan-results.txt + + # Generate plan and update CSV + python3 channel_planner.py "scale-public-fast" scan-results.txt --update apuse.csv + + # Show this help + python3 channel_planner.py --help +""" +) + +parser.add_argument("ssid", help="SSID to analyze") +parser.add_argument("data_file", help="Input file with concatenated iwinfo output") +parser.add_argument("--update", metavar="CSVFILE", + help="CSV file to update with new channels (creates CSVFILE.auto-channel)") + +args = parser.parse_args() + +ssid = args.ssid +data_file = args.data_file +update_csv_path = args.update + +# ────────────────────────────────────────────────────────────────────────────── +# Parse the data file +# ────────────────────────────────────────────────────────────────────────────── + +devices = defaultdict(lambda: {'own_aps': [], 'scans': [], 'lines': []}) + +with open(data_file, 'r') as f: + for line in f: + if not line.strip(): + continue + parts = line.split(None, 1) + if len(parts) < 2: + continue + dev, cont = parts + cont = cont.strip() + if cont: + devices[dev]['lines'].append(cont) + +for dev in devices: + lines = devices[dev]['lines'] + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith('phy') and 'ESSID:' in line: + ap = parse_own_ap(lines, i) + if 'ssid' in ap and 'bssid' in ap and 'channel' in ap and 'band' in ap: + devices[dev]['own_aps'].append(ap) + i = ap['end_index'] + elif line.startswith('Cell '): + scan = parse_scan(lines, i) + if 'bssid' in scan and 'channel' in scan and 'band' in scan and 'signal' in scan: + devices[dev]['scans'].append(scan) + i = scan['end_index'] + else: + i += 1 + +# Keep only first matching own-AP per device + store noise floor +our_aps = {} +bssid_to_dev = {} +noise_floors = {} +for dev in devices: + for ap in devices[dev]['own_aps']: + if ap['ssid'] == ssid: + if dev not in our_aps: + our_aps[dev] = ap + bssid_to_dev[ap['bssid']] = dev + noise_floors[dev] = ap.get('noise_floor', -95) # default if missing + break + +if not our_aps: + print(f"No APs found for SSID: {ssid}") + sys.exit(0) + +bands = {ap['band'] for ap in our_aps.values()} +if len(bands) > 1: + print("Error: SSID operates on multiple bands across devices.") + sys.exit(1) +band = next(iter(bands)) + +# Strongest signal per pair +signals = defaultdict(lambda: defaultdict(lambda: -200)) +for dev1 in devices: + for scan in devices[dev1]['scans']: + if 'ssid' in scan and scan['ssid'] == ssid: + bssid = scan['bssid'] + if bssid in bssid_to_dev: + dev2 = bssid_to_dev[bssid] + if dev1 != dev2: + sig = scan['signal'] + if sig > signals[dev1][dev2]: + signals[dev1][dev2] = sig + +incoming = defaultdict(lambda: defaultdict(lambda: -200)) +for dev1 in signals: + for dev2 in signals[dev1]: + incoming[dev2][dev1] = signals[dev1][dev2] + +devs = list(our_aps.keys()) + +if band == '2.4': + avail = [1, 6, 11] +else: + avail = [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 128, 132, 136, 140, 149, 153, 157, 161, 165] + +# Current assignment from data +current_assignment = {} +for dev in devs: + if dev in our_aps and 'channel' in our_aps[dev]: + current_assignment[dev] = our_aps[dev]['channel'] + else: + print(f"Warning: No current channel found for {dev}", file=sys.stderr) + +# Compute before stats (with real noise floors) +total_eff_current = compute_total_effective(current_assignment, signals, devs) +per_ch_current = compute_per_channel_stats(current_assignment, signals, devs, avail, noise_floors) +co_signals_current = get_cochannel_signals(current_assignment, signals, devs) +hist_current = compute_histogram(co_signals_current) + +# Suggested assignment +num_attempts = 10000 +best_total_eff = float('inf') +best_assignment = None + +for attempt in range(num_attempts): + assignment = assign_channels(devs, avail, signals, incoming) + total_eff = compute_total_effective(assignment, signals, devs) + if total_eff < best_total_eff: + best_total_eff = total_eff + best_assignment = assignment + +# Compute after stats (with real noise floors) +per_ch_suggested = compute_per_channel_stats(best_assignment, signals, devs, avail, noise_floors) +co_signals_suggested = get_cochannel_signals(best_assignment, signals, devs) +hist_suggested = compute_histogram(co_signals_suggested) + +# Output current (before) +print("\nCurrent (Before) Interference Statistics:") +print(f"Total effective interference: {total_eff_current:.2f} dBm") +print("\nPer-Channel Statistics (per-AP experienced):") +for ch in sorted(avail): + info = per_ch_current.get(ch, {'effective_dbm': -120.0, 'ap_count': 0, + 'average_per_ap_dbm': None, 'median_per_ap_dbm': None, + 'worst_per_ap_dbm': None, 'pair_count': 0}) + print(f"Channel {ch}:") + print(f" Channel-wide effective dBm: {info['effective_dbm']:.2f} dBm") + print(f" APs assigned: {info['ap_count']}") + if info['ap_count'] > 0 and info['average_per_ap_dbm'] is not None: + print(f" Average interference per AP: {info['average_per_ap_dbm']:.2f} dBm") + print(f" Median interference per AP: {info['median_per_ap_dbm']:.2f} dBm") + print(f" Worst interference per AP: {info['worst_per_ap_dbm']:.2f} dBm") + else: + print(" No measurable interference on this channel") + print() + +print("\nHistogram of individual co-channel signal strengths (before):") +for bin_name, count in hist_current.items(): + print(f"{bin_name}: {count}") + +# Output suggested (after) +print(f"\nChannel Allocation Plan for SSID: {ssid} (Band: {band} GHz)") +print("Device\t\tSuggested Channel") +for dev in sorted(best_assignment): + print(f"{dev}\t\t{best_assignment[dev]}") + +print(f"\nSuggested (After) Interference Statistics:") +print(f"Total effective interference: {best_total_eff:.2f} dBm") +print("\nPer-Channel Statistics (per-AP experienced):") +for ch in sorted(avail): + info = per_ch_suggested.get(ch, {'effective_dbm': -120.0, 'ap_count': 0, + 'average_per_ap_dbm': None, 'median_per_ap_dbm': None, + 'worst_per_ap_dbm': None, 'pair_count': 0}) + print(f"Channel {ch}:") + print(f" Channel-wide effective dBm: {info['effective_dbm']:.2f} dBm") + print(f" APs assigned: {info['ap_count']}") + if info['ap_count'] > 0 and info['average_per_ap_dbm'] is not None: + print(f" Average interference per AP: {info['average_per_ap_dbm']:.2f} dBm") + print(f" Median interference per AP: {info['median_per_ap_dbm']:.2f} dBm") + print(f" Worst interference per AP: {info['worst_per_ap_dbm']:.2f} dBm") + else: + print(" No measurable interference on this channel") + print() + +print("\nHistogram of individual co-channel signal strengths (after):") +for bin_name, count in hist_suggested.items(): + print(f"{bin_name}: {count}") + +# Update CSV if requested +if args.update: + update_csv(args.update, best_assignment, band, verbose=True) diff --git a/facts/aps/gather-wifi-scans.py b/facts/aps/gather-wifi-scans.py new file mode 100755 index 000000000..6b51c469a --- /dev/null +++ b/facts/aps/gather-wifi-scans.py @@ -0,0 +1,257 @@ +#!/usr/bin/python3 +""" +WiFi Scan Gatherer - Collects iwinfo + iwinfo scan data from multiple APs via SSH (sequential) + +Purpose: + Reads a CSV (default apuse.csv), extracts IP addresses from a specified column, + SSHes to each AP sequentially, runs 'iwinfo' to capture device status, + identifies interfaces broadcasting the target SSID, then runs + 'iwinfo scan' on each (or a specific --radio if provided), + prefixes every line with the hostname, and collects output. + +Usage: + python3 gather-wifi-scans.py --ssid [options] + +Examples: + # Default: scan all matching phy's, output to stdout + python3 gather-wifi-scans.py --ssid scale-public-slow + + # Save to file, verbose progress, 2-second delay between hosts + python3 gather-wifi-scans.py --ssid scale-public-fast -o scans-fast.txt -v -d 2 + + # Specific radio, longer command timeout + python3 gather-wifi-scans.py --ssid scale-public-slow --radio phy0-ap0 --timeout 120 + + # Filter input lines (regex on whole CSV line) + python3 gather-wifi-scans.py --ssid scale-public-slow --filter 'Rm10[1-9]-' -v + + # Use different IP column (by name or number) + python3 gather-wifi-scans.py --ssid scale-public-slow -c ipv4 + python3 gather-wifi-scans.py --ssid scale-public-slow -c 4 + +Sample output lines (both iwinfo status and scans included): + Rm101-a phy0-ap0 ESSID: "scale-public-slow" + Rm101-a Access Point: 20:05:b6:ff:81:24 + Rm101-a Cell 01 - Address: xx:xx:xx:xx:xx:xx + ... + +Full help: python3 gather-wifi-scans.py --help +""" + +import sys +import re +import argparse +import csv +import paramiko +import socket +import time + +def parse_arguments(): + parser = argparse.ArgumentParser( + description="Gather iwinfo + iwinfo scan data from multiple APs via SSH (sequential)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__.split("Sample output lines (both")[0].strip() + ) + parser.add_argument("--ssid", required=True, + help="SSID to look for in iwinfo output (used to find relevant interfaces)") + parser.add_argument("-i", "--input", default="apuse.csv", + help="Input CSV file (default: apuse.csv)") + parser.add_argument("-o", "--output", default=None, + help="Output file for scan results (default: stdout)") + parser.add_argument("-c", "--ip-column", default="3", + help="IP address column: number (1-based) or header name (default: 3)") + parser.add_argument("--filter", default=None, + help="Regex to filter CSV lines before processing (applied to whole line)") + parser.add_argument("--radio", default=None, + help="Specific radio to scan (e.g. phy0-ap0). If omitted, scans all matching phy*") + parser.add_argument("--timeout", type=int, default=60, + help="Max seconds for each SSH command to run (default: 60)") + parser.add_argument("--connect-timeout", type=float, default=5.0, + help="Max seconds to wait for SSH connection (default: 5.0)") + parser.add_argument("-d", "--delay", type=float, default=0.0, + help="Seconds to wait between hosts (default: 0)") + parser.add_argument("-v", "--verbose", action="store_true", + help="Print detailed progress messages to stderr (basic progress is always shown)") + + return parser.parse_args() + +def get_ip_column_index(csv_path, ip_col_spec): + with open(csv_path, 'r', newline='') as f: + reader = csv.reader(f) + header = next(reader, None) + if header is None: + raise ValueError("CSV file is empty") + + try: + col_num = int(ip_col_spec) + if col_num < 1 or col_num > len(header): + raise ValueError(f"Column number {col_num} out of range (1-{len(header)})") + return col_num - 1 # 0-based + except ValueError: + try: + idx = header.index(ip_col_spec) + return idx + except ValueError: + raise ValueError(f"Column '{ip_col_spec}' not found in CSV header: {header}") + +def get_servers(csv_path, ip_col_idx, line_filter=None): + servers = [] + with open(csv_path, 'r', newline='') as f: + reader = csv.reader(f) + next(reader, None) # skip header + for row in reader: + if len(row) <= ip_col_idx: + continue + line = ','.join(row) + if line_filter and not re.search(line_filter, line): + continue + ip = row[ip_col_idx].strip() + if ip: + servers.append(ip) + return servers + +def ssh_scan_host(host, ssid, radio=None, connect_timeout=5.0, cmd_timeout=60, + username="root", verbose=False): + """SSH to host, capture iwinfo + scans, return output lines + error (if any)""" + output_lines = [] + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + if verbose: + print(f"Connecting to {host}...", file=sys.stderr) + client.connect( + host, + username=username, + allow_agent=True, + look_for_keys=True, + timeout=connect_timeout, + banner_timeout=connect_timeout, + auth_timeout=connect_timeout + ) + + # Always capture full plain iwinfo output first + if verbose: + print(f" Capturing plain iwinfo output...", file=sys.stderr) + stdin, stdout, stderr = client.exec_command("iwinfo", timeout=cmd_timeout) + iwinfo_out = stdout.read().decode(errors='ignore') + for line in iwinfo_out.splitlines(): + output_lines.append(f"{host} {line.rstrip()}") + + stderr_out = stderr.read().decode(errors='ignore') + if stderr_out: + output_lines.append(f"{host} ERROR (iwinfo): {stderr_out.rstrip()}") + + # Determine interfaces to scan + interfaces = [] + if radio is None: + for line in iwinfo_out.splitlines(): + if ssid in line and 'ESSID:' in line: + m = re.match(r'(\S+)\s+ESSID:', line.strip()) + if m: + interfaces.append(m.group(1)) + if not interfaces: + raise RuntimeError(f"No interfaces found broadcasting '{ssid}'") + else: + interfaces = [radio] + + if verbose: + print(f" Starting scan on {host} ({len(interfaces)} interface(s))", file=sys.stderr) + + for iface in interfaces: + cmd = f"iwinfo {iface} scan 2>&1" + if verbose: + print(f" Scanning {iface}...", file=sys.stderr) + stdin, stdout, stderr = client.exec_command(cmd, timeout=cmd_timeout) + for line in iter(stdout.readline, ''): + output_lines.append(f"{host} {line.rstrip()}") + stderr_out = stderr.read().decode(errors='ignore') + if stderr_out: + output_lines.append(f"{host} ERROR ({iface} scan): {stderr_out.rstrip()}") + + return output_lines, None + + except socket.timeout: + return [], f"Connection timeout after {connect_timeout}s" + except paramiko.AuthenticationException: + return [], "Authentication failed (check keys)" + except paramiko.SSHException as e: + if 'timeout' in str(e).lower(): + return [], f"Command execution timeout after {cmd_timeout}s" + else: + return [], f"SSH error: {str(e)}" + except RuntimeError as e: + return [], str(e) + except Exception as e: + return [], f"Unexpected error: {str(e)}" + finally: + client.close() + +def main(): + args = parse_arguments() + + # Get IP column index + try: + ip_col_idx = get_ip_column_index(args.input, args.ip_column) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + # Load and filter servers + servers = get_servers(args.input, ip_col_idx, args.filter) + if not servers: + print("No servers found after filtering.", file=sys.stderr) + sys.exit(1) + + print(f"Scanning {len(servers)} hosts for SSID '{args.ssid}' sequentially", file=sys.stderr) + + all_output = [] + failures = [] + + for i, host in enumerate(servers, 1): + print(f"Processing {host}... ({i}/{len(servers)})", file=sys.stderr) # always print basic progress + + output, err = ssh_scan_host( + host=host, + ssid=args.ssid, + radio=args.radio, + connect_timeout=args.connect_timeout, + cmd_timeout=args.timeout, + verbose=args.verbose + ) + + all_output.extend(output) + + if err: + failures.append((host, err)) + if args.verbose: + print(f" → {err}", file=sys.stderr) + else: + if args.verbose: + print(f" → Completed", file=sys.stderr) + + # Delay if requested + if args.delay > 0 and i < len(servers): + if args.verbose: + print(f" Waiting {args.delay}s...", file=sys.stderr) + time.sleep(args.delay) + + # Write output + output_stream = open(args.output, 'w') if args.output else sys.stdout + try: + for line in all_output: + print(line, file=output_stream) + finally: + if args.output: + output_stream.close() + print(f"Scan results saved to: {args.output}", file=sys.stderr) + + # Report failures + if failures: + print("\nHosts that timed out or failed:", file=sys.stderr) + for host, reason in failures: + print(f" {host}: {reason}", file=sys.stderr) + print(f"\nTotal failures: {len(failures)}/{len(servers)}", file=sys.stderr) + +if __name__ == "__main__": + main() From c08ffe9de844d31b69c83f2b2fadf595b4706b14 Mon Sep 17 00:00:00 2001 From: David Lang Date: Fri, 6 Mar 2026 20:56:39 -0800 Subject: [PATCH 3/3] channel reassignment --- facts/aps/apuse.csv | 250 ++++++++++++++++++++++---------------------- 1 file changed, 125 insertions(+), 125 deletions(-) diff --git a/facts/aps/apuse.csv b/facts/aps/apuse.csv index 68fc61bba..57d0dc36a 100644 --- a/facts/aps/apuse.csv +++ b/facts/aps/apuse.csv @@ -1,128 +1,128 @@ name,serial,ipv4,2.4Ghz_chan,5Ghz_chan,config_ver,map_id,map_x,map_y -Rm101-a,o1-0006,10.128.3.100,11,44,0,0,6,23 -Rm101-b,o1-0007,10.128.3.101,11,40,0,0,13,35 -Rm101-c,o1-0008,10.128.3.102,6,100,0,0,17,21 -Rm101-d,o1-0009,10.128.3.103,1,161,0,0,27,28 -Rm101-e,o1-0010,10.128.3.104,11,165,0,0,33,21 -Rm101-f,o1-0011,10.128.3.105,6,157,0,0,32,36 -Hall-9,o1-0012,10.0.3.106,6,161,0,2,87,59 -Rm103-a,o1-0013,10.128.3.107,6,52,0,0,47,20 -Rm103-b,o1-0014,10.128.3.108,11,128,0,0,39,28 -Rm103-c,o1-0017,10.128.3.109,1,36,0,0,43,38 -Rm104-a,o1-0018,10.128.3.110,6,44,0,0,58,19 -Rm104-b,o1-0019,10.128.3.111,11,116,0,0,53,29 -Rm104-c,o1-0020,10.128.3.112,6,64,0,0,64,29 -Rm104-d,o1-0021,10.128.3.113,1,157,0,0,59,38 -Hall-10,o1-0022,10.0.3.114,11,104,0,2,6,46 -Rm105-a,o1-0023,10.128.3.115,1,48,0,0,71,22 -Rm105-b,o1-0024,10.128.3.116,11,104,0,0,81,28 +Rm101-a,o1-0006,10.128.3.100,6,52,0,0,6,23 +Rm101-b,o1-0007,10.128.3.101,11,108,0,0,13,35 +Rm101-c,o1-0008,10.128.3.102,1,132,0,0,17,21 +Rm101-d,o1-0009,10.128.3.103,1,140,0,0,27,28 +Rm101-e,o1-0010,10.128.3.104,11,44,0,0,33,21 +Rm101-f,o1-0011,10.128.3.105,6,120,0,0,32,36 +Hall-9,o1-0012,10.0.3.106,1,112,0,2,87,59 +Rm103-a,o1-0013,10.128.3.107,11,64,0,0,47,20 +Rm103-b,o1-0014,10.128.3.108,1,136,0,0,39,28 +Rm103-c,o1-0017,10.128.3.109,6,149,0,0,43,38 +Rm104-a,o1-0018,10.128.3.110,1,112,0,0,58,19 +Rm104-b,o1-0019,10.128.3.111,11,36,0,0,53,29 +Rm104-c,o1-0020,10.128.3.112,11,100,0,0,64,29 +Rm104-d,o1-0021,10.128.3.113,6,48,0,0,59,38 +Hall-10,o1-0022,10.0.3.114,6,157,0,2,6,46 +Rm105-a,o1-0023,10.128.3.115,1,64,0,0,71,22 +Rm105-b,o1-0024,10.128.3.116,11,56,0,0,81,28 Rm105-c,o1-0025,10.128.3.117,6,56,0,0,75,37 -Rm106-d,o1-0026,10.128.3.118,11,153,0,0,58,84 -Rm106-e,o1-0027,10.128.3.119,11,153,0,0,58,69 -Rm106-a,o1-0028,10.128.3.120,6,128,0,0,79,84 -Rm106-b,o1-0029,10.128.3.121,1,64,0,0,76,68 -Rm106-c,o1-0030,10.128.3.122,1,60,0,0,68,76 -Rm207-c,o1-0032,10.128.3.124,1,116,0,1,64,37 -Rm107-a,o1-0033,10.128.3.125,6,104,0,0,9,84 -Rm107-b,o1-0034,10.128.3.126,1,132,0,0,13,70 -Rm107-c,o1-0035,10.128.3.127,6,140,0,0,19,85 -Rm107-d,o1-0036,10.128.3.128,11,120,0,0,24,70 -Rm107-e,o1-0037,10.128.3.129,1,48,0,0,28,85 -AV-1,o1-0003,10.128.3.130,11,136,0,1,41,17 -AV-2,o1-0004,10.128.3.131,6,64,0,1,40,38 -AV-3,o1-0005,10.128.3.132,11,64,0,1,34,31 -Rm207-b,o1-0039,10.128.3.133,6,60,0,1,69,29 -Rm208-a,o1-0040,10.128.3.134,1,140,0,1,75,19 -Rm208-c,o1-0041,10.128.3.135,6,60,0,1,76,36 -Rm207-a,o1-0042,10.128.3.136,11,120,0,1,64,19 -Rm209-a,o1-0043,10.128.3.137,1,100,0,1,92,17 -Rm209-b,o1-0044,10.128.3.138,11,48,0,1,88,35 -Rm211-a,o1-0045,10.128.3.139,11,153,0,1,92,93 -Rm211-b,o1-0046,10.128.3.140,6,153,0,1,92,79 -Rm211-c,o1-0047,10.128.3.141,1,104,0,1,85,85 -Rm211-d,o1-0048,10.128.3.142,6,56,0,1,79,92 -Rm211-e,o1-0049,10.128.3.143,11,100,0,1,78,79 -Rm208-b,o1-0050,10.128.3.144,11,52,0,1,80,28 -Rm209-c,o1-0053,10.128.3.147,6,161,0,1,95,27 -lowconf-8,o1-0054,10.128.3.148,6,108,0,0,61,63 -Rm215-a,o1-0001,10.128.3.150,11,149,0,1,23,55 -Rm215-b,o1-0002,10.128.3.151,6,112,0,1,32,59 -BallroomA-1,o1-0056,10.0.3.152,1,112,0,2,63,85 -BallroomA-2,o1-0057,10.0.3.153,1,36,0,2,66,74 -BallroomA-3,o1-0058,10.0.3.154,11,120,0,2,70,84 -BallroomA-4,o1-0059,10.0.3.155,11,40,0,2,75,75 -BallroomA-5,o1-0060,10.0.3.156,6,128,0,2,76,84 -BallroomB-1,o1-0061,10.0.3.157,6,149,0,2,63,67 -BallroomB-2,o1-0062,10.0.3.158,11,36,0,2,65,56 -BallroomB-3,o1-0063,10.0.3.159,11,108,0,2,70,66 -BallroomB-4,o1-0064,10.0.3.160,1,128,0,2,73,56 -BallroomB-5,o1-0065,10.0.3.161,6,108,0,2,76,67 -BallroomC-1,o1-0066,10.0.3.162,6,56,0,2,63,50 -BallroomC-2,o1-0067,10.0.3.163,1,165,0,2,66,40 -BallroomC-3,o1-0068,10.0.3.164,1,140,0,2,70,49 -BallroomC-4,o1-0069,10.0.3.165,11,56,0,2,74,40 -BallroomC-5,o1-0070,10.0.3.166,6,64,0,2,77,49 -BallroomDE-1,o1-0071,10.0.3.167,1,112,0,2,37,42 -BallroomDE-2,o1-0072,10.0.3.168,11,104,0,2,41,51 -BallroomDE-3,o1-0073,10.0.3.169,1,149,0,2,45,58 -BallroomDE-4,o1-0074,10.0.3.170,6,165,0,2,38,64 -BallroomDE-5,o1-0075,10.0.3.171,11,44,0,2,43,68 -BallroomDE-6,o1-0076,10.0.3.172,6,116,0,2,49,44 -BallroomDE-7,o1-0077,10.0.3.173,1,40,0,2,57,43 -BallroomDE-8,o1-0078,10.0.3.174,6,132,0,2,52,50 -BallroomDE-9,o1-0079,10.0.3.175,11,100,0,2,56,60 -BallroomDE-10,o1-0080,10.0.3.176,11,140,0,2,53,68 -BallroomF-1,o1-0081,10.0.3.177,6,108,0,2,18,50 -BallroomF-2,o1-0082,10.0.3.178,1,100,0,2,20,40 -BallroomF-3,o1-0083,10.0.3.179,11,44,0,2,24,48 -BallroomF-4,o1-0084,10.0.3.180,6,116,0,2,29,41 -BallroomF-5,o1-0085,10.0.3.181,11,44,0,2,31,49 -BallroomG-1,o1-0086,10.0.3.182,11,136,0,2,32,66 -BallroomG-2,o1-0087,10.0.3.183,6,120,0,2,29,56 -BallroomG-3,o1-0088,10.0.3.184,1,132,0,2,25,67 -BallroomG-4,o1-0089,10.0.3.185,6,112,0,2,21,56 -BallroomG-5,o1-0090,10.0.3.186,11,64,0,2,18,66 -BallroomH-1,o1-0091,10.0.3.187,6,108,0,2,29,75 -BallroomH-2,o1-0092,10.0.3.188,11,161,0,2,31,85 -BallroomH-3,o1-0093,10.0.3.189,6,157,0,2,25,83 -BallroomH-4,o1-0094,10.0.3.190,1,60,0,2,21,75 -BallroomH-5,o1-0095,10.0.3.191,11,132,0,2,18,84 -BallroomI-1,o1-0051,10.0.3.192,11,48,0,2,40,88 -BallroomJ-1,o1-0052,10.0.3.193,6,48,0,2,53,87 -Hall-1,o1-0096,10.0.3.194,1,140,0,2,11,65 -Hall-2,o1-0097,10.0.3.195,11,52,0,2,11,41 -Hall-3,o1-0098,10.0.3.196,6,165,0,2,14,32 -Hall-4,o1-0099,10.0.3.197,1,36,0,2,33,32 -Hall-5,o1-0100,10.0.3.198,6,120,0,2,39,22 -Hall-6,o1-0101,10.0.3.199,11,149,0,2,55,22 -Hall-7,o1-0102,10.0.3.200,11,136,0,2,60,32 -Hall-8,o1-0031,10.0.3.201,1,52,0,2,98,42 -ExpoAW-a,o1-0103,10.0.3.204,11,157,0,3,40,99 -ExpoA1-a,o1-0104,10.0.3.205,1,132,0,3,55,89 -ExpoA2-a,n7a-0086,10.0.3.226,1,116,0,3,55,89 -ExpoC1-a,o1-0105,10.0.3.206,6,161,0,3,23,88 -ExpoC2-a,n8t-0071,10.0.3.227,6,112,0,3,23,88 -ExpoB1-a,n8t-0060,10.0.3.229,11,108,0,0,0,0 -ExpoB2-a,o1-0106,10.0.3.207,11,36,0,3,39,79 -ExpoA3-a,o1-0107,10.0.3.208,6,149,0,3,54,70 -ExpoC3-a,o1-0108,10.0.3.209,1,165,0,3,23,70 -ExpoB3-a,n8c-0012,10.0.3.228,11,48,0,3,38,63 -ExpoB4-a,o1-0109,10.0.3.210,11,104,0,3,38,63 -game-1,o1-0110,10.0.3.211,1,140,0,3,43,21 -game-2,o1-0111,10.0.3.212,6,136,0,3,24,37 -game-3,o1-0112,10.0.3.213,11,52,0,3,55,37 +Rm106-d,o1-0026,10.128.3.118,1,104,0,0,58,84 +Rm106-e,o1-0027,10.128.3.119,6,56,0,0,58,69 +Rm106-a,o1-0028,10.128.3.120,6,52,0,0,79,84 +Rm106-b,o1-0029,10.128.3.121,1,112,0,0,76,68 +Rm106-c,o1-0030,10.128.3.122,11,40,0,0,68,76 +Rm207-c,o1-0032,10.128.3.124,1,40,0,1,64,37 +Rm107-a,o1-0033,10.128.3.125,6,132,0,0,9,84 +Rm107-b,o1-0034,10.128.3.126,1,149,0,0,13,70 +Rm107-c,o1-0035,10.128.3.127,11,128,0,0,19,85 +Rm107-d,o1-0036,10.128.3.128,6,40,0,0,24,70 +Rm107-e,o1-0037,10.128.3.129,1,161,0,0,28,85 +AV-1,o1-0003,10.128.3.130,11,165,0,1,41,17 +AV-2,o1-0004,10.128.3.131,1,100,0,1,40,38 +AV-3,o1-0005,10.128.3.132,6,44,0,1,34,31 +Rm207-b,o1-0039,10.128.3.133,6,120,0,1,69,29 +Rm208-a,o1-0040,10.128.3.134,1,44,0,1,75,19 +Rm208-c,o1-0041,10.128.3.135,11,153,0,1,76,36 +Rm207-a,o1-0042,10.128.3.136,11,140,0,1,64,19 +Rm209-a,o1-0043,10.128.3.137,1,64,0,1,92,17 +Rm209-b,o1-0044,10.128.3.138,6,100,0,1,88,35 +Rm211-a,o1-0045,10.128.3.139,11,56,0,1,92,93 +Rm211-b,o1-0046,10.128.3.140,1,157,0,1,92,79 +Rm211-c,o1-0047,10.128.3.141,6,48,0,1,85,85 +Rm211-d,o1-0048,10.128.3.142,11,108,0,1,79,92 +Rm211-e,o1-0049,10.128.3.143,1,165,0,1,78,79 +Rm208-b,o1-0050,10.128.3.144,11,36,0,1,80,28 +Rm209-c,o1-0053,10.128.3.147,11,165,0,1,95,27 +lowconf-8,o1-0054,10.128.3.148,1,136,0,0,61,63 +Rm215-a,o1-0001,10.128.3.150,11,104,0,1,23,55 +Rm215-b,o1-0002,10.128.3.151,1,112,0,1,32,59 +BallroomA-1,o1-0056,10.0.3.152,1,149,0,2,63,85 +BallroomA-2,o1-0057,10.0.3.153,6,104,0,2,66,74 +BallroomA-3,o1-0058,10.0.3.154,1,40,0,2,70,84 +BallroomA-4,o1-0059,10.0.3.155,11,165,0,2,75,75 +BallroomA-5,o1-0060,10.0.3.156,11,128,0,2,76,84 +BallroomB-1,o1-0061,10.0.3.157,6,140,0,2,63,67 +BallroomB-2,o1-0062,10.0.3.158,1,153,0,2,65,56 +BallroomB-3,o1-0063,10.0.3.159,6,52,0,2,70,66 +BallroomB-4,o1-0064,10.0.3.160,11,36,0,2,73,56 +BallroomB-5,o1-0065,10.0.3.161,11,132,0,2,76,67 +BallroomC-1,o1-0066,10.0.3.162,1,116,0,2,63,50 +BallroomC-2,o1-0067,10.0.3.163,1,120,0,2,66,40 +BallroomC-3,o1-0068,10.0.3.164,6,149,0,2,70,49 +BallroomC-4,o1-0069,10.0.3.165,11,116,0,2,74,40 +BallroomC-5,o1-0070,10.0.3.166,11,104,0,2,77,49 +BallroomDE-1,o1-0071,10.0.3.167,11,136,0,2,37,42 +BallroomDE-2,o1-0072,10.0.3.168,11,161,0,2,41,51 +BallroomDE-3,o1-0073,10.0.3.169,1,112,0,2,45,58 +BallroomDE-4,o1-0074,10.0.3.170,1,100,0,2,38,64 +BallroomDE-5,o1-0075,10.0.3.171,6,64,0,2,43,68 +BallroomDE-6,o1-0076,10.0.3.172,6,60,0,2,49,44 +BallroomDE-7,o1-0077,10.0.3.173,11,100,0,2,57,43 +BallroomDE-8,o1-0078,10.0.3.174,1,112,0,2,52,50 +BallroomDE-9,o1-0079,10.0.3.175,11,116,0,2,56,60 +BallroomDE-10,o1-0080,10.0.3.176,1,60,0,2,53,68 +BallroomF-1,o1-0081,10.0.3.177,1,128,0,2,18,50 +BallroomF-2,o1-0082,10.0.3.178,1,52,0,2,20,40 +BallroomF-3,o1-0083,10.0.3.179,11,153,0,2,24,48 +BallroomF-4,o1-0084,10.0.3.180,11,104,0,2,29,41 +BallroomF-5,o1-0085,10.0.3.181,6,44,0,2,31,49 +BallroomG-1,o1-0086,10.0.3.182,6,140,0,2,32,66 +BallroomG-2,o1-0087,10.0.3.183,1,48,0,2,29,56 +BallroomG-3,o1-0088,10.0.3.184,11,157,0,2,25,67 +BallroomG-4,o1-0089,10.0.3.185,1,132,0,2,21,56 +BallroomG-5,o1-0090,10.0.3.186,11,161,0,2,18,66 +BallroomH-1,o1-0091,10.0.3.187,11,132,0,2,29,75 +BallroomH-2,o1-0092,10.0.3.188,1,60,0,2,31,85 +BallroomH-3,o1-0093,10.0.3.189,11,36,0,2,25,83 +BallroomH-4,o1-0094,10.0.3.190,11,128,0,2,21,75 +BallroomH-5,o1-0095,10.0.3.191,6,120,0,2,18,84 +BallroomI-1,o1-0051,10.0.3.192,11,52,0,2,40,88 +BallroomJ-1,o1-0052,10.0.3.193,1,108,0,2,53,87 +Hall-1,o1-0096,10.0.3.194,1,44,0,2,11,65 +Hall-2,o1-0097,10.0.3.195,6,40,0,2,11,41 +Hall-3,o1-0098,10.0.3.196,11,149,0,2,14,32 +Hall-4,o1-0099,10.0.3.197,6,56,0,2,33,32 +Hall-5,o1-0100,10.0.3.198,1,157,0,2,39,22 +Hall-6,o1-0101,10.0.3.199,11,136,0,2,55,22 +Hall-7,o1-0102,10.0.3.200,6,157,0,2,60,32 +Hall-8,o1-0031,10.0.3.201,11,60,0,2,98,42 +ExpoAW-a,o1-0103,10.0.3.204,6,56,0,3,40,99 +ExpoA1-a,o1-0104,10.0.3.205,1,120,0,3,55,89 +ExpoA2-a,n7a-0086,10.0.3.226,11,116,0,3,55,89 +ExpoC1-a,o1-0105,10.0.3.206,11,36,0,3,23,88 +ExpoC2-a,n8t-0071,10.0.3.227,1,165,0,3,23,88 +ExpoB1-a,n8t-0060,10.0.3.229,11,48,0,0,0,0 +ExpoB2-a,o1-0106,10.0.3.207,6,116,0,3,39,79 +ExpoA3-a,o1-0107,10.0.3.208,6,136,0,3,54,70 +ExpoC3-a,o1-0108,10.0.3.209,11,60,0,3,23,70 +ExpoB3-a,n8c-0012,10.0.3.228,11,153,0,3,38,63 +ExpoB4-a,o1-0109,10.0.3.210,1,64,0,3,38,63 +game-1,o1-0110,10.0.3.211,6,100,0,3,43,21 +game-2,o1-0111,10.0.3.212,1,136,0,3,24,37 +game-3,o1-0112,10.0.3.213,11,44,0,3,55,37 lowconf-1,o1-0113,10.128.3.214,6,112,0,0,16,43 -lowconf-2,o1-0114,10.128.3.215,11,100,0,0,50,44 -lowconf-3,o1-0115,10.128.3.216,11,153,0,0,71,43 -lowconf-4,o1-0116,10.128.3.217,11,165,0,0,29,53 -lowconf-5,o1-0117,10.128.3.218,6,56,0,0,43,53 -lowconf-6,o1-0118,10.128.3.219,1,136,0,0,56,53 -lowconf-7,o1-0119,10.128.3.220,1,40,0,0,19,63 -reghall-1,o1-0120,10.0.3.221,1,132,0,3,84,27 -reghall-2,o1-0015,10.0.3.222,11,40,0,3,95,48 -reghall-3,o1-0016,10.0.3.223,1,120,0,3,83,58 -reghall-4,o1-0038,10.0.3.224,11,157,0,3,94,65 -reghall-5,o1-0055,10.0.3.225,6,60,0,3,84,76 -Rm205-a,n8c-0009,10.128.3.226,1,52,0,1,56,34 -NOC-1,n8t-0052,10.128.3.227,1,116,0,1,26,92 -NOC-2,n8c-0036,10.128.3.228,11,128,0,1,29,80 +lowconf-2,o1-0114,10.128.3.215,1,100,0,0,50,44 +lowconf-3,o1-0115,10.128.3.216,11,116,0,0,71,43 +lowconf-4,o1-0116,10.128.3.217,11,36,0,0,29,53 +lowconf-5,o1-0117,10.128.3.218,11,56,0,0,43,53 +lowconf-6,o1-0118,10.128.3.219,6,108,0,0,56,53 +lowconf-7,o1-0119,10.128.3.220,1,161,0,0,19,63 +reghall-1,o1-0120,10.0.3.221,1,64,0,3,84,27 +reghall-2,o1-0015,10.0.3.222,11,108,0,3,95,48 +reghall-3,o1-0016,10.0.3.223,6,140,0,3,83,58 +reghall-4,o1-0038,10.0.3.224,11,161,0,3,94,65 +reghall-5,o1-0055,10.0.3.225,1,40,0,3,84,76 +Rm205-a,n8c-0009,10.128.3.226,11,153,0,1,56,34 +NOC-1,n8t-0052,10.128.3.227,11,128,0,1,26,92 +NOC-2,n8c-0036,10.128.3.228,1,48,0,1,29,80