Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 17 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<h1>NUS Logger</h1>

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

<!-- Badges -->
<p>
Expand All @@ -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.
Expand Down Expand Up @@ -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 |

<details><summary><strong>Show full help example</strong></summary>

```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) |

</details>

## 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.
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
3 changes: 1 addition & 2 deletions src/nus_logger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -26,7 +26,6 @@
"LineAssembler",
"utc_ts",
"local_ts",
"exponential_backoff",
"open_log_file",
"NUS_SERVICE_UUID",
"NUS_RX_CHAR_UUID",
Expand Down
56 changes: 13 additions & 43 deletions src/nus_logger/logger_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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
Expand Down Expand Up @@ -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 ---------------------------------
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading