diff --git a/README.md b/README.md
index 692ec91..e465f6a 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.
+- **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,72 +65,28 @@ 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. 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 |
-| `--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 |
-
-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.
@@ -144,7 +100,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..b525daa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "nus-logger"
-version = "0.1.1"
-description = "Auto-reconnecting Nordic UART Service (NUS) BLE logger."
+version = "0.1.2"
+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..9ea019e 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
@@ -14,11 +14,11 @@
import os
import signal
import sys
-from typing import Optional, TextIO
+from typing import Optional, TextIO, Awaitable, TypeVar
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
@@ -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.")
@@ -65,21 +68,19 @@ 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",
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
@@ -94,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
@@ -115,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
@@ -277,22 +312,20 @@ 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():
+ # Connection loop with optional automatic re-scan & reconnect to the same device.
+ try:
try:
- device = await client.scan_and_connect(
- name=args.name,
- timeout=args.timeout,
- adapter=args.adapter,
- preferred_addr_substring=args.filter_addr,
+ 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}'",
)
- stable_connected_since = loop.time()
- retries = 0
print(
format_event(
f"Connected to {device.name} ({device.address}) RSSI={device.rssi}dBm", "ok"
@@ -301,46 +334,70 @@ def _on_bytes(chunk: bytes) -> None:
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
+ # Initial connection failure => exit (retain existing behaviour)
+ raise e
+
+ # Run until disconnect once (always)
+ await client.run_until_disconnect(stop_event)
+ 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(
- 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
+ "Waiting for device to reappear (Ctrl-C to quit)...", "warn"))
- stop_event.set()
- idle_task.cancel()
+ # 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()
+ 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()
+ stop_event.set()
+ idle_task.cancel()
try:
await idle_task
except asyncio.CancelledError: # Expected during shutdown; suppress noisy traceback
@@ -431,8 +488,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