Thread network diagnostics tool, anyone using it yet?

Thank you. I disabled then enabled ipv6 in my router’s settings. That seems to have fixed it.

I tried it, and found it to be very intermittent. One day it sees my SmartThings Thread network and one or two of my three Amazon Echos out of which one actually does run its Thread capability, on other days it doesn’t see them all. One day it does connect to the ST and shows me devices - how many of them is also somewhat random - and the other day it says it doesn’t have the credentials.

Conclusion: This sure is nice and useful, but reliability is nowhere near good as yet.

I’m no expert, but it seems the app is affected by your ISP router’s ipv6 settings.

The app froze on me again today. So I disabled wan ipv6 (I left lan ipv6 enabled), and the app is working again.

In fact, the diagnostic results for my thread network after disabling wan ipv6 were the best so far.

It seems there may be an issue with my router’s wan ipv6 implementation.

I had the app not open, so I disabled ipv6 on my router then it would open. Obviously this caused other problems for example my blinds went offline. So I re-enabled ipv6 and rebooted my router and points. This fixed the problem.

WOW. Thanks again. I pulled the csv through my Gemini (pro)

Also some nonsense, but still.

Still a mixed bag here. This week it decided it doesn’t know the credentials to my ST Thread network (it did before). But now it did detect my Amazon Thread network in full, confirming that my three Thread-capable Amazon Echos do run one common Thread network. Doesn’t have credentials for that one either.

The app wasn’t opening for me sometimes. So I sent feedback through the Play Store.

They must have tweaked something because it’s now opening and working reliably for me.

Anyway, you may want to send feedback.

It’s not going to find credentials for the Amazon Thread network because Amazon doesn’t share credentials through the credential sharing mechanisms in Android and iOS.

Additionally, as I posted previously, the diag tool incorrectly uses network name to identify the Thread network so if you change your network name and don’t commission a new device, it will be unable to find the credentials as well.

For the Amazon network I did expect the credentials issue; I’m still hoping for the day Amazon implements merging their Thread networks with non-Amazon ones.

For my ST network, I have not done anything to it between the tool being able to connect and it now not being able to connect.

Thread Network Diagnostics App is deprecated

New App

Ive not tried it. Is the new app much different other than rebranding ?

It seems to be the same. The only change seems to be added support for iOS.

Perhaps a little more chatty while collecting the data, but I may have forgotten what it was like. Otherwise seems the same. I still get a mass of ‘good’ connections and just a couple of ‘fair’ connections.

Given Gemini’s summary I wouldn’t like to have a bad connection.

It remains a good start but it needs to be self-sufficient in analysis.

I’ve yet to see the vendor information be anything but ‘N/A’ or empty.

Those fields come from the optional Thread 1.4 Enhanced Network Diagnostics (a low-level Thread feature). Many manufacturers simply don’t populate them, because the proper manufacturer, vendor, and model information lives in the Matter Basic Information Cluster instead.

In Thread 1.4+, there are enhanced network diagnostics that can include: Vendor Name, Vendor Model, Vendor Software Version.

These are settable via OpenThread APIs like otThreadSetVendorName/otThreadSetVendorModel and exposed in network diagnostics.

I’m finding the new version far less reliable. It often can’t find all of my TBR’s and when it scans my ST Thread network, it is inconsistent in how many devices it finds.

I only have 5 thread devices including my ST Station hub. I usually shows the TBR and 2 repeater. Some things it shows 1 of 2 end devices and sometimes it shows none. It has never shown both end devices.

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()

I played around with the Thread Tools beta on iOS…if saw that I only have one mesh. AppleTV 4k 2nd Gen (when the small mem box still had gigabit ethernet). Was holding out for a 4th gen, now I’m not sure… The $100 jump seems disproportionate…maybe I’ll go back to Roku. Smartthings can control a Roku to some extent (or has that gotten even better? Or would I need an IR blaster still.)

And, I only have one thread device. Aqara Thread/Zigbee Smart Climate Sensor W100, Temperature and Humidity Monitoring, Wireless Remote HVAC Control, 3 Customizable Smart Buttons, works with HomeKit, Alexa, Google and more.

Think I tried adding it to threads on SmartThings, even though I only have a V2 Hub and it worked, Though I think I had go back and add it to AppleTV first and then share it to SmartThings, so that it could get shared. Think that has been the norm with Matter over Wifi as well. There’s probably a way to get the phone camera to see the QR code screen instead of having to print the QR code first, but I can never remember to find out before I need to again. I think it’s about where it gets into the keychain that is the issue. Of course, I’ll keep the AppleTV, it’s part of a chain that makes some of my HomeKit automations work. Another is a pushcut automation server that runs on a dedicated iPhone…(not so dedicated at the moment, as 26.5.2 pushed my main phone over the edge and it’s been off for over a week now. Didn’t occur to me until just before my service appointment that I needed something to uber to and from with. And, yet another thing that uses that cell number for 2fa, that I can’t get into. number sync only syncs voice calls, it relies on iMessage sharing to make messages accessible on apple watch…otherwise, there’d be lots of duplicates that iMessage would need to deal with (or perhaps it does already? for other carriers?). Need to figure out if the number that apple watch calls out as requires iPhone nearby and on to use that number or does it always originate using its own number…need to find out…for future reference…

I had picked up an Aqara hub around the same time as I got this…but haven’t decided if I’ll need it or if there’s benefit in having it. But, I have lots of their temperature and humidity and motion sensors in SmartThings. I had reduced a bunch of individual virtual switches into banks of 5 switches… since these don’t need Alexa to know about, just some that can be controlled from Alexa to have Smartthings apply and/or/xor logic to things. Though a switchback that could control Alexa could also be useful…as I’m using 9 virtual switches in an only one switch out of 9 can be on, where the 9th switch is actually part of another set of 4, where only one can be on.

The panel is part of the control of a smart fan (and a smart humidifier). Which can only be controlled by Alexa…so lots of virtual switches and tweaking of timing, since the fan doesn’t like to be told to do things all at once. Don’t know why timing has to be ignored when testing…it’s exactly the thing I’m trying to test. And never get the fan into an unknown state that can’t be recovered from easily… it takes a little over a minute to recalibrate the fan positioning. And, that requires its app to do.

Odd, now the app is saying no credentials found in my shared keychain…did they change to looking in there? It worked the other day. Oh, it’s not connected to my wifi, wonder why that makes the difference…wonder what happens if I have AppleTV in a different vlan from all my other IoT devices. (it’ll be in a vlan for streaming devices…since IoT vlan will have lower priority compared to most other vlans.). Should be interesting if I decide to dual home my network again. Wonder if I’d mess around with trying to get vrrp working again. still trying to wrap my head around multicast bridging or multicast repeating or using MVR. my Mac will keep changing its hostname, because it’ll announce to all connected vlans its info, which is then repeated onto all the other vlans…which it sees as duplicate host on the network requiring it to change…and change…and change…

Anyways, this time It asked if it could connect to my Home network (which I had already granted yesterday.). The phone I had installed from is currently dead, which it seemed to point to as the issue, but not being able to connect to my home network was the real issue. Wonder if this means one could use an old phone as a TBR?

Thanks, Grok!

Here’s a clear, structured breakdown of that comment (post #78 in the thread, by user TheDreamer).

Context of the thread

The overall thread is about the Thread Network Diagnostics tool (a beta feature, initially Android-focused, with an iOS version also available). People are using it to view Thread mesh topology, link quality (good/fair/poor), download dumps, and even feed the data into AI tools like Gemini or ChatGPT for analysis.

What the comment is actually saying

The poster is rambling in a stream-of-consciousness style while experimenting with the Thread Tools beta on iOS. Here’s the coherent content, grouped by topic:

  1. Their Thread network is extremely minimal

    • The diagnostics tool shows only one mesh.
    • The only Thread Border Router (TBR) is an Apple TV 4K (2nd generation) — the model that still had Gigabit Ethernet.
    • They only have one Thread device: an Aqara Thread/Zigbee Smart Climate Sensor W100 (temperature/humidity sensor + remote buttons that also works with HomeKit, Alexa, Google, etc.).
  2. How they added the Aqara sensor

    • Even though they only have a SmartThings V2 Hub, they managed to get the sensor onto Thread.
    • The usual workaround was required: add it first via the Apple TV (HomeKit/Thread), then share/commission it into SmartThings.
    • This is the common pattern for Matter-over-Thread (and even some Matter-over-WiFi) devices when mixing ecosystems.
  3. Hardware thoughts

    • They’re second-guessing whether to upgrade to a newer Apple TV (the price jump feels steep) and briefly considering going back to Roku.
    • They note that SmartThings can control Roku to some extent (and wonder if it’s improved, or if an IR blaster is still needed).
    • They’ll keep the Apple TV anyway because it’s part of a chain that makes some of their HomeKit automations work.
  4. Other ecosystem and practical issues

    • They mention a Pushcut automation server running on a dedicated iPhone.
    • Their main phone is currently dead after an iOS update (26.5.2), which is causing 2FA and number-sync headaches (Apple Watch calling, iMessage sharing, etc.).
    • They briefly wonder whether an old/dead phone could still act as a Thread Border Router.
  5. Aqara hub and virtual switches

    • They bought an Aqara hub around the same time but haven’t decided whether they need it.
    • They already have many Aqara temperature, humidity, and motion sensors in SmartThings.
    • They use a lot of virtual switches (grouped into banks) so Alexa can trigger SmartThings logic (AND/OR/XOR style rules) for devices that Alexa can’t control directly.
    • One example is a smart fan + humidifier that only responds to Alexa; they had to create complex virtual-switch timing sequences because the fan doesn’t like simultaneous commands and takes over a minute to recalibrate if it gets into a bad state.
  6. Networking side notes

    • The Thread app suddenly said “no credentials found in my shared keychain.”
    • They discovered it was because the phone wasn’t on Wi-Fi.
    • They’re planning VLANs (streaming devices vs. IoT) and wondering about multicast bridging/repeating/MVR and VRRP.
    • Their Mac keeps changing its hostname because of multicast announcements across VLANs.

Bottom line

The comment is less a focused technical report and more the user thinking out loud while poking at the new Thread diagnostics tool. Their actual Thread network is tiny (one Apple TV + one Aqara sensor), so the tool mainly confirmed that simple topology. The rest is a mix of setup tips, ecosystem friction (Apple keychain, phone dependency, HomeKit sharing), Alexa/SmartThings workarounds with virtual switches, and random networking speculation.

In short: “I tried the iOS Thread Tools beta. I only have one mesh and one Thread device. Here’s the messy process I used and all the other home-automation headaches I’m dealing with at the same time.”

No.

It would be awesome to see a combination of the SmartThings app 3D home view with the Thread Tools connection view, showing connection quality and info between devices in 3D.

Unfortunately I don’t see an easy way to export the 3D view from SmartThings. And unfortunately while Home Assistant does seem to have some 3D mapping options, they all seem kinda janky and untrustworthy at this time.

I have a few devices with poor connection quality, but it’s a bit hard for me to visualize where exactly they all are in relation to each other and whether I could add range extenders to improve the situation. I suppose if I rescan with Thread Tools with each no range extender I add, that will give me fairly immediate feedback on whether or not I picked a good location.