From cd8c06ccd6079e16ded8501ded8b8165dfa048eb Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 9 Sep 2025 06:26:54 +0200 Subject: [PATCH 1/3] remove reconnect and backoff logic - Removed auto-reconnect and backoff logic from the NUS logger. - Updated README and project description for clarity. - Cleaned up imports by removing unused exponential backoff utility. - Adjusted tests to reflect the removal of backoff functionality. --- README.md | 32 ++++----- pyproject.toml | 2 +- src/nus_logger/__init__.py | 3 +- src/nus_logger/logger_controller.py | 56 ++++----------- src/nus_logger/nus_logger.py | 107 +++++++++------------------- src/nus_logger/utils.py | 14 +--- tests/test_basic.py | 22 ------ 7 files changed, 66 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index 692ec91..a35ecff 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

NUS Logger

-

Auto‑reconnecting Nordic UART Service (NUS) BLE log collector for Zephyr / nRF Connect SDK devices.

+

Nordic UART Service (NUS) BLE log collector for Zephyr / nRF Connect SDK devices.

@@ -21,7 +21,7 @@ ## ✨ Highlights - **Zero‑config CLI**: discover, connect, stream logs in one command. -- **Resilient**: automatic reconnect with exponential backoff (capped) & graceful exit. +- **Simple**: single connection session; exits cleanly on disconnect. - **Readable timestamps**: UTC (`--ts`) or local (`--ts-local`). - **Dual view**: optional raw hex alongside decoded UTF‑8 text (`--raw`). - **Log persistence**: safe append mode (rotation‑friendly) to any file. @@ -65,25 +65,23 @@ Module mode (equivalent): python -m nus_logger --name my-device --ts ``` -Press Ctrl-C to stop; the tool will attempt automatic reconnection until max retries. +Press Ctrl-C to stop; the tool exits on disconnect. ## CLI Reference Environment variables override flags when corresponding flags are omitted. -| Flag | Description | Env | Notes | -| ---------------------- | ----------------------------------- | ----------------- | -------------------------------- | -| `--wizard` | Interactive scan & option wizard | – | Default when no args | -| `--list` | List visible devices then exit | – | Passive scan only | -| `--name SUBSTR` | Match advertising name | `NUS_NAME` | Case-insensitive substring | -| `--filter-addr SUBSTR` | Prefer address containing substring | – | Helps disambiguate similar names | -| `--ts` / `--ts-local` | Add UTC or local timestamps | – | Mutually exclusive | -| `--raw` | Show hex bytes before decoded line | – | Two aligned columns | -| `--logfile PATH` | Append decoded lines to file | `NUS_LOGFILE` | File is created if missing | -| `--timeout SECS` | Scan / connect timeout | `NUS_TIMEOUT` | Applies to each attempt | -| `--backoff SECS` | Initial reconnect backoff | `NUS_BACKOFF` | Grows up to 15s cap | -| `--max-retries N` | Stop after N failed reconnects | `NUS_MAX_RETRIES` | Omit to retry indefinitely | -| `--verbose` | Dump discovered GATT structure once | – | For debugging / inspection | +| Flag | Description | Env | Notes | +| ---------------------- | ----------------------------------- | ------------- | -------------------------------- | --- | --- | --- | +| `--wizard` | Interactive scan & option wizard | – | Default when no args | +| `--list` | List visible devices then exit | – | Passive scan only | +| `--name SUBSTR` | Match advertising name | `NUS_NAME` | Case-insensitive substring | +| `--filter-addr SUBSTR` | Prefer address containing substring | – | Helps disambiguate similar names | +| `--ts` / `--ts-local` | Add UTC or local timestamps | – | Mutually exclusive | +| `--raw` | Show hex bytes before decoded line | – | Two aligned columns | +| `--logfile PATH` | Append decoded lines to file | `NUS_LOGFILE` | File is created if missing | +| `--timeout SECS` | Scan / connect timeout | `NUS_TIMEOUT` | Applies to each attempt | – | – | – | +| `--verbose` | Dump discovered GATT structure once | – | For debugging / inspection |

Show full help example @@ -144,7 +142,7 @@ To stream the Zephyr logging subsystem over BLE for `nus-logger` to consume you | 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. | -| Frequent disconnects | Reduce distance / interference; backoff resets after ~60s stable link. | +| Disconnects | Reduce distance / interference. | | Mixed devices with similar names | Use `--filter-addr` to prefer a known address substring. | ## Development diff --git a/pyproject.toml b/pyproject.toml index 38d6dd8..a01c0ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "nus-logger" version = "0.1.1" -description = "Auto-reconnecting Nordic UART Service (NUS) BLE logger." +description = "Nordic UART Service (NUS) BLE logger." readme = "README.md" requires-python = ">=3.9" license = {file = "LICENSE"} diff --git a/src/nus_logger/__init__.py b/src/nus_logger/__init__.py index 80cb204..603539e 100644 --- a/src/nus_logger/__init__.py +++ b/src/nus_logger/__init__.py @@ -8,7 +8,7 @@ from .ble_nus import NUSClient, DiscoveredDevice, NUS_SERVICE_UUID, NUS_RX_CHAR_UUID, NUS_TX_CHAR_UUID from .logger_controller import NUSLoggerController, controller, LoggerSettings, LoggerStatus -from .utils import LineAssembler, utc_ts, local_ts, exponential_backoff, open_log_file +from .utils import LineAssembler, utc_ts, local_ts, open_log_file try: # pragma: no cover - metadata environment __version__ = version("nus-logger") @@ -26,7 +26,6 @@ "LineAssembler", "utc_ts", "local_ts", - "exponential_backoff", "open_log_file", "NUS_SERVICE_UUID", "NUS_RX_CHAR_UUID", diff --git a/src/nus_logger/logger_controller.py b/src/nus_logger/logger_controller.py index 660fa6d..c479ba7 100644 --- a/src/nus_logger/logger_controller.py +++ b/src/nus_logger/logger_controller.py @@ -19,7 +19,7 @@ from bleak.exc import BleakError from .ble_nus import NUSClient, DiscoveredDevice -from .utils import LineAssembler, utc_ts, local_ts, exponential_backoff, open_log_file +from .utils import LineAssembler, utc_ts, local_ts, open_log_file LOG = logging.getLogger("logger_controller") @@ -29,9 +29,6 @@ class LoggerSettings: name: str = "" # substring for scan + connect filter_addr: Optional[str] = None timeout: float = 5.0 - reconnect: bool = True - backoff: float = 0.5 # initial - max_retries: int = 1_000_000_000 ts_mode: str = "none" # "none" | "utc" | "local" raw: bool = False # include hex logfile: Optional[str] = None @@ -63,7 +60,6 @@ def __init__(self) -> None: self._connecting = False self._retries = 0 self._lock = asyncio.Lock() - self._backoff_iter: Optional[AsyncIterator[float]] = None self._client.on_bytes(self._on_bytes) # ---------------------- public API --------------------------------- @@ -99,7 +95,6 @@ async def connect(self, name: Optional[str] = None, filter_addr: Optional[str] = self._loop_task = asyncio.create_task(self._run_loop()) async def disconnect(self) -> None: - self._settings.reconnect = False # disable further attempts self._stop_event.set() if self._loop_task: try: @@ -196,45 +191,20 @@ async def _idle_flush_task(self): async def _run_loop(self) -> None: await self._reopen_logfile() idle_task = asyncio.create_task(self._idle_flush_task()) - self._backoff_iter = exponential_backoff( - initial=self._settings.backoff, cap=15.0) - self._retries = 0 try: - while not self._stop_event.is_set(): - self._connecting = True - try: - self._device = await self._client.scan_and_connect( - name=self._settings.name, - timeout=self._settings.timeout, - adapter=self._settings.adapter, - preferred_addr_substring=self._settings.filter_addr, - ) - self._connecting = False - self._retries = 0 - # reset backoff after a successful connect - self._backoff_iter = exponential_backoff( - initial=self._settings.backoff, cap=15.0) - # Wait until disconnect - await self._client.run_until_disconnect() - except BleakError as e: - LOG.warning("BLE error: %s", e) - finally: - await self._client.disconnect() - self._connecting = False - self._device = None - if self._stop_event.is_set() or not self._settings.reconnect: - break - self._retries += 1 - if self._retries > self._settings.max_retries: - LOG.warning("Max retries reached; stopping reconnect loop") - break - assert self._backoff_iter is not None - delay = await self._backoff_iter.__anext__() - try: - await asyncio.wait_for(self._stop_event.wait(), timeout=delay) - except asyncio.TimeoutError: - continue + self._connecting = True + self._device = await self._client.scan_and_connect( + name=self._settings.name, + timeout=self._settings.timeout, + adapter=self._settings.adapter, + preferred_addr_substring=self._settings.filter_addr, + ) + self._connecting = False + await self._client.run_until_disconnect() + except BleakError as e: + LOG.warning("BLE error: %s", e) finally: + await self._client.disconnect() self._connecting = False self._device = None self._stop_event.clear() diff --git a/src/nus_logger/nus_logger.py b/src/nus_logger/nus_logger.py index 3e79f2d..55b632a 100644 --- a/src/nus_logger/nus_logger.py +++ b/src/nus_logger/nus_logger.py @@ -3,7 +3,7 @@ Features: * Scans by (substring) name, selects strongest RSSI. * Reassembles newline-delimited log lines (flush on idle). -* Optional timestamps, raw hex, file logging, auto-reconnect with backoff. +* Optional timestamps, raw hex, file logging. * Minimal dependencies: bleak (+ colorama auto-installed on Windows for colored events, optional elsewhere). """ from __future__ import annotations @@ -18,7 +18,7 @@ from bleak.exc import BleakError -from .utils import utc_ts, local_ts, exponential_backoff, open_log_file, supports_color, LineAssembler +from .utils import utc_ts, local_ts, open_log_file, supports_color, LineAssembler from .ble_nus import NUSClient, NUS_SERVICE_UUID, NUS_RX_CHAR_UUID, NUS_TX_CHAR_UUID, DiscoveredDevice @@ -65,15 +65,6 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="Prefix lines with UTC timestamp") p.add_argument("--ts-local", action="store_true", help="Prefix lines with local timestamp") - p.add_argument("--reconnect", action="store_true", default=True, - help="Auto reconnect on disconnect (default true)") - max_retries_def = env_default( - "NUS_MAX_RETRIES", "1000000000") or "1000000000" - backoff_def = env_default("NUS_BACKOFF", "0.5") or "0.5" - p.add_argument("--max-retries", type=int, default=int(float(max_retries_def)), - help="Max reconnect attempts (default large ~1e9)") - p.add_argument("--backoff", type=float, default=float(backoff_def), - help="Initial reconnect backoff seconds (default 0.5)") p.add_argument("--verbose", action="store_true", help="Verbose diagnostics") p.add_argument("--list", action="store_true", @@ -277,70 +268,44 @@ def _on_bytes(chunk: bytes) -> None: client.on_bytes(_on_bytes) - stable_connected_since: Optional[float] = None - backoff_iter = exponential_backoff(initial=args.backoff, cap=15.0) - retries = 0 - idle_task = asyncio.create_task(_flush_idle()) - while not stop_event.is_set(): - try: - device = await client.scan_and_connect( - name=args.name, - timeout=args.timeout, - adapter=args.adapter, - preferred_addr_substring=args.filter_addr, + # Single connection attempt then exit on disconnect + try: + device = await client.scan_and_connect( + name=args.name, + timeout=args.timeout, + adapter=args.adapter, + preferred_addr_substring=args.filter_addr, + ) + print( + format_event( + f"Connected to {device.name} ({device.address}) RSSI={device.rssi}dBm", "ok" ) - stable_connected_since = loop.time() - retries = 0 + ) + if args.verbose: + svcs = await client.get_services_debug() + print("Services:\n" + svcs) + await client.run_until_disconnect(stop_event) + print(format_event("Disconnected", "warn")) + except BleakError as e: + print(format_event(f"BLE error: {e}", "err"), file=sys.stderr) + msg = str(e).lower() + if "failed to execute management command" in msg or "not available" in msg: + print( + "Hint: Ensure Bluetooth adapter is powered and not blocked (rfkill).", file=sys.stderr) + if "permission" in msg and sys.platform.startswith("linux"): print( - format_event( - f"Connected to {device.name} ({device.address}) RSSI={device.rssi}dBm", "ok" - ) + "Hint: Missing permissions. Consider adding user to 'bluetooth' group or setcap 'cap_net_raw+eip' on python.", + file=sys.stderr, ) - if args.verbose: - svcs = await client.get_services_debug() - print("Services:\n" + svcs) - await client.run_until_disconnect(stop_event) - print(format_event("Disconnected", "warn")) - except BleakError as e: - print(format_event(f"BLE error: {e}", "err"), file=sys.stderr) - # Provide hints for common cases - msg = str(e).lower() - if "failed to execute management command" in msg or "not available" in msg: - print( - "Hint: Ensure Bluetooth adapter is powered and not blocked (rfkill).", file=sys.stderr) - if "permission" in msg and sys.platform.startswith("linux"): - print( - "Hint: Missing permissions. Consider adding user to 'bluetooth' group or setcap 'cap_net_raw+eip' on python.", - file=sys.stderr, - ) - except Exception as e: # pragma: no cover - unexpected path - print(format_event( - f"Unexpected error: {e}", "err"), file=sys.stderr) - finally: - await client.disconnect() - if stop_event.is_set() or not args.reconnect: - break - # Backoff decisions - if stable_connected_since and (loop.time() - stable_connected_since) > 60: - # Reset backoff after stable period - backoff_iter = exponential_backoff( - initial=args.backoff, cap=15.0) - retries += 1 - if retries > args.max_retries: - print(format_event("Max retries reached, exiting.", "err")) - break - delay = await backoff_iter.__anext__() - print(format_event(f"Reconnecting in {delay:.2f}s...", "warn")) - try: - await asyncio.wait_for(stop_event.wait(), timeout=delay) - break # stop requested - except asyncio.TimeoutError: - continue - - stop_event.set() - idle_task.cancel() + except Exception as e: # pragma: no cover - unexpected path + print(format_event( + f"Unexpected error: {e}", "err"), file=sys.stderr) + finally: + await client.disconnect() + stop_event.set() + idle_task.cancel() try: await idle_task except asyncio.CancelledError: # Expected during shutdown; suppress noisy traceback @@ -431,8 +396,6 @@ async def wizard_flow(base_args: argparse.Namespace) -> Optional[argparse.Namesp new_args.ts_local = ts_mode == 'local' new_args.raw = raw_hex new_args.logfile = logfile - # Force reconnect true by default - new_args.reconnect = True print(format_event(f"Selected {selected.name} ({selected.address})", "ok")) return new_args diff --git a/src/nus_logger/utils.py b/src/nus_logger/utils.py index 88e7e69..5e2d099 100644 --- a/src/nus_logger/utils.py +++ b/src/nus_logger/utils.py @@ -8,7 +8,7 @@ import sys from datetime import datetime, timezone from pathlib import Path -from typing import AsyncIterator, Optional, TextIO +from typing import Optional, TextIO def utc_ts() -> str: @@ -29,18 +29,6 @@ def local_ts() -> str: return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + f"{sign}{hh:02d}:{mm:02d}" -async def exponential_backoff(initial: float, cap: float) -> AsyncIterator[float]: - """Yield successive backoff delays with jitter until cancelled. - - Jitter: uniform(0, current/2). Delay doubles each time until `cap`. - """ - delay = max(0.0, initial) - while True: - jitter = random.uniform(0, delay / 2 if delay > 0 else 0.1) - yield delay + jitter - delay = min(cap, delay * 2 if delay else initial or 0.5) - - def open_log_file(path: str | os.PathLike[str]) -> Optional[TextIO]: if not path: return None diff --git a/tests/test_basic.py b/tests/test_basic.py index cedd136..1445fc4 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -19,25 +19,3 @@ def test_line_assembler_basic(): # partial parts = la.feed(b"partial") assert parts == [] - - -@pytest.mark.asyncio -async def test_exponential_backoff_progression(): - # ensure it yields increasing (roughly) values - seen = [] - async for i, delay in aenumerate(nus_logger.exponential_backoff(initial=0.1, cap=0.4), 5): - seen.append(delay) - if i >= 4: - break - assert len(seen) >= 5 - # Allow some jitter above nominal due to random component - assert max(seen) <= 0.65 - - -async def aenumerate(ait, n): # helper - i = 0 - async for val in ait: - yield i, val - i += 1 - if i >= n: - return From 16d83e51631946c71b18e00735a511ffdee8a9a8 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 9 Sep 2025 08:40:05 +0200 Subject: [PATCH 2/3] feat: implement automatic reconnection feature - Added `--reconnect` flag to enable/disable automatic reconnection after disconnect. - Updated CLI help to reflect new reconnection options. - Introduced spinner for device scanning feedback. - Enhanced connection logic to support reconnection attempts. --- README.md | 72 +++++--------------- src/nus_logger/nus_logger.py | 126 ++++++++++++++++++++++++++++++----- 2 files changed, 124 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index a35ecff..e465f6a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## ✨ Highlights - **Zero‑config CLI**: discover, connect, stream logs in one command. -- **Simple**: single connection session; exits cleanly on disconnect. +- **Resilient**: automatic reconnect to the same device after link loss (disable with `--no-reconnect`). - **Readable timestamps**: UTC (`--ts`) or local (`--ts-local`). - **Dual view**: optional raw hex alongside decoded UTF‑8 text (`--raw`). - **Log persistence**: safe append mode (rotation‑friendly) to any file. @@ -65,70 +65,28 @@ Module mode (equivalent): python -m nus_logger --name my-device --ts ``` -Press Ctrl-C to stop; the tool exits on disconnect. +Press Ctrl-C to stop. By default the tool will auto‑reconnect after an unexpected disconnect; use `--no-reconnect` to revert to single‑session behaviour. ## CLI Reference Environment variables override flags when corresponding flags are omitted. -| Flag | Description | Env | Notes | -| ---------------------- | ----------------------------------- | ------------- | -------------------------------- | --- | --- | --- | -| `--wizard` | Interactive scan & option wizard | – | Default when no args | -| `--list` | List visible devices then exit | – | Passive scan only | -| `--name SUBSTR` | Match advertising name | `NUS_NAME` | Case-insensitive substring | -| `--filter-addr SUBSTR` | Prefer address containing substring | – | Helps disambiguate similar names | -| `--ts` / `--ts-local` | Add UTC or local timestamps | – | Mutually exclusive | -| `--raw` | Show hex bytes before decoded line | – | Two aligned columns | -| `--logfile PATH` | Append decoded lines to file | `NUS_LOGFILE` | File is created if missing | -| `--timeout SECS` | Scan / connect timeout | `NUS_TIMEOUT` | Applies to each attempt | – | – | – | -| `--verbose` | Dump discovered GATT structure once | – | For debugging / inspection | - -
Show full help example - -```text -nus-logger --help -``` +| 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) |
-## Programmatic Use - -```python -import asyncio -from nus_logger.ble_nus import NUSClient - -async def main(): - client = NUSClient(name_substring="my-device") - await client.connect() - try: - async for line in client.iter_lines(): # yields decoded UTF-8 log lines - print(line) - if "READY" in line: - await client.write(b"ping\n") # optional upstream write - finally: - await client.disconnect() - -asyncio.run(main()) -``` - -See `nus_logger.nus_logger:main` for full CLI orchestration (reconnect logic, backoff, etc.). Higher level automation can use `NUSLoggerController` for managed sessions. - -### Minimal Flow (Conceptual) - -```text -┌─────────────┐ BLE (NUS) ┌──────────────┐ ┌─────────────┐ -│ Zephyr App │ ───────────────▶│ Adapter │────────────▶│ bleak API │ -└─────────────┘ └──────────────┘ └─────────────┘ - LOG_INF() lines │ │ - ▼ ▼ - NUSClient (async) ──▶ line iterator - │ - ▼ - NUSLoggerController - │ - stdout / hex column / logfile -``` - ## Typical Workflow (Zephyr / nRF Connect) To stream the Zephyr logging subsystem over BLE for `nus-logger` to consume you should enable the BLE logging backend with `CONFIG_LOG_BACKEND_BLE=y`. The backend handles formatting, buffering and transport so normal `LOG_INF()/LOG_ERR()` etc. arrive as text lines. diff --git a/src/nus_logger/nus_logger.py b/src/nus_logger/nus_logger.py index 55b632a..9ea019e 100644 --- a/src/nus_logger/nus_logger.py +++ b/src/nus_logger/nus_logger.py @@ -14,7 +14,7 @@ import os import signal import sys -from typing import Optional, TextIO +from typing import Optional, TextIO, Awaitable, TypeVar from bleak.exc import BleakError @@ -46,6 +46,9 @@ def env_default(name: str, fallback: Optional[str] = None) -> Optional[str]: return os.environ.get(name, fallback) +T = TypeVar("T") + + def parse_args(argv: list[str]) -> argparse.Namespace: p = argparse.ArgumentParser( description="Read Nordic UART Service logs over BLE.") @@ -71,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") + # Reconnection control: default on; allow --no-reconnect to disable. + try: # Python 3.9+ supports BooleanOptionalAction + p.add_argument("--reconnect", action=argparse.BooleanOptionalAction, default=True, + help="Automatically rescan & reconnect after disconnect (default: enabled)") + except AttributeError: # pragma: no cover - fallback for very old Python, though unsupported + p.add_argument("--no-reconnect", action="store_true", + help="Disable automatic reconnection attempts") args = p.parse_args(argv) # If user supplied no arguments at all, treat as --wizard @@ -85,6 +95,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace: 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)") + # Normalize reconnect flag when fallback arg style used + if hasattr(args, "no_reconnect"): + args.reconnect = not args.no_reconnect # type: ignore[attr-defined] + if args.ts and args.ts_local: p.error("--ts and --ts-local are mutually exclusive") return args @@ -106,10 +120,40 @@ def decode_line(raw: bytes) -> str: return raw.decode("utf-8", errors="replace") +async def _run_with_spinner(aw: Awaitable[T], message: str, interval: float = 0.15) -> T: + """Run an awaitable while displaying a simple spinner (TTY only). + + Clears the line when done. If stdout isn't a TTY, no spinner is shown. + """ + if not sys.stdout.isatty(): + return await aw + spinner = "/-\\|" + # ensure_future accepts Awaitable and wraps appropriately + task: asyncio.Future[T] = asyncio.ensure_future( + aw) # type: ignore[assignment] + i = 0 + msg = message.rstrip() + try: + while not task.done(): + ch = spinner[i % len(spinner)] + print(f"\r{msg} {ch}", end="", flush=True) + await asyncio.sleep(interval) + i += 1 + return await task + finally: + # Clear line + if sys.stdout.isatty(): + blank = " " * (len(msg) + 2) + print(f"\r{blank}\r", end="", flush=True) + + async def list_devices(timeout: float, adapter: Optional[str]) -> int: client = NUSClient() try: - devices = await client.scan(name="", timeout=timeout, adapter=adapter) + devices = await _run_with_spinner( + client.scan(name="", timeout=timeout, adapter=adapter), + "Scanning for devices", + ) except BleakError as e: print(format_event(f"Scan failed: {e}", "err"), file=sys.stderr) return 2 @@ -270,24 +314,72 @@ def _on_bytes(chunk: bytes) -> None: idle_task = asyncio.create_task(_flush_idle()) - # Single connection attempt then exit on disconnect + # Connection loop with optional automatic re-scan & reconnect to the same device. try: - device = await client.scan_and_connect( - name=args.name, - timeout=args.timeout, - adapter=args.adapter, - preferred_addr_substring=args.filter_addr, - ) - print( - format_event( - f"Connected to {device.name} ({device.address}) RSSI={device.rssi}dBm", "ok" + try: + device = await _run_with_spinner( + client.scan_and_connect( + name=args.name, + timeout=args.timeout, + adapter=args.adapter, + preferred_addr_substring=args.filter_addr, + ), + f"Scanning for '{args.name}'", ) - ) - if args.verbose: - svcs = await client.get_services_debug() - print("Services:\n" + svcs) + 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) await client.run_until_disconnect(stop_event) - print(format_event("Disconnected", "warn")) + if not args.reconnect or stop_event.is_set(): + if not stop_event.is_set(): + print(format_event("Disconnected", "warn")) + return 0 + + # Reconnection loop if enabled + while args.reconnect and not stop_event.is_set(): + print(format_event("Disconnected", "warn")) + print(format_event( + "Waiting for device to reappear (Ctrl-C to quit)...", "warn")) + + # Reconnection scan attempts until success or stop + while not stop_event.is_set(): + try: + next_dev = await _run_with_spinner( + client.scan_and_connect( + name=device.name, + timeout=args.timeout, + adapter=args.adapter, + preferred_addr_substring=device.address, + ), + f"Re-scanning for '{device.name}'", + ) + device = next_dev + print(format_event( + f"Reconnected to {device.name} ({device.address}) RSSI={device.rssi}dBm", "ok")) + if args.verbose: + svcs = await client.get_services_debug() + print("Services:\n" + svcs) + break + except BleakError: + if args.verbose and not stop_event.is_set(): + print(format_event( + "Device not yet visible; retrying...", "warn")) + await asyncio.sleep(1.0) + + if stop_event.is_set(): + break + # After a successful reconnection, wait again for next disconnect + await client.run_until_disconnect(stop_event) except BleakError as e: print(format_event(f"BLE error: {e}", "err"), file=sys.stderr) msg = str(e).lower() From fb4a1cbb2fe25d03cdeec59569d9638574960f14 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 9 Sep 2025 08:40:38 +0200 Subject: [PATCH 3/3] chore: update version to 0.1.2 - Bumped version from 0.1.1 to 0.1.2 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a01c0ab..b525daa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "nus-logger" -version = "0.1.1" +version = "0.1.2" description = "Nordic UART Service (NUS) BLE logger." readme = "README.md" requires-python = ">=3.9"