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