Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b4dfd83
new: mdns-browser for Thread Border Routers or other services
rochaferraz Nov 10, 2025
a144ad3
Update script/mdns-browser.py
rochaferraz Nov 10, 2025
e2270f0
chg: improvements based on Gemini review. Get Service Info timeout an…
rochaferraz Nov 10, 2025
96191fe
Update script/mdns-browser.py
rochaferraz Nov 10, 2025
756ed39
Fix: Improve mDNS service resolution and error handling
rochaferraz Nov 10, 2025
7e8a27a
chg: minor readability improvaments
rochaferraz Nov 10, 2025
02ea15a
Update script/mdns-browser.py
rochaferraz Nov 10, 2025
4222bd0
Update script/mdns-browser.py
rochaferraz Nov 10, 2025
b946c25
fix: removed unused import
rochaferraz Nov 10, 2025
8218900
fix: prettify
rochaferraz Nov 10, 2025
cf34510
fix: update mdns-browser instructions and refactor
rochaferraz Nov 27, 2025
98a2d8e
fix: ensure q.task_done() is called on worker thread exception
rochaferraz Nov 27, 2025
3e69f12
prettify
rochaferraz Nov 27, 2025
5aaaa82
Update script/mdns-browser.py
rochaferraz Nov 27, 2025
9095e46
fix: gracefully handle non-UTF8 TXT record keys
rochaferraz Nov 27, 2025
e5cc403
fix: gracefully handle non-UTF8 TXT record keys
rochaferraz Nov 27, 2025
f62f0b1
refactor: rename 'type' parameter to 'service_type' to avoid shadowin…
rochaferraz Nov 27, 2025
5be72bc
Update script/mdns-browser.py
rochaferraz Nov 27, 2025
c6d5d70
fix: dollar sign before bash commands
rochaferraz Dec 1, 2025
956d83a
Merge branch 'main' into main
rochaferraz Dec 16, 2025
806bbef
feat: Add IPv6 support to mDNS fake border agent and browser test
rochaferraz Jan 14, 2026
5f676be
prettify
rochaferraz Jan 14, 2026
155aeaa
Merge branch 'openthread:main' into main
rochaferraz Feb 18, 2026
46e2698
fix: address reviewer comment on PR 334
rochaferraz May 11, 2026
41df0c9
Refactor: apply remaining AI review suggestions for worker loop and T…
rochaferraz May 15, 2026
3839cd1
chore: fix formatting in mdns-browser.py
rochaferraz May 19, 2026
83576dd
Fix: Add script requirements and fix typo
rochaferraz Jul 1, 2026
18f5b59
script: Add task_done to mdns-browser worker queue to prevent hanging
rochaferraz Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions script/mdns-browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/python3
Comment thread
rochaferraz marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing license header?

# mDNS Service Scanner
#
# This script scans the local network for multicast DNS (mDNS) services.
# It displays detailed information about discovered and updated services,
# including their IP addresses (both IPv4 and IPv6), port numbers, and TXT records.
#
# Features:
# - Discovers services on the local network.
# - Displays distinct icons for newly discovered (✅) and updated (🔄) services.
# - Resolves and lists all associated IPv4 and IPv6 addresses.
# - Parses and displays all TXT records for each service.
# - Handles graceful shutdown with Ctrl+C.
#
# Requirements:
# - Python 3
# - zeroconf
#
# Installation:
# 1. Clone the repository or download the script.
# 2. Install the required Python package:
#
# $ pip install zeroconf
#
# Usage:
# Run the scanner from your terminal. You can optionally provide a service name
# as an argument. If no service name is provided, it defaults to `_meshcop._udp.local. `:
#
# $ python3 mdns-browser.py [service_name]
#
# Examples:
#
# Scan for the default `_meshcop._udp.local.` service:
# $ python3 mdns-browser.py
#
# Scan for a custom service, e.g., `_http._tcp.local.`:
# $ python3 mdns-browser.py _http._tcp.local.
#
# The script will continuously listen for services. Press Ctrl+C to stop the scan.

import queue
import time
import argparse
import threading
from typing import Optional
from zeroconf import ServiceBrowser, Zeroconf, IPVersion, ServiceInfo

# Constants
EVENT_DISCOVERED = "discovered"
EVENT_UPDATED = "updated"
SERVICE_RESOLVE_TIMEOUT_MS = 5000


def _decode_txt_property(prop: Optional[bytes]) -> str:
if prop is None:
return ""
try:
return prop.decode("utf-8")
except UnicodeDecodeError:
return f"0x{prop.hex()}"


def print_service_info(zeroconf: Zeroconf, service_type: str, name: str, event: str) -> None:
icon = "✅" if event == EVENT_DISCOVERED else "🔄"
action = "Discovered" if event == EVENT_DISCOVERED else "Updated"
print(f"\n{icon} Service {action}: {name}")
info: Optional[ServiceInfo] = zeroconf.get_service_info(service_type, name, timeout=SERVICE_RESOLVE_TIMEOUT_MS)
if not info:
print(" Could not resolve details.")
return

print(f" Host: {info.server}:{info.port}")
print(f" Host TTL: {info.host_ttl}")
print(f" Other TTL: {info.other_ttl}")
print(f" Priority: {info.priority}")
print(f" Weight: {info.weight}")

for ip_address in info.parsed_addresses():
print(f" IP Address: {ip_address}")

if info.properties:
print(" --- TXT Records ---")
for key, value in info.properties.items():
key_str = _decode_txt_property(key)
val_str = _decode_txt_property(value)
print(f" {key_str} = {val_str}")
else:
print(" No TXT records found.")


class ServiceListener:

def __init__(self, q: queue.Queue) -> None:
self.q = q

def remove_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
print(f"\n❌ Service Removed: {name}")

def update_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
self.q.put((EVENT_UPDATED, service_type, name))

def add_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
self.q.put((EVENT_DISCOVERED, service_type, name))


def worker(q: queue.Queue, zeroconf_instance: Zeroconf) -> None:
while True:
item = q.get()
if item is None:
q.task_done()
break
try:
event, service_type, name = item
print_service_info(zeroconf_instance, service_type, name, event)
except Exception as e:
print(f"Error in worker thread: {e}")
finally:
q.task_done()


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="mDNS Service Scanner")
parser.add_argument('service_name',
nargs='?',
default="_meshcop._udp.local.",
help="The mDNS service to scan for (default: %(default)s)")
parser.add_argument(
'--timeout',
type=int,
default=0,
help='Timeout in seconds after which the script will exit (default: %(default)s, 0 means no timeout)')
args = parser.parse_args()
service_name = args.service_name
timeout = args.timeout

q: queue.Queue = queue.Queue()
zeroconf = Zeroconf(ip_version=IPVersion.All)
listener = ServiceListener(q)
browser = ServiceBrowser(zeroconf, service_name, listener)

resolver_thread = threading.Thread(target=worker, args=(q, zeroconf))
resolver_thread.start()

print(f"🔍 Starting mDNS scan for {service_name} services...")
if timeout > 0:
print(f"Script will exit in {timeout} seconds.")
else:
print("Press Ctrl+C to exit.")

try:
if timeout > 0:
time.sleep(timeout)
else:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")
finally:
q.put(None)
resolver_thread.join()
zeroconf.close()
1 change: 1 addition & 0 deletions script/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
zeroconf>=0.131.0
46 changes: 46 additions & 0 deletions tests/integration/fake_border_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/python3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing license header?

import time
import socket
from zeroconf import ServiceInfo, Zeroconf

import uuid

# Service details
SERVICE_TYPE = "_meshcop._udp.local."
SERVICE_NAME = f"OpenThread Border Router.{uuid.uuid4().hex}._meshcop._udp.local."
SERVER_NAME = "otbr.local."
IP_ADDRESS = "192.168.1.2"
IP_ADDRESS_V6 = "fe80::1"
PORT = 49152

# TXT records
PROPERTIES = {
b"rv": b"1",
b"vn": b"OpenThread",
b"mn": b"BorderRouter",
b"nn": b"OTBR",
b"xp": b"12345678",
b"tv": b"1.1.1",
}

if __name__ == "__main__":
zeroconf = Zeroconf()
info = ServiceInfo(
SERVICE_TYPE,
SERVICE_NAME,
addresses=[socket.inet_aton(IP_ADDRESS),
socket.inet_pton(socket.AF_INET6, IP_ADDRESS_V6)],
port=PORT,
properties=PROPERTIES,
server=SERVER_NAME,
)
zeroconf.register_service(info)
try:
while True:
time.sleep(0.1)
except KeyboardInterrupt:
pass
finally:
zeroconf.unregister_service(info)
zeroconf.close()
1 change: 1 addition & 0 deletions tests/integration/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
. "$(dirname "$0")"/test_discover.sh
. "$(dirname "$0")"/test_energy_scan.sh
. "$(dirname "$0")"/test_joining.sh
. "$(dirname "$0")"/test_mdns_browser.sh
. "$(dirname "$0")"/test_native_commissioner.sh
. "$(dirname "$0")"/test_operational_dataset.sh
. "$(dirname "$0")"/test_pan_id_query.sh
Expand Down
102 changes: 102 additions & 0 deletions tests/integration/test_mdns_browser.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/bin/bash
#
# Copyright (c) 2023, The OpenThread Commissioner Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#

[ -z "${TEST_ROOT_DIR}" ] && . "$(dirname "$0")"/common.sh

test_mdns_browser()
Comment thread
rochaferraz marked this conversation as resolved.
{
set -e

if ! python3 -c "import zeroconf" &>/dev/null; then
echo "SKIPPING mDNS browser test: zeroconf module not found."
echo "Please install it using 'pip install zeroconf'"
exit 0
fi

echo "Starting fake border agent service..."
python3 "${CUR_DIR}/fake_border_agent.py" &
FAKE_BA_PID=$!

# Give the fake service some time to start
sleep 2

echo "Running mDNS browser..."
OUTPUT=$(python3 "${CUR_DIR}/../../script/mdns-browser.py" --timeout 5 "_meshcop._udp.local.")

echo "Stopping fake border agent service..."
kill ${FAKE_BA_PID}
wait ${FAKE_BA_PID} 2>/dev/null || true

echo "Verifying mDNS browser output..."
echo "Raw mdns-browser.py output:"
echo "${OUTPUT}"

# Dynamically extract the full service name from the output
SERVICE_NAME=$(echo "${OUTPUT}" | grep -o "Service Discovered: OpenThread Border Router\.[0-9a-fA-F]\{32\}\._meshcop\._udp\.local\." | sed 's/Service Discovered: //')

if [ -z "${SERVICE_NAME}" ]; then
echo "FAIL: Service name not discovered in output"
exit 1
fi

# Escape special characters for the final verification grep
ESCAPED_SERVICE_NAME=$(echo "${SERVICE_NAME}" | sed 's/[\.\*+\?\|\{\}\(\)\[\]\\\^\$]/\\&/g')

if ! echo "${OUTPUT}" | grep -q "Service Discovered: ${ESCAPED_SERVICE_NAME}"; then
echo "FAIL: Service name verification failed"
exit 1
fi

if ! echo "${OUTPUT}" | grep -q "Host: otbr.local.:49152"; then
echo "FAIL: Host not discovered"
exit 1
fi

if ! echo "${OUTPUT}" | grep -q "IP Address: 192.168.1.2"; then
echo "FAIL: IP address not discovered"
exit 1
fi

if ! echo "${OUTPUT}" | grep -q "IP Address: fe80::1"; then
echo "FAIL: IPv6 address not discovered"
exit 1
fi

if ! echo "${OUTPUT}" | grep -q "rv = 1"; then
echo "FAIL: TXT record 'rv' not discovered"
exit 1
fi

if ! echo "${OUTPUT}" | grep -q "nn = OTBR"; then
echo "FAIL: TXT record 'nn' not discovered"
exit 1
fi

echo "PASS: mDNS browser test"
}
Loading