diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dbc0fc5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2025-09-11 + +### Added + +- Advertisement / scan response filtering enabled by default requiring the Nordic UART Service UUID; new `--adv-filter / --no-adv-filter` CLI flag and `LoggerSettings.require_adv_nus` to control it. +- Early scan termination when a preferred address substring is observed (used internally to speed up reconnect loops). +- Support scanning without a `--name` filter (wildcard mode) – name now defaults to empty string instead of requiring a parameter. +- Wizard flow enhancements: ability to disable advertisement filter mid-flow; clearer display of unnamed devices. +- New `NUSClient.connect_discovered` method allowing a two-step scan-then-connect pattern for custom selection logic. +- Added test ensuring `--filter-addr` can be used without specifying a name. + +### Changed + +- Connection logic refactored: initial scan and selection separated from connection to improve UX and provide warnings when multiple candidates match. +- `NUSClient.scan` now returns devices even if they have an empty name when wildcard scanning; includes new parameters `early_addr_substring` and `require_adv_nus`. +- Improved reconnection loop: re-scan leverages early address substring matching for faster recovery. +- README updated with new CLI flags, troubleshooting hint, and dedicated section explaining advertisement filtering. + +### Fixed + +- Better handling of devices omitting advertised names (no longer silently excluded when scanning without a name filter). + +### Internal + +- Additional logging around early scan termination and selection. + +## [0.1.3] - 2025-09-09 + +- Previous release (see git history for details). + +[0.2.0]: https://pypi.org/project/nus-logger/0.2.0/ +[0.1.3]: https://pypi.org/project/nus-logger/0.1.3/ diff --git a/README.md b/README.md index e465f6a..5567d3d 100644 --- a/README.md +++ b/README.md @@ -71,19 +71,20 @@ Press Ctrl-C to stop. By default the tool will auto‑reconnect after an unexpec Environment variables override flags when corresponding flags are omitted. -| Flag | Description | -| ----------------------------- | -------------------------------------------------------------------- | -| `-h, --help` | Show CLI help | -| `--wizard` | Interactive scan & option wizard (default when no args) | -| `--list` | List visible devices then exit | -| `--name SUBSTR` | Match advertising name | -| `--filter-addr SUBSTR` | Prefer address containing substring | -| `--ts` / `--ts-local` | Add UTC or local timestamps (mutually exclusive) | -| `--raw` | Show hex bytes | -| `--logfile PATH` | Append decoded lines to file (relative or absolute path) | -| `--timeout SECS` | Scan / connect timeout | -| `--verbose` | Dump discovered GATT structure once | -| `--reconnect, --no-reconnect` | Automatically rescan & reconnect after disconnect (default: enabled) | +| Flag | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `-h, --help` | Show CLI help | +| `--wizard` | Interactive scan & option wizard (default when no args) | +| `--list` | List visible devices then exit | +| `--name SUBSTR` | Match advertising name | +| `--filter-addr SUBSTR` | Prefer address containing substring | +| `--adv-filter / --no-adv-filter` | Require (default) or disable requiring that the NUS 128-bit UUID appears in advertisement/scan response | +| `--ts` / `--ts-local` | Add UTC or local timestamps (mutually exclusive) | +| `--raw` | Show hex bytes | +| `--logfile PATH` | Append decoded lines to file (relative or absolute path) | +| `--timeout SECS` | Scan / connect timeout | +| `--verbose` | Dump discovered GATT structure once | +| `--reconnect, --no-reconnect` | Automatically rescan & reconnect after disconnect (default: enabled) | @@ -95,13 +96,26 @@ To stream the Zephyr logging subsystem over BLE for `nus-logger` to consume you ## Troubleshooting -| Situation | Hint | -| -------------------------------- | --------------------------------------------------------------------------- | -| No devices on Windows | Toggle Bluetooth off/on or airplane mode, verify advertising. | -| Linux permission errors | Ensure user in `bluetooth` group or grant `CAP_NET_RAW` to Python binary. | -| macOS permission prompt | Allow Bluetooth access in System Settings > Privacy & Security > Bluetooth. | -| Disconnects | Reduce distance / interference. | -| Mixed devices with similar names | Use `--filter-addr` to prefer a known address substring. | +| Situation | Hint | +| ----------------------------------- | ---------------------------------------------------------------------------------------- | +| No devices on Windows | Toggle Bluetooth off/on or airplane mode, verify advertising. | +| Linux permission errors | Ensure user in `bluetooth` group or grant `CAP_NET_RAW` to Python binary. | +| macOS permission prompt | Allow Bluetooth access in System Settings > Privacy & Security > Bluetooth. | +| Disconnects | Reduce distance / interference. | +| Mixed devices with similar names | Use `--filter-addr` to prefer a known address substring. | +| Device not found but is advertising | Your firmware may omit the NUS UUID from advertising data. Retry with `--no-adv-filter`. | + +### Advertisement / Scan Response Filtering + +By default `nus-logger` filters discovered devices to only those whose advertising data (including scan responses) lists the Nordic UART Service UUID (`6E400001-B5A3-F393-E0A9-E50E24DCCA9E`). This reduces false positives when multiple similarly named devices are present. + +Some firmware builds intentionally omit 128‑bit service UUIDs to save advertising space. If your device is not being found, disable this filter: + +```bash +nus-logger --name my-device --no-adv-filter +``` + +Platform note: Bleak typically performs active scanning (requesting scan responses). On platforms/backends where only passive advertising data is available, the UUID may also be missing—disabling the filter provides a fallback. ## Development diff --git a/pyproject.toml b/pyproject.toml index 3b98c45..b2cdda0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "nus-logger" -version = "0.1.3" +version = "0.2.0" description = "Nordic UART Service (NUS) BLE logger." readme = "README.md" requires-python = ">=3.9" diff --git a/src/nus_logger/ble_nus.py b/src/nus_logger/ble_nus.py index 5c84fe8..700cdb4 100644 --- a/src/nus_logger/ble_nus.py +++ b/src/nus_logger/ble_nus.py @@ -63,45 +63,87 @@ def on_bytes(self, callback: Callable[[bytes], None]) -> None: self._notify_cb = callback # ------------------------------------------------------------------ - async def scan(self, name: str, timeout: float, adapter: Optional[str] = None) -> List[DiscoveredDevice]: + async def scan( + self, + name: str, + timeout: float, + adapter: Optional[str] = None, + early_addr_substring: Optional[str] = None, + require_adv_nus: bool = True, + ) -> List[DiscoveredDevice]: """Scan for devices whose name equals or contains `name`. - Uses a detection callback to access `AdvertisementData.rssi` (avoiding deprecated - BLEDevice.rssi) and returns candidates sorted by strongest RSSI. - If `name` is empty, all devices with a non-empty name are returned. + Behaviour: + * Collect all advertising devices during the scan window (up to `timeout`). + * If `early_addr_substring` is provided, the scan will terminate early as soon as a + device matching BOTH the name filter (or wildcard) and the address substring + is observed. This accelerates reconnection loops where the target device is already + back in range and there's no need to wait the full timeout. + * Returns candidates sorted by strongest RSSI. + * If `name` is empty, all devices are considered (including those without a name). """ seen: dict[str, tuple[BLEDevice, AdvertisementData]] = {} - - def _detection(device: BLEDevice, adv: AdvertisementData): # pragma: no cover - BLE runtime - if device and device.address: - seen[device.address] = (device, adv) + early_event = asyncio.Event() + lname = name.lower() + early_sub = early_addr_substring.lower() if early_addr_substring else None + + def _detection(device: BLEDevice, adv: AdvertisementData): # pragma: no cover - BLE runtime path + if not device or not device.address: + return + seen[device.address] = (device, adv) + if early_sub: + dname = (device.name or "").strip() + # Name must match (unless no name filter supplied) AND address substring matches + if ((not name) or (dname and lname in dname.lower())) and early_sub in device.address.lower(): + # Signal early exit; the loop below will stop scanner promptly. + if not early_event.is_set(): + early_event.set() scanner = BleakScanner(detection_callback=_detection, adapter=adapter) await scanner.start() try: - await asyncio.sleep(timeout) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + # Poll until timeout or early match + while loop.time() < deadline: + if early_event.is_set(): + self._log.debug( + "Early scan stop triggered by preferred address match '%s'", early_addr_substring) + break + await asyncio.sleep(0.1) finally: await scanner.stop() matches: List[DiscoveredDevice] = [] - lname = name.lower() for _, (dev, adv) in seen.items(): dname = (dev.name or "").strip() - if not dname: - continue - if not name or lname in dname.lower(): - rssi_val = adv.rssi if adv and adv.rssi is not None else -200 - # Minimal metadata (avoid deprecated BLEDevice.metadata) - meta = {"manufacturer_data": dict( - adv.manufacturer_data) if adv.manufacturer_data else {}} - matches.append( - DiscoveredDevice( - address=dev.address, - name=dname, - rssi=rssi_val, - metadata=meta, - ) + if name: # name filter active + if not dname: + continue # cannot match + if lname not in dname.lower(): + continue + # Optional filter: require that the advertisement (including scan response) + # lists the Nordic UART Service UUID. Some firmwares omit 128-bit UUIDs + # to save space; users can disable this via CLI / settings if needed. + if require_adv_nus: + try: + svc_uuids = [u.lower() for u in (adv.service_uuids or [])] + except AttributeError: # pragma: no cover - defensive for older bleak + svc_uuids = [] + if NUS_SERVICE_UUID.lower() not in svc_uuids: + continue + rssi_val = adv.rssi if adv and adv.rssi is not None else -200 + meta = { + "manufacturer_data": dict(adv.manufacturer_data) if adv.manufacturer_data else {}, + } + matches.append( + DiscoveredDevice( + address=dev.address, + name=dname, # may be empty + rssi=rssi_val, + metadata=meta, ) + ) matches.sort(key=lambda x: x.rssi, reverse=True) return matches @@ -112,6 +154,7 @@ async def scan_and_connect( timeout: float, adapter: Optional[str] = None, preferred_addr_substring: Optional[str] = None, + require_adv_nus: bool = True, ) -> DiscoveredDevice: """Scan and connect to the best matching device. @@ -120,7 +163,13 @@ async def scan_and_connect( * If multiple and `preferred_addr_substring` matches, prefer those. * Then pick highest RSSI. """ - candidates = await self.scan(name=name, timeout=timeout, adapter=adapter) + candidates = await self.scan( + name=name, + timeout=timeout, + adapter=adapter, + early_addr_substring=preferred_addr_substring, + require_adv_nus=require_adv_nus, + ) if not candidates: raise BleakError( f"No device found matching name substring '{name}'.") @@ -135,25 +184,31 @@ async def scan_and_connect( self._log.debug( "Selected device %s (%s) RSSI=%s dBm", target.name, target.address, target.rssi ) + await self.connect_discovered(target) + return target + # ------------------------------------------------------------------ + async def connect_discovered(self, device: DiscoveredDevice) -> None: + """Connect to a previously discovered `DiscoveredDevice`. + + This is factored out so callers can perform a scan separately (e.g. to + implement custom selection warnings) before connecting. + """ def _handle_disconnect(_: BleakClient): # pragma: no cover - runtime path # Bleak expects a sync callback; keep minimal work here. self._log.debug("Device disconnected callback fired") self._connected_event.clear() - # Pass disconnect callback directly (deprecated set_disconnected_callback removed in future bleak) client = BleakClient( - target.address, disconnected_callback=_handle_disconnect) + device.address, disconnected_callback=_handle_disconnect) try: await client.connect() except BleakError: raise - # Discover NUS service (prefer property, fallback if not yet populated) svcs = getattr(client, "services", None) if not svcs: # pragma: no cover - depends on bleak version - # Older bleak still exposes get_services during transition try: # type: ignore[attr-defined] # type: ignore[attr-defined] svcs = await client.get_services() @@ -173,12 +228,10 @@ def _handle_disconnect(_: BleakClient): # pragma: no cover - runtime path self._tx_char = tx.uuid self._rx_char = rx.uuid - # Subscribe to notifications assert self._tx_char is not None # type: ignore[arg-type] await client.start_notify(self._tx_char, self._notification_handler) self._connected_event.set() - return target # ------------------------------------------------------------------ # type: ignore[override] diff --git a/src/nus_logger/logger_controller.py b/src/nus_logger/logger_controller.py index c479ba7..ba0d1c5 100644 --- a/src/nus_logger/logger_controller.py +++ b/src/nus_logger/logger_controller.py @@ -33,6 +33,7 @@ class LoggerSettings: raw: bool = False # include hex logfile: Optional[str] = None adapter: Optional[str] = None # platform specific (Linux hciX) + require_adv_nus: bool = True # filter by advertised NUS UUID @dataclass @@ -78,7 +79,14 @@ async def update_settings(self, **kwargs) -> LoggerSettings: async def scan(self, name: str = "", timeout: Optional[float] = None) -> List[DiscoveredDevice]: timeout = timeout if timeout is not None else self._settings.timeout - return await self._client.scan(name=name, timeout=timeout, adapter=self._settings.adapter) + # No early stop outside reconnect context here + return await self._client.scan( + name=name, + timeout=timeout, + adapter=self._settings.adapter, + early_addr_substring=None, + require_adv_nus=self._settings.require_adv_nus, + ) async def connect(self, name: Optional[str] = None, filter_addr: Optional[str] = None) -> None: if name is not None: @@ -198,6 +206,7 @@ async def _run_loop(self) -> None: timeout=self._settings.timeout, adapter=self._settings.adapter, preferred_addr_substring=self._settings.filter_addr, + require_adv_nus=self._settings.require_adv_nus, ) self._connecting = False await self._client.run_until_disconnect() diff --git a/src/nus_logger/nus_logger.py b/src/nus_logger/nus_logger.py index 9ea019e..ae669af 100644 --- a/src/nus_logger/nus_logger.py +++ b/src/nus_logger/nus_logger.py @@ -74,6 +74,13 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="List visible devices and exit") p.add_argument("--filter-addr", help="Preferred address substring when multiple matches") + # Advertisement service filtering (default on) + try: + p.add_argument("--adv-filter", action=argparse.BooleanOptionalAction, default=True, + help="Require advertised NUS service UUID (default: enabled). Disable if your device omits 128-bit UUIDs from adverts.") + except AttributeError: # pragma: no cover - very old Python fallback + p.add_argument("--no-adv-filter", action="store_true", + help="Disable requiring advertised NUS service UUID") # Reconnection control: default on; allow --no-reconnect to disable. try: # Python 3.9+ supports BooleanOptionalAction p.add_argument("--reconnect", action=argparse.BooleanOptionalAction, default=True, @@ -92,12 +99,15 @@ def parse_args(argv: list[str]) -> argparse.Namespace: env_name = env_default("NUS_NAME") if env_name: args.name = env_name - if not args.wizard and not args.name and not args.list: - p.error("--name required unless --list or --wizard is used (or set NUS_NAME)") + # Allow omission of --name: treat as wildcard (scan all NUS devices) + if not args.name: + args.name = "" # Normalize reconnect flag when fallback arg style used if hasattr(args, "no_reconnect"): args.reconnect = not args.no_reconnect # type: ignore[attr-defined] + if hasattr(args, "no_adv_filter"): + args.adv_filter = not args.no_adv_filter # type: ignore[attr-defined] if args.ts and args.ts_local: p.error("--ts and --ts-local are mutually exclusive") @@ -151,7 +161,8 @@ async def list_devices(timeout: float, adapter: Optional[str]) -> int: client = NUSClient() try: devices = await _run_with_spinner( - client.scan(name="", timeout=timeout, adapter=adapter), + client.scan(name="", timeout=timeout, adapter=adapter, + early_addr_substring=None, require_adv_nus=True), "Scanning for devices", ) except BleakError as e: @@ -159,7 +170,7 @@ async def list_devices(timeout: float, adapter: Optional[str]) -> int: return 2 seen = set() if not devices: - print("No devices with names discovered.") + print("No devices with names discovered (with NUS UUID advertised). Try --no-adv-filter if your firmware omits service UUIDs.") return 0 print("Discovered devices (name | address | RSSI dBm):") for d in devices: @@ -317,25 +328,52 @@ def _on_bytes(chunk: bytes) -> None: # Connection loop with optional automatic re-scan & reconnect to the same device. try: try: - device = await _run_with_spinner( - client.scan_and_connect( + # Perform scan separately so we can emit warning if multiple devices match + scan_label = f"Scanning for '{args.name}'" if args.name else "Scanning for NUS devices" + devices = await _run_with_spinner( + client.scan( name=args.name, timeout=args.timeout, adapter=args.adapter, - preferred_addr_substring=args.filter_addr, + early_addr_substring=None, # Initial scan: no early exit + require_adv_nus=args.adv_filter, ), - f"Scanning for '{args.name}'", - ) - print( - format_event( - f"Connected to {device.name} ({device.address}) RSSI={device.rssi}dBm", "ok" - ) + scan_label, ) + if not devices: + hint = " (try --no-adv-filter)" if args.adv_filter else "" + if args.name: + raise BleakError( + f"No device found matching name substring '{args.name}'{hint}.") + else: + raise BleakError( + f"No advertising NUS devices found{hint}.") + # Apply preferred address substring filtering like scan_and_connect + if args.filter_addr: + filt = [d for d in devices if args.filter_addr.lower() + in d.address.lower()] + if filt: + devices = filt + # Warn if multiple candidates + if len(devices) > 1: + match_desc = f"('{args.name}')" if args.name else "(any)" + # Show summary list (limit maybe to 8 for brevity?) + print(format_event( + f"Multiple devices matched {match_desc} - selecting strongest RSSI (override with --filter-addr).", "warn")) + for d in devices[:8]: + print(format_event( + f" Candidate: {d.name} | {d.address} | RSSI {d.rssi} dBm", "warn")) + if len(devices) > 8: + print(format_event( + f" ... {len(devices)-8} more hidden", "warn")) + device = devices[0] + await client.connect_discovered(device) + print(format_event( + f"Connected to {device.name} ({device.address}) RSSI={device.rssi}dBm", "ok")) if args.verbose: svcs = await client.get_services_debug() print("Services:\n" + svcs) except BleakError as e: - # Initial connection failure => exit (retain existing behaviour) raise e # Run until disconnect once (always) @@ -360,6 +398,7 @@ def _on_bytes(chunk: bytes) -> None: timeout=args.timeout, adapter=args.adapter, preferred_addr_substring=device.address, + require_adv_nus=args.adv_filter, ), f"Re-scanning for '{device.name}'", ) @@ -427,9 +466,10 @@ async def wizard_flow(base_args: argparse.Namespace) -> Optional[argparse.Namesp client = NUSClient() selected: Optional[DiscoveredDevice] = None + adv_filter = getattr(base_args, "adv_filter", True) while selected is None: try: - devices = await client.scan(name="", timeout=base_args.timeout, adapter=base_args.adapter) + devices = await client.scan(name="", timeout=base_args.timeout, adapter=base_args.adapter, early_addr_substring=None, require_adv_nus=adv_filter) except BleakError as e: print(format_event(f"Scan failed: {e}", "err"), file=sys.stderr) choice = input("Retry scan? [Y/n]: ").strip().lower() @@ -437,8 +477,17 @@ async def wizard_flow(base_args: argparse.Namespace) -> Optional[argparse.Namesp return None continue if not devices: - print("No named devices found.") - choice = input("(R)escan or (Q)uit? [R/q]: ").strip().lower() + if adv_filter: + print( + "No devices advertising NUS UUID found. (Your device may omit the UUID.)") + choice = input( + "(R)escan, disable fi(L)ter then rescan, or (Q)uit? [R/l/q]: ").strip().lower() + if choice == 'l': + adv_filter = False + continue + else: + print("No devices found.") + choice = input("(R)escan or (Q)uit? [R/q]: ").strip().lower() if choice == "q": return None else: @@ -446,7 +495,8 @@ async def wizard_flow(base_args: argparse.Namespace) -> Optional[argparse.Namesp # Display table print("\nDiscovered devices:") for idx, d in enumerate(devices): - print(f" [{idx}] {d.name} | {d.address} | RSSI {d.rssi} dBm") + disp_name = d.name if d.name else "" + print(f" [{idx}] {disp_name} | {d.address} | RSSI {d.rssi} dBm") resp = input( "Select device index, or 'r' to rescan, 'q' to quit: ").strip().lower() if resp == 'q': @@ -487,8 +537,10 @@ async def wizard_flow(base_args: argparse.Namespace) -> Optional[argparse.Namesp new_args.ts = ts_mode == 'utc' new_args.ts_local = ts_mode == 'local' new_args.raw = raw_hex + new_args.adv_filter = adv_filter new_args.logfile = logfile - print(format_event(f"Selected {selected.name} ({selected.address})", "ok")) + disp_sel = selected.name if selected.name else "" + print(format_event(f"Selected {disp_sel} ({selected.address})", "ok")) return new_args diff --git a/tests/test_basic.py b/tests/test_basic.py index 1445fc4..1b4b4d2 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -19,3 +19,11 @@ def test_line_assembler_basic(): # partial parts = la.feed(b"partial") assert parts == [] + + +def test_parse_args_filter_addr_only(): + from nus_logger.nus_logger import parse_args + ns = parse_args(["--filter-addr", "ff"]) + # Name should default to wildcard (empty string) instead of raising an error + assert ns.name == "" + assert ns.filter_addr == "ff"