Fix JSON serialization for D-Bus advertisement data

- Add recursive unwrap_variant() to handle nested Variant objects
- Convert manufacturer_data/service_data bytes to hex strings in DeviceInfo
- Remove redundant bytes-to-hex conversion from resources.py

BlueZ returns ManufacturerData as {int: Variant(bytes)} which wasn't
being fully unwrapped, causing JSON serialization failures during scan.
This commit is contained in:
Ryan Malloy 2026-02-02 11:09:20 -07:00
parent 61e424ab40
commit 3e3d77068b
3 changed files with 28 additions and 21 deletions

1
.gitignore vendored
View File

@ -44,3 +44,4 @@ uv.lock
# Project specific # Project specific
*.log *.log
.mcp.json

View File

@ -39,6 +39,21 @@ DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties"
DBUS_OBJMANAGER_IFACE = "org.freedesktop.DBus.ObjectManager" DBUS_OBJMANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
def unwrap_variant(value: Any) -> Any:
"""Recursively unwrap dbus-fast Variant objects to plain Python types.
BlueZ returns nested structures like ManufacturerData = {int: Variant(bytes)}.
This function ensures all Variant wrappers are removed at every level.
"""
if isinstance(value, Variant):
return unwrap_variant(value.value)
elif isinstance(value, dict):
return {k: unwrap_variant(v) for k, v in value.items()}
elif isinstance(value, list):
return [unwrap_variant(v) for v in value]
return value
def address_to_path(adapter: str, address: str) -> str: def address_to_path(adapter: str, address: str) -> str:
"""Convert a Bluetooth address to D-Bus object path.""" """Convert a Bluetooth address to D-Bus object path."""
addr_path = address.upper().replace(":", "_") addr_path = address.upper().replace(":", "_")
@ -94,8 +109,8 @@ class DeviceInfo:
appearance: int = 0 appearance: int = 0
icon: str = "" icon: str = ""
services_resolved: bool = False services_resolved: bool = False
manufacturer_data: dict[int, bytes] = field(default_factory=dict) manufacturer_data: dict[int, str] = field(default_factory=dict) # hex strings
service_data: dict[str, bytes] = field(default_factory=dict) service_data: dict[str, str] = field(default_factory=dict) # hex strings
@dataclass @dataclass
@ -163,7 +178,7 @@ class BlueZClient:
result[path] = {} result[path] = {}
for iface, props in interfaces.items(): for iface, props in interfaces.items():
result[path][iface] = { result[path][iface] = {
k: v.value if isinstance(v, Variant) else v for k, v in props.items() k: unwrap_variant(v) for k, v in props.items()
} }
return result return result
@ -200,7 +215,7 @@ class BlueZClient:
"""Get all properties from an object.""" """Get all properties from an object."""
props_iface = await self._get_interface(path, DBUS_PROPS_IFACE) props_iface = await self._get_interface(path, DBUS_PROPS_IFACE)
props = await props_iface.call_get_all(interface) props = await props_iface.call_get_all(interface)
return {k: v.value if isinstance(v, Variant) else v for k, v in props.items()} return {k: unwrap_variant(v) for k, v in props.items()}
# ==================== Adapter Operations ==================== # ==================== Adapter Operations ====================
@ -358,17 +373,19 @@ class BlueZClient:
# Extract adapter name from path # Extract adapter name from path
adapter_name = path.split("/")[3] if len(path.split("/")) > 3 else "" adapter_name = path.split("/")[3] if len(path.split("/")) > 3 else ""
# Parse manufacturer data # Parse manufacturer data (convert bytes to hex strings for JSON)
mfr_data = {} mfr_data = {}
if "ManufacturerData" in props: if "ManufacturerData" in props:
for k, v in props["ManufacturerData"].items(): for k, v in props["ManufacturerData"].items():
mfr_data[k] = bytes(v) if isinstance(v, (list, bytearray)) else v raw = bytes(v) if isinstance(v, (list, bytearray)) else v
mfr_data[k] = raw.hex() if isinstance(raw, bytes) else str(raw)
# Parse service data # Parse service data (convert bytes to hex strings for JSON)
svc_data = {} svc_data = {}
if "ServiceData" in props: if "ServiceData" in props:
for k, v in props["ServiceData"].items(): for k, v in props["ServiceData"].items():
svc_data[k] = bytes(v) if isinstance(v, (list, bytearray)) else v raw = bytes(v) if isinstance(v, (list, bytearray)) else v
svc_data[k] = raw.hex() if isinstance(raw, bytes) else str(raw)
devices.append( devices.append(
DeviceInfo( DeviceInfo(

View File

@ -193,17 +193,6 @@ def register_resources(mcp: FastMCP) -> None:
devices = await client.list_devices() devices = await client.list_devices()
for d in devices: for d in devices:
if d.address.upper() == address.upper(): if d.address.upper() == address.upper():
data = asdict(d) # DeviceInfo already has manufacturer_data/service_data as hex strings
# Convert bytes to hex strings for JSON serialization return json.dumps(asdict(d), indent=2)
if data.get("manufacturer_data"):
data["manufacturer_data"] = {
k: v.hex() if isinstance(v, bytes) else str(v)
for k, v in data["manufacturer_data"].items()
}
if data.get("service_data"):
data["service_data"] = {
k: v.hex() if isinstance(v, bytes) else str(v)
for k, v in data["service_data"].items()
}
return json.dumps(data, indent=2)
return json.dumps({"error": f"Device '{address}' not found"}) return json.dumps({"error": f"Device '{address}' not found"})