Skip to content

Commit 219e2ed

Browse files
committed
Enhance BLE device scanning and connection management
- Refactored the device scanning logic to return all matching known or auto-discovered devices, improving flexibility in device selection. - Introduced a new interactive prompt for users to select from multiple discovered devices, enhancing user experience. - Updated the connection handling to support specific MAC address targeting, allowing for more precise device management. - Improved the backend streaming URI construction by URL-encoding the device name, ensuring compatibility with special characters.
1 parent b7e2820 commit 219e2ed

File tree

2 files changed

+76
-24
lines changed

2 files changed

+76
-24
lines changed

extras/local-wearable-client/backend_sender.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import ssl
88
from typing import AsyncGenerator, Optional
9+
from urllib.parse import quote
910

1011
import httpx
1112
import websockets
@@ -130,7 +131,7 @@ async def stream_to_backend(
130131
logger.error("Failed to get JWT token, cannot stream audio")
131132
return
132133

133-
uri_with_token = f"{websocket_uri}&token={token}&device_name={device_name}"
134+
uri_with_token = f"{websocket_uri}&token={token}&device_name={quote(device_name)}"
134135

135136
ssl_context = None
136137
if USE_HTTPS:

extras/local-wearable-client/main.py

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -85,37 +85,71 @@ def create_connection(mac: str, device_type: str) -> WearableConnection:
8585
return OmiConnection(mac)
8686

8787

88-
async def scan_for_device(config: dict):
89-
"""Scan BLE and return the first matching known or auto-discovered device.
88+
async def scan_all_devices(config: dict) -> list[dict]:
89+
"""Scan BLE and return all matching known or auto-discovered devices.
9090
91-
Returns a dict with keys: mac, name, type — or None.
91+
Returns a list of dicts with keys: mac, name, type, rssi.
9292
"""
9393
known = {d["mac"]: d for d in config.get("devices", [])}
9494
auto_discover = config.get("auto_discover", True)
9595

9696
logger.info("Scanning for wearable devices...")
97-
discovered = await BleakScanner.discover(timeout=5.0)
97+
discovered = await BleakScanner.discover(timeout=5.0, return_adv=True)
9898

99-
# Check known devices first
100-
for d in discovered:
99+
devices = []
100+
for d, adv in discovered.values():
101101
if d.address in known:
102102
entry = known[d.address]
103-
logger.info("Found known device: %s [%s]", entry.get("name", d.name), d.address)
104-
return {
103+
devices.append({
105104
"mac": d.address,
106-
"name": entry.get("name", d.name),
105+
"name": entry.get("name", d.name or "Unknown"),
107106
"type": entry.get("type", detect_device_type(d.name or "")),
108-
}
107+
"rssi": adv.rssi,
108+
})
109+
elif auto_discover and d.name:
110+
lower = d.name.casefold()
111+
if "omi" in lower or "neo" in lower or "friend" in lower:
112+
devices.append({
113+
"mac": d.address,
114+
"name": d.name,
115+
"type": detect_device_type(d.name),
116+
"rssi": adv.rssi,
117+
})
118+
119+
devices.sort(key=lambda x: x.get("rssi", -999), reverse=True)
120+
return devices
121+
122+
123+
async def scan_for_device(config: dict):
124+
"""Scan BLE and return the first matching device, or None."""
125+
devices = await scan_all_devices(config)
126+
return devices[0] if devices else None
127+
109128

110-
# Auto-discover any recognised OMI/Neo device
111-
if auto_discover:
112-
for d in discovered:
113-
if d.name and ("omi" in d.name.casefold() or "neo" in d.name.casefold() or "friend" in d.name.casefold()):
114-
dtype = detect_device_type(d.name)
115-
logger.info("Auto-discovered %s device: %s [%s]", dtype, d.name, d.address)
116-
return {"mac": d.address, "name": d.name, "type": dtype}
129+
def prompt_device_selection(devices: list[dict]) -> dict | None:
130+
"""Show an interactive numbered list and let the user pick a device."""
131+
print(f"\nFound {len(devices)} device(s):\n")
132+
print(f" {'#':<4} {'Name':<20} {'MAC':<20} {'Type':<8} {'RSSI'}")
133+
print(" " + "-" * 60)
134+
for i, d in enumerate(devices, 1):
135+
print(f" {i:<4} {d['name']:<20} {d['mac']:<20} {d['type']:<8} {d.get('rssi', '?')}")
117136

118-
return None
137+
print()
138+
while True:
139+
try:
140+
choice = input("Select device [1]: ").strip()
141+
if not choice:
142+
idx = 0
143+
else:
144+
idx = int(choice) - 1
145+
if 0 <= idx < len(devices):
146+
return devices[idx]
147+
print(f" Please enter a number between 1 and {len(devices)}")
148+
except ValueError:
149+
print(f" Please enter a number between 1 and {len(devices)}")
150+
except (EOFError, KeyboardInterrupt):
151+
print()
152+
return None
119153

120154

121155
async def connect_and_stream(device: dict, backend_enabled: bool = True) -> None:
@@ -241,22 +275,38 @@ async def queue_to_stream():
241275
await backend_queue.put(None)
242276

243277

244-
async def run() -> None:
278+
async def run(target_mac: str | None = None) -> None:
245279
config = load_config()
246280
scan_interval = config.get("scan_interval", 10)
247281
backend_enabled = check_config()
248282

249283
logger.info("Local wearable client started — scanning for devices...")
250284

251285
while True:
252-
device = await scan_for_device(config)
286+
devices = await scan_all_devices(config)
287+
288+
device = None
289+
if target_mac:
290+
# --device flag: connect to specific MAC
291+
device = next((d for d in devices if d["mac"].casefold() == target_mac.casefold()), None)
292+
if not device:
293+
logger.debug("Target device %s not found, retrying in %ds...", target_mac, scan_interval)
294+
elif len(devices) == 1:
295+
device = devices[0]
296+
elif len(devices) > 1:
297+
device = prompt_device_selection(devices)
298+
if device is None:
299+
logger.info("No device selected, exiting.")
300+
return
301+
253302
if device:
254303
logger.info("Connecting to %s [%s] (type=%s)", device["name"], device["mac"], device["type"])
255304
await connect_and_stream(device, backend_enabled=backend_enabled)
256305
logger.info("Device disconnected, resuming scan...")
257306
else:
258307
logger.debug("No devices found, retrying in %ds...", scan_interval)
259-
await asyncio.sleep(scan_interval)
308+
309+
await asyncio.sleep(scan_interval)
260310

261311

262312
async def scan_and_print() -> None:
@@ -311,7 +361,8 @@ def build_parser() -> argparse.ArgumentParser:
311361
sub = parser.add_subparsers(dest="command")
312362

313363
sub.add_parser("menu", help="Launch menu bar app (default)")
314-
sub.add_parser("run", help="Headless mode — scan, connect, and stream (for launchd)")
364+
run_parser = sub.add_parser("run", help="Headless mode — scan, connect, and stream (for launchd)")
365+
run_parser.add_argument("--device", metavar="MAC", help="Connect to a specific device by MAC address")
315366
sub.add_parser("scan", help="One-shot scan — print nearby devices and exit")
316367
sub.add_parser("install", help="Install macOS launchd agent (auto-start on login)")
317368
sub.add_parser("uninstall", help="Remove macOS launchd agent")
@@ -327,7 +378,7 @@ def main() -> None:
327378
command = args.command or "menu" # Default to menu mode
328379

329380
if command == "run":
330-
asyncio.run(run())
381+
asyncio.run(run(target_mac=getattr(args, "device", None)))
331382

332383
elif command == "menu":
333384
from menu_app import run_menu_app

0 commit comments

Comments
 (0)