Thread network diagnostics tool, anyone using it yet?

Claude got it working for me on Windows 11. Unfortunately while I tried both WSL and Windows Sandbox, apparently both interfere with the mDNS requests that need to go out to scan the network.

First you have to install Apple’s Bonjour SDK for Windows which includes a version of dns-sd. This can be obtained through winget. Trying to download from Apple requires a developer account.

winget install -e --source winget --id Apple.Bonjour

Then you can run the Python script. Note for a large network, it takes a long time to complete. It was roughly a minute on my network. Perhaps that’s waiting for responses from battery-powered devices? I don’t really know, but the Thread Tools app takes a pretty long time too, so I guess there’s a good reason for it.

matter-map-windows-bonjour-dns-sd.py
#!/usr/bin/env python3

import subprocess
import json
import sys
import re
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed

# ---------------- configuration ----------------
CACHE_TTL = 300  # seconds for hostname cache
MAX_THREADS = 10  # max parallel DNS resolution threads

# ---------------- helpers ----------------

def run(cmd):
    return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)


def get_locations():
    locations = json.loads(run(["smartthings", "locations", "-j"]))
    id_to_name = {}
    name_to_id = {}

    for loc in locations:
        id_to_name[loc["locationId"]] = loc["name"]
        name_to_id[loc["name"]] = loc["locationId"]

    return id_to_name, name_to_id


def get_matter_devices(location_id=None):
    cmd = ["smartthings", "devices", "--type", "MATTER", "-j"]
    if location_id:
        cmd += ["--location", location_id]

    devices = json.loads(run(cmd))
    results = []

    for d in devices:
        network_id = d.get("matter", {}).get("networkId")
        if not network_id:
            continue

        results.append({
            "label": d.get("label", "UNKNOWN"),
            "instance": network_id,
            "locationId": d.get("locationId"),
            "hostname": "",  # will be filled by DNS-SD discovery
            "ipv4": "",
            "ipv6": "",
        })

    return results


# dns-sd (Bonjour for Windows / Bonjour SDK for Windows) is used instead of
# Avahi for mDNS/DNS-SD discovery. Unlike `avahi-browse -rtpk`, which does a
# one-shot resolve-then-terminate, `dns-sd -B`/`-L`/`-G` run continuously, so
# each call is wrapped with a timeout and the process is killed once enough
# time has passed for replies to arrive.
BROWSE_TIMEOUT = 4   # seconds to browse for _matter._tcp instances
LOOKUP_TIMEOUT = 3   # seconds to resolve an instance to a hostname:port
RESOLVE_TIMEOUT = 3  # seconds to resolve a hostname to A/AAAA records


def run_timed(cmd, timeout, debug_dns=False):
    """Run a long-lived dns-sd command, collecting output until timeout."""
    if debug_dns:
        print(f"[DEBUG] Running command: {' '.join(cmd)} (timeout={timeout}s)")
    proc = subprocess.Popen(
        cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True
    )
    try:
        out, _ = proc.communicate(timeout=timeout)
    except subprocess.TimeoutExpired:
        proc.kill()
        out, _ = proc.communicate()
    return out


def get_dns_sd_data(debug_dns=False):
    """Discover Matter services using dns-sd (Bonjour for Windows).

    Browses for _matter._tcp instances, then resolves each instance to a
    hostname via `dns-sd -L`, then resolves that hostname to IPv4/IPv6
    addresses via `dns-sd -G v4v6`.
    """
    data = defaultdict(dict)

    browse_out = run_timed(
        ["dns-sd", "-B", "_matter._tcp", "local"], BROWSE_TIMEOUT, debug_dns
    )

    # dns-sd -B output columns:
    # Timestamp  A/R  Flags  if  Domain  Service Type  Instance Name
    instances = set()
    for raw_line in browse_out.splitlines():
        line = raw_line.strip()
        if not line or line.startswith(("Browsing", "DATE:", "Timestamp")):
            continue
        parts = line.split(None, 6)
        if len(parts) < 7:
            if debug_dns:
                print(f"[DEBUG] Skipping unparsable browse line: {raw_line}")
            continue
        action, instance_name = parts[1], parts[6]
        if action != "Add":
            continue
        instances.add(instance_name)

    for instance in instances:
        lookup_out = run_timed(
            ["dns-sd", "-L", instance, "_matter._tcp", "local"],
            LOOKUP_TIMEOUT,
            debug_dns,
        )
        # Example line: "... can be reached at MyDevice.local.:5540 (interface 4)"
        match = re.search(r"can be reached at\s+(\S+):(\d+)", lookup_out)
        if not match:
            if debug_dns:
                print(f"[DEBUG] No resolution found for instance: {instance}")
            continue

        hostname = match.group(1).rstrip(".")
        data[instance]["hostname"] = hostname

        ipv4, ipv6 = resolve_hostname_cached(hostname, debug_dns)
        if ipv4:
            data[instance]["ipv4"] = ipv4
        if ipv6:
            data[instance]["ipv6"] = ipv6

    return data


# ---------------- hostname cache ----------------
hostname_cache = {}  # hostname -> (ipv4, ipv6, timestamp)


def resolve_hostname_cached(hostname, debug_dns=False):
    now = time.time()
    if hostname in hostname_cache:
        ipv4, ipv6, ts = hostname_cache[hostname]
        if now - ts < CACHE_TTL:
            return ipv4, ipv6  # use cached

    ipv4, ipv6 = "", ""

    out = run_timed(["dns-sd", "-G", "v4v6", hostname], RESOLVE_TIMEOUT, debug_dns)
    # dns-sd -G output columns:
    # Timestamp  A/R  Flags  if  Hostname  Address  TTL
    for raw_line in out.splitlines():
        line = raw_line.strip()
        if not line or line.startswith(("Timestamp", "DATE:")):
            continue
        parts = line.split()
        if len(parts) < 6:
            continue
        action, addr = parts[1], parts[-2]
        if action != "Add":
            continue
        if ":" in addr:
            ipv6 = addr
        elif re.match(r"^\d+\.\d+\.\d+\.\d+$", addr):
            ipv4 = addr

    hostname_cache[hostname] = (ipv4, ipv6, now)
    return ipv4, ipv6


# ---------------- help ----------------
def print_help():
    print("""Usage: matter-map [LOCATION] [OPTIONS]

Positional arguments:
  LOCATION          Filter devices by location name

Options:
  --help            Show this help message and exit
  --sort=FIELD      Sort by field: label, location, instance, hostname, ipv4, ipv6
  --reverse         Reverse sort order
  --debug-dns       Print debug info for dns-sd (Bonjour) queries""")


# ---------------- main ----------------
def main():
    sort_key = "label"
    reverse = False
    location_arg = None
    debug_dns = False

    args = sys.argv[1:]
    positional_args = []

    for arg in args:
        if arg == "--help":
            print_help()
            sys.exit(0)
        elif arg == "--reverse":
            reverse = True
        elif arg == "--debug-dns":
            debug_dns = True
        elif arg.startswith("--sort="):
            sort_key = arg.split("=", 1)[1]
        elif arg.startswith("-"):
            print(f"Unknown option: {arg}", file=sys.stderr)
            sys.exit(1)
        else:
            positional_args.append(arg)

    if len(positional_args) > 1:
        print("Error: only one location name may be specified", file=sys.stderr)
        sys.exit(1)
    if positional_args:
        location_arg = positional_args[0]

    valid_sorts = {"label", "location", "instance", "hostname", "ipv4", "ipv6"}
    if sort_key not in valid_sorts:
        print(f"Invalid sort key: {sort_key}", file=sys.stderr)
        print(f"Valid options: {', '.join(sorted(valid_sorts))}", file=sys.stderr)
        sys.exit(1)

    id_to_name, name_to_id = get_locations()
    location_id = None
    if location_arg:
        location_id = name_to_id.get(location_arg)
        if not location_id:
            print("Unknown location", file=sys.stderr)
            sys.exit(1)

    devices = get_matter_devices(location_id)
    dns_data = get_dns_sd_data(debug_dns)

    # Merge DNS-SD data into devices
    for d in devices:
        dns = dns_data.get(d["instance"], {})
        d["hostname"] = dns.get("hostname", "UNKNOWN")
        d["ipv4"] = dns.get("ipv4", "")
        d["ipv6"] = dns.get("ipv6", "")
        d["location"] = id_to_name.get(d.get("locationId"), "UNKNOWN")

    # Resolve missing dual-stack IPs in parallel
    to_resolve = [d for d in devices if d["hostname"] != "UNKNOWN"]
    with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
        future_to_device = {
            executor.submit(resolve_hostname_cached, d["hostname"], debug_dns): d for d in to_resolve
        }
        for future in as_completed(future_to_device):
            d = future_to_device[future]
            try:
                ipv4, ipv6 = future.result()
                d["ipv4"] = ipv4 or d["ipv4"]
                d["ipv6"] = ipv6 or d["ipv6"]
            except Exception:
                pass

    # Sort and group
    devices.sort(key=lambda r: r.get(sort_key, ""), reverse=reverse)
    grouped = defaultdict(list)
    for r in devices:
        grouped[r.get("location", "UNKNOWN")].append(r)

    # Print output
    print(
        f"{'DEVICE LABEL':<25} {'LOCATION':<15} {'INSTANCE NAME/NETWORK ID':<40} "
        f"{'HOSTNAME':<30} {'IPv6 ADDRESS':<40} {'IPv4 ADDRESS':<20}"
    )
    print("-" * 180)

    for location in sorted(grouped):
        for r in grouped[location]:
            print(
                f"{r['label']:<25} "
                f"{location:<15} "
                f"{r['instance']:<40} "
                f"{r['hostname']:<30} "
                f"{r['ipv6']:<40} "
                f"{r['ipv4']:<20}"
            )


if __name__ == "__main__":
    main()

Also in case anyone else is like me and had trouble applying their first patch file for the avahi script version from @Andreas_Roedl , the patch command and resulting complete file are:

patch matter-map-avahi.py -i matter-map-avahi.patch

matter-map-linux-avahi.py
#!/usr/bin/env python3

import subprocess
import json
import sys
import re
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed

# ---------------- configuration ----------------
CACHE_TTL = 300  # seconds for hostname cache
MAX_THREADS = 10  # max parallel DNS resolution threads

# ---------------- helpers ----------------

def run(cmd):
    return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)


def get_locations():
    locations = json.loads(run(["smartthings", "locations", "-j"]))
    id_to_name = {}
    name_to_id = {}

    for loc in locations:
        id_to_name[loc["locationId"]] = loc["name"]
        name_to_id[loc["name"]] = loc["locationId"]

    return id_to_name, name_to_id


def get_matter_devices(location_id=None):
    cmd = ["smartthings", "devices", "--type", "MATTER", "-j"]
    if location_id:
        cmd += ["--location", location_id]

    devices = json.loads(run(cmd))
    results = []

    for d in devices:
        network_id = d.get("matter", {}).get("networkId")
        if not network_id:
            continue

        results.append({
            "label": d.get("label", "UNKNOWN"),
            "instance": network_id,
            "locationId": d.get("locationId"),
            "hostname": "",  # will be filled by DNS-SD discovery
            "ipv4": "",
            "ipv6": "",
        })

    return results


def unescape_avahi_label(value):
    """Undo avahi-browse -p escaping for service names/labels.

    avahi-browse uses DNS label escaping. This function handles both
    backslash-escaped single characters (e.g. '\\;') and 3-digit decimal
    escapes (e.g. '\\032' for space).
    """
    out = []
    i = 0
    while i < len(value):
        ch = value[i]
        if ch != "\\":
            out.append(ch)
            i += 1
            continue

        if i + 3 < len(value) and value[i + 1:i + 4].isdigit():
            out.append(chr(int(value[i + 1:i + 4], 10)))
            i += 4
        elif i + 1 < len(value):
            out.append(value[i + 1])
            i += 2
        else:
            out.append("\\")
            i += 1

    return "".join(out)


def get_dns_sd_data(debug_dns=False):
    """Discover Matter services using Avahi instead of dns-sd.

    Uses avahi-browse in parsable mode so the output is stable and easy to
    consume from scripts.
    """
    cmd = ["avahi-browse", "-rtpk", "_matter._tcp"]
    if debug_dns:
        print(f"[DEBUG] Running command: {' '.join(cmd)}")
    output = run(cmd)

    data = defaultdict(dict)

    for raw_line in output.splitlines():
        line = raw_line.strip()
        if not line or not line.startswith("="):
            continue

        # Resolved parsable format from avahi-browse:
        # =;iface;proto;name;type;domain;host;address;port;txt
        parts = line.split(";", 9)
        if len(parts) < 9:
            if debug_dns:
                print(f"[DEBUG] Skipping unparsable avahi line: {raw_line}")
            continue

        _, _iface, _proto, instance, service_type, _domain, hostname, addr, _port, *_rest = parts

        if service_type != "_matter._tcp":
            continue

        instance = unescape_avahi_label(instance)
        hostname = hostname.strip()
        addr = addr.strip()

        if hostname:
            data[instance]["hostname"] = hostname

        if addr:
            if ":" in addr:
                data[instance]["ipv6"] = addr
            else:
                data[instance]["ipv4"] = addr

    return data


# ---------------- hostname cache ----------------
hostname_cache = {}  # hostname -> (ipv4, ipv6, timestamp)


def resolve_hostname_cached(hostname):
    now = time.time()
    if hostname in hostname_cache:
        ipv4, ipv6, ts = hostname_cache[hostname]
        if now - ts < CACHE_TTL:
            return ipv4, ipv6  # use cached

    ipv4, ipv6 = "", ""
    try:
        # Resolve IPv4
        result = run(["avahi-resolve", "-n", "-4", hostname]).strip()
        if result and "\t" in result:
            ipv4 = result.split("\t", 1)[1]
    except subprocess.CalledProcessError:
        ipv4 = ""

    try:
        # Resolve IPv6
        result = run(["avahi-resolve", "-n", "-6", hostname]).strip()
        if result and "\t" in result:
            ipv6 = result.split("\t", 1)[1]
    except subprocess.CalledProcessError:
        ipv6 = ""

    hostname_cache[hostname] = (ipv4, ipv6, now)
    return ipv4, ipv6


# ---------------- help ----------------
def print_help():
    print("""Usage: matter-map [LOCATION] [OPTIONS]

Positional arguments:
  LOCATION          Filter devices by location name

Options:
  --help            Show this help message and exit
  --sort=FIELD      Sort by field: label, location, instance, hostname, ipv4, ipv6
  --reverse         Reverse sort order
  --debug-dns       Print debug info for Avahi/DNS-SD queries""")


# ---------------- main ----------------
def main():
    sort_key = "label"
    reverse = False
    location_arg = None
    debug_dns = False

    args = sys.argv[1:]
    positional_args = []

    for arg in args:
        if arg == "--help":
            print_help()
            sys.exit(0)
        elif arg == "--reverse":
            reverse = True
        elif arg == "--debug-dns":
            debug_dns = True
        elif arg.startswith("--sort="):
            sort_key = arg.split("=", 1)[1]
        elif arg.startswith("-"):
            print(f"Unknown option: {arg}", file=sys.stderr)
            sys.exit(1)
        else:
            positional_args.append(arg)

    if len(positional_args) > 1:
        print("Error: only one location name may be specified", file=sys.stderr)
        sys.exit(1)
    if positional_args:
        location_arg = positional_args[0]

    valid_sorts = {"label", "location", "instance", "hostname", "ipv4", "ipv6"}
    if sort_key not in valid_sorts:
        print(f"Invalid sort key: {sort_key}", file=sys.stderr)
        print(f"Valid options: {', '.join(sorted(valid_sorts))}", file=sys.stderr)
        sys.exit(1)

    id_to_name, name_to_id = get_locations()
    location_id = None
    if location_arg:
        location_id = name_to_id.get(location_arg)
        if not location_id:
            print("Unknown location", file=sys.stderr)
            sys.exit(1)

    devices = get_matter_devices(location_id)
    dns_data = get_dns_sd_data(debug_dns)

    # Merge DNS-SD data into devices
    for d in devices:
        dns = dns_data.get(d["instance"], {})
        d["hostname"] = dns.get("hostname", "UNKNOWN")
        d["ipv4"] = dns.get("ipv4", "")
        d["ipv6"] = dns.get("ipv6", "")
        d["location"] = id_to_name.get(d.get("locationId"), "UNKNOWN")

    # Resolve missing dual-stack IPs in parallel
    to_resolve = [d for d in devices if d["hostname"] != "UNKNOWN"]
    with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
        future_to_device = {executor.submit(resolve_hostname_cached, d["hostname"]): d for d in to_resolve}
        for future in as_completed(future_to_device):
            d = future_to_device[future]
            try:
                ipv4, ipv6 = future.result()
                d["ipv4"] = ipv4 or d["ipv4"]
                d["ipv6"] = ipv6 or d["ipv6"]
            except Exception:
                pass

    # Sort and group
    devices.sort(key=lambda r: r.get(sort_key, ""), reverse=reverse)
    grouped = defaultdict(list)
    for r in devices:
        grouped[r.get("location", "UNKNOWN")].append(r)

    # Print output
    print(
        f"{'DEVICE LABEL':<25} {'LOCATION':<15} {'INSTANCE NAME/NETWORK ID':<40} "
        f"{'HOSTNAME':<30} {'IPv6 ADDRESS':<40} {'IPv4 ADDRESS':<20}"
    )
    print("-" * 180)

    for location in sorted(grouped):
        for r in grouped[location]:
            print(
                f"{r['label']:<25} "
                f"{location:<15} "
                f"{r['instance']:<40} "
                f"{r['hostname']:<30} "
                f"{r['ipv6']:<40} "
                f"{r['ipv4']:<20}"
            )


if __name__ == "__main__":
    main()

Fwiw, I see the original script using dns-sd still uses avahi-resolve. I would guess anyone who has avahi-resolve will also be able to get avahi-browse? And just in case the dropbox link disappears at some point, here is that file, too:

matter-map-linux-dns-sd.py
#!/usr/bin/env python3

import subprocess
import json
import sys
import re
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed

# ---------------- configuration ----------------
CACHE_TTL = 300  # seconds for hostname cache
MAX_THREADS = 10  # max parallel DNS resolution threads

# ---------------- helpers ----------------

def run(cmd):
    return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)

def get_locations():
    locations = json.loads(run(["smartthings", "locations", "-j"]))
    id_to_name = {}
    name_to_id = {}

    for loc in locations:
        id_to_name[loc["locationId"]] = loc["name"]
        name_to_id[loc["name"]] = loc["locationId"]

    return id_to_name, name_to_id

def get_matter_devices(location_id=None):
    cmd = ["smartthings", "devices", "--type", "MATTER", "-j"]
    if location_id:
        cmd += ["--location", location_id]

    devices = json.loads(run(cmd))
    results = []

    for d in devices:
        network_id = d.get("matter", {}).get("networkId")
        if not network_id:
            continue

        results.append({
            "label": d.get("label", "UNKNOWN"),
            "instance": network_id,
            "locationId": d.get("locationId"),
            "hostname": "",  # will be filled by DNS
            "ipv4": "",
            "ipv6": "",
        })

    return results

def get_dns_sd_data(debug_dns=False):
    cmd = ["dns-sd", "-t", "-r", "_matter._tcp"]
    if debug_dns:
        print(f"[DEBUG] Running command: {' '.join(cmd)}")
    output = run(cmd)
    lines = output.splitlines()

    data = defaultdict(dict)
    current = None
    instance_re = re.compile(r"=\s+\S+\s+(IPv4|IPv6)\s+(\S+)\s+_matter\._tcp")

    for line in lines:
        m = instance_re.match(line)
        if m:
            current = m.group(2)
            continue

        if not current:
            continue

        if "hostname =" in line:
            data[current]["hostname"] = line.split("[")[1].split("]")[0]
        elif "address =" in line:
            addr = line.split("[")[1].split("]")[0]
            if ":" in addr:
                data[current]["ipv6"] = addr
            else:
                data[current]["ipv4"] = addr

    return data

# ---------------- hostname cache ----------------
hostname_cache = {}  # hostname -> (ipv4, ipv6, timestamp)

def resolve_hostname_cached(hostname):
    now = time.time()
    if hostname in hostname_cache:
        ipv4, ipv6, ts = hostname_cache[hostname]
        if now - ts < CACHE_TTL:
            return ipv4, ipv6  # use cached

    ipv4, ipv6 = "", ""
    try:
        # Resolve IPv4
        result = run(["avahi-resolve", "-n", "-4", hostname]).strip()
        if result and "\t" in result:
            ipv4 = result.split("\t")[1]
    except subprocess.CalledProcessError:
        ipv4 = ""

    try:
        # Resolve IPv6
        result = run(["avahi-resolve", "-n", "-6", hostname]).strip()
        if result and "\t" in result:
            ipv6 = result.split("\t")[1]
    except subprocess.CalledProcessError:
        ipv6 = ""

    hostname_cache[hostname] = (ipv4, ipv6, now)
    return ipv4, ipv6

# ---------------- help ----------------
def print_help():
    print("""Usage: matter-map [LOCATION] [OPTIONS]

Positional arguments:
  LOCATION          Filter devices by location name

Options:
  --help            Show this help message and exit
  --sort=FIELD      Sort by field: label, location, instance, hostname, ipv4, ipv6
  --reverse         Reverse sort order
  --debug-dns       Print debug info for DNS queries""")

# ---------------- main ----------------
def main():
    sort_key = "label"
    reverse = False
    location_arg = None
    debug_dns = False

    args = sys.argv[1:]
    positional_args = []

    for arg in args:
        if arg == "--help":
            print_help()
            sys.exit(0)
        elif arg == "--reverse":
            reverse = True
        elif arg == "--debug-dns":
            debug_dns = True
        elif arg.startswith("--sort="):
            sort_key = arg.split("=", 1)[1]
        elif arg.startswith("-"):
            print(f"Unknown option: {arg}", file=sys.stderr)
            sys.exit(1)
        else:
            positional_args.append(arg)

    if len(positional_args) > 1:
        print("Error: only one location name may be specified", file=sys.stderr)
        sys.exit(1)
    if positional_args:
        location_arg = positional_args[0]

    valid_sorts = {"label", "location", "instance", "hostname", "ipv4", "ipv6"}
    if sort_key not in valid_sorts:
        print(f"Invalid sort key: {sort_key}", file=sys.stderr)
        print(f"Valid options: {', '.join(sorted(valid_sorts))}", file=sys.stderr)
        sys.exit(1)

    id_to_name, name_to_id = get_locations()
    location_id = None
    if location_arg:
        location_id = name_to_id.get(location_arg)
        if not location_id:
            print("Unknown location", file=sys.stderr)
            sys.exit(1)

    devices = get_matter_devices(location_id)
    dns_data = get_dns_sd_data(debug_dns)

    # Merge DNS SD data into devices
    for d in devices:
        dns = dns_data.get(d["instance"], {})
        d["hostname"] = dns.get("hostname", "UNKNOWN")
        d["ipv4"] = dns.get("ipv4", "")
        d["ipv6"] = dns.get("ipv6", "")

    # Resolve missing dual-stack IPs in parallel
    to_resolve = [d for d in devices if d["hostname"] != "UNKNOWN"]
    with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
        future_to_device = {executor.submit(resolve_hostname_cached, d["hostname"]): d for d in to_resolve}
        for future in as_completed(future_to_device):
            d = future_to_device[future]
            try:
                ipv4, ipv6 = future.result()
                d["ipv4"] = ipv4 or d["ipv4"]
                d["ipv6"] = ipv6 or d["ipv6"]
            except Exception:
                d["ipv4"], d["ipv6"] = d["ipv4"], d["ipv6"]

    # Sort and group
    devices.sort(key=lambda r: r.get(sort_key, ""), reverse=reverse)
    grouped = defaultdict(list)
    for r in devices:
        grouped[r.get("locationId") and id_to_name.get(r["locationId"], "UNKNOWN") or "UNKNOWN"].append(r)

    # Print output
    print(
        f"{'DEVICE LABEL':<25} {'LOCATION':<15} {'INSTANCE NAME/NETWORK ID':<40} "
        f"{'HOSTNAME':<30} {'IPv6 ADDRESS':<40} {'IPv4 ADDRESS':<20}"
    )
    print("-" * 180)

    for location in sorted(grouped):
        for r in grouped[location]:
            print(
                f"{r['label']:<25} "
                f"{location:<15} "
                f"{r['instance']:<40} "
                f"{r['hostname']:<30} "
                f"{r['ipv6']:<40} "
                f"{r['ipv4']:<20}"
            )

if __name__ == "__main__":
    main()