Control embedded debug operations including device connection, firmware flashing, reset, and memory access using J-Link debug probes.
Import
from lager import Net, NetType
Methods
The Net-based API provides methods for embedded debugging operations.
| Method | Description |
|---|
connect(speed, transport, *, script, force, ignore_if_connected) | Connect to target device (optional per-connect J-Link script override) |
disconnect() | Disconnect from target |
reset(halt) | Reset the device |
flash(firmware_path) | Flash firmware to device |
erase() | Perform full chip erase |
read_memory(address, length) | Read memory from device |
status() | Get connection status |
rtt(channel, search_addr, search_size, chunk_size) | Create RTT session for bidirectional communication (raw bytes) |
rtt_defmt(elf, channel) | RTT session decoded through defmt-print (yields log lines) |
session(...) | Scoped session: connect on entry, guaranteed teardown on exit |
Method Reference
Net.get(name, type=NetType.Debug)
Get a debug net by name.
from lager import Net, NetType
dbg = Net.get('DUT', type=NetType.Debug)
Parameters:
| Parameter | Type | Description |
|---|
name | str | Name of the debug net |
type | NetType | Must be NetType.Debug |
Returns: Debug Net instance
Note: The debug net must be configured with the target device name stored in the channel field (e.g., ‘NRF52840_XXAA’, ‘R7FA0E107’).
connect(speed=None, transport=None, *, script=None, force=False, ignore_if_connected=False)
Connect to the target device (start the gdbserver for this probe). The backend
(J-Link or OpenOCD) is chosen automatically from the probe.
# Connect with default settings (4000 kHz, SWD)
dbg.connect()
# Connect with custom speed
dbg.connect(speed='adaptive')
# Connect with JTAG
dbg.connect(transport='JTAG')
# Connect with a per-connect J-Link script override (box path or base64 blob)
dbg.connect(script='/home/lagerdata/probes/my_target.JLinkScript')
Parameters:
| Parameter | Type | Default | Description |
|---|
speed | str | '4000' | Interface speed in kHz (e.g., ‘4000’) or ‘adaptive’ |
transport | str | 'SWD' | Transport protocol (‘SWD’ or ‘JTAG’) |
script | str | None | J-Link only — a per-connect .JLinkScript override: a path on the box or a base64-encoded script blob. It is copied to the shared script temp path so subsequent flash() / reset() / read_memory() calls pick it up immediately. The OpenOCD backend ignores this. |
force | bool | False | Stop any gdbserver already running for this probe and start fresh. |
ignore_if_connected | bool | False | If a gdbserver is already running for this probe, return its status without touching it. |
Returns: dict - Status dictionary with connection information
A script override is only adopted by a relaunch of the gdbserver. If a server
is already running, pass force=True to restart it with the new script;
ignore_if_connected=True returns early without relaunching (though the script
file is still repointed for subsequent Commander operations). Invalid input — a
missing path that isn’t valid base64, or an empty string — is silently ignored and
the net’s previously materialised script stays in effect. Concurrency caveat:
two debug nets connecting with different scripts share one temp path and can clobber
each other.
disconnect()
Disconnect from the target device.
Returns: dict - Status dictionary
reset(halt=False)
Reset the device.
# Reset and continue execution
output = dbg.reset(halt=False)
print(output)
# Reset and halt for debugging
output = dbg.reset(halt=True)
print(output)
Parameters:
| Parameter | Type | Default | Description |
|---|
halt | bool | False | Halt CPU after reset |
Returns: str - Combined output from reset operation
Self-heal (both backends): Right after a flash() there is a short window where the debug server isn’t reachable yet — for J-Link the restarted GDB server’s PID isn’t observable, for OpenOCD a transient daemon/RPC fault — and a bare call would raise. reset() now retries with bounded backoff on both the J-Link and OpenOCD backends, and only (re)starts a server when one is genuinely down. It never tears down a server that is already running, so an attached RTT session is left intact, and callers no longer need their own retry wrappers.
DA1469x exception: On DA1469x, flash() deliberately leaves the server down (it ends in a software reset rather than a server restart) and the documented flow is an explicit, halt-aware reconnect. So on DA1469x the self-heal retries but never auto-starts a server — a genuinely-down server still surfaces the original error, exactly as before, instead of silently coming up unhalted (which can yield garbage QSPI-XIP reads).
flash(firmware_path)
Flash firmware to the device.
# Flash a hex file
output = dbg.flash('/path/to/firmware.hex')
print(output)
# Flash a binary file (address 0x00000000 assumed)
output = dbg.flash('/path/to/firmware.bin')
print(output)
# Flash an ELF file
output = dbg.flash('/path/to/firmware.elf')
print(output)
Parameters:
| Parameter | Type | Description |
|---|
firmware_path | str | Path to firmware file (.hex, .bin, or .elf) |
Returns: str - Combined output from flash operation
Note: For .bin files, flash address defaults to 0x00000000.
erase()
Perform full chip erase. This erases ALL flash memory including protection settings.
# Full chip erase
output = dbg.erase()
print(output)
Returns: str - Combined output from erase operation
read_memory(address, length)
Read memory from the target device.
# Read 256 bytes starting at address 0x20000000
data = dbg.read_memory(0x20000000, 256)
print(f"Read {len(data)} bytes")
print(data.hex())
Parameters:
| Parameter | Type | Description |
|---|
address | int | Starting memory address |
length | int | Number of bytes to read |
Returns: bytes - Memory data
Self-heal: like reset(), read_memory() retries with bounded backoff across the brief post-flash() settling window on both backends and only reconnects when no server is running, never disturbing a live session. erase() behaves the same way. The same DA1469x exception applies — no server is auto-started, so a post-flash DA1469x read raises clearly rather than returning unhalted-XIP garbage.
status()
Get the current connection status.
status = dbg.status()
print(f"Connected: {status.get('connected', False)}")
Returns: dict - Status dictionary with connection information
session(speed=None, transport=None, connect=True, ignore_if_connected=True, disconnect_on_exit=True)
Scoped debug session. Connects on entry and guarantees teardown on exit, so the safe flash → attach-RTT → reset ordering is encoded once instead of being rediscovered in every script. The with target is the net itself, so the full surface (flash, rtt_defmt, reset, read_memory, …) is available inside the block.
with dbg.session() as s:
s.flash('build/app.hex') # built-in stop->flash->restart handoff
with s.rtt_defmt(elf='build/app.elf') as logs:
s.reset(halt=False) # reader re-attaches across the reset blip
for line in logs:
if 'boot ok' in line:
break
# GDB server is torn down here (disconnect_on_exit=True)
Parameters:
| Parameter | Type | Default | Description |
|---|
speed | str or None | None | Forwarded to connect() |
transport | str or None | None | Forwarded to connect() |
connect | bool | True | Connect on entry. Set False to attach to a server you manage yourself |
ignore_if_connected | bool | True | Reuse a running server instead of raising (regression-safe: never restarts a live server) |
disconnect_on_exit | bool | True | Stop the GDB server on exit. Set False to leave it running for later commands |
Returns: a context manager yielding the debug net.
Why it pairs with RTT: the in-process RTT reader is reconnect-aware (see below), so a flash() or reset() inside the session that bounces the GDB server doesn’t kill a log stream opened in the same block.
rtt(channel=0, search_addr=None, search_size=None, chunk_size=None)
Create an RTT (Real-Time Transfer) session for bidirectional communication with the target device.
# Open RTT session on default channel (0)
with dbg.rtt() as rtt:
# Read debug output
data = rtt.read_some(timeout=1.0)
if data:
print(data.decode('utf-8'))
# Send commands to device
rtt.write(b'test_command\n')
# Use different RTT channel
with dbg.rtt(channel=1) as rtt:
data = rtt.read_some(timeout=2.0)
# Specify RAM search region for RTT control block
with dbg.rtt(search_addr=0x20000000, search_size=0x10000) as rtt:
data = rtt.read_some(timeout=1.0)
Parameters:
| Parameter | Type | Default | Description |
|---|
channel | int | 0 | RTT channel number (typically 0-15) |
search_addr | int or None | None | RAM start address for RTT control block search |
search_size | int or None | None | Size of RAM region to search in bytes |
chunk_size | int or None | None | Size of each read chunk in bytes |
Returns: RTT context manager with methods:
read_some(timeout) - Read available data with timeout (returns bytes or None)
write(data) - Write data to target (accepts bytes or str)
Note: Debug connection must be active before using RTT. Call connect() first.
Reconnect-aware (both backends): a J-Link flash() (and reset() via its Commander grab) briefly frees the probe’s USB and restarts the GDB server on the same ports, dropping the RTT socket. The reader transparently re-attaches to the same RTT telnet port instead of going silent, so a long-lived read_some() / rtt_defmt() loop keeps producing across a flash. The OpenOCD reader is reconnect-aware too — OpenOCD keeps its daemon up across an ordinary flash so the socket rarely drops, but if it does (daemon force-restart or rtt-server bounce) the reader re-runs the rtt setup / rtt server start and re-attaches. In both cases reconnection is bounded (default 30 s) and only re-attaches once the server/daemon is actually back up — it never starts one — so a flash that deliberately leaves the server down (e.g. DA1469x) won’t spin forever, and the reader can’t disturb a DA1469x left intentionally down. Pass reconnect=False for the legacy one-shot behavior.
Examples
Flash Firmware and Reset
from lager import Net, NetType
# Get debug net
dbg = Net.get('DUT', type=NetType.Debug)
# Connect to target
status = dbg.connect()
print(f"Connected: {status}")
# Flash firmware
output = dbg.flash('/etc/lager/firmware/app.hex')
print(output)
# Reset and run
output = dbg.reset(halt=False)
print(output)
# Disconnect
dbg.disconnect()
Chip Erase Before Programming
from lager import Net, NetType
# Get debug net
dbg = Net.get('DUT', type=NetType.Debug)
# Connect to target
status = dbg.connect()
print(f"Connected: {status}")
# Erase entire chip first (ensures clean state)
print("Erasing chip...")
output = dbg.erase()
print(output)
# Flash new firmware
output = dbg.flash('/etc/lager/firmware/app.hex')
print(output)
# Disconnect
dbg.disconnect()
Read Memory
from lager import Net, NetType
dbg = Net.get('DUT', type=NetType.Debug)
# Connect to target
dbg.connect()
# Read 256 bytes from RAM
data = dbg.read_memory(0x20000000, 256)
print(f"Read {len(data)} bytes")
print(data.hex())
# Disconnect
dbg.disconnect()
CLI Commands (Recommended)
For most use cases, the CLI provides a simpler interface:
# Start GDB server (connect to target)
lager debug <net> gdbserver --box <box-name>
# Flash firmware
lager debug <net> flash --hex firmware.hex --box <box-name>
# Reset device
lager debug <net> reset --box <box-name>
# Erase flash
lager debug <net> erase --box <box-name>
# Read memory
lager debug <net> memrd 0x20000000 256 --box <box-name>
# Disconnect
lager debug <net> disconnect --box <box-name>
# Check status
lager debug <net> status --box <box-name>
See the CLI Debug Reference for full CLI documentation.
RTT Streaming
SEGGER Real-Time Transfer (RTT) enables high-speed bidirectional communication with embedded devices during debugging (faster than UART, no timing impact).
from lager import Net, NetType
# Connect debug probe first
debug = Net.get('debug1', type=NetType.Debug)
debug.connect()
# Open RTT session for reading debug output
with debug.rtt() as rtt:
# Read debug output from MCU
data = rtt.read_some(timeout=1.0)
if data:
print(data.decode('utf-8'))
# Can also write commands to MCU
rtt.write(b'start_test\n')
RTT Methods:
| Method | Description |
|---|
read_some(timeout) | Read available data with timeout (returns bytes or None) |
write(data) | Write data to RTT (accepts bytes or str) |
rtt().read_some() returns raw, still-encoded bytes. Firmware that logs with defmt (the de-facto standard for embedded Rust) emits a compressed binary format — calling .decode('utf-8') on it yields garbage. For defmt firmware, use rtt_defmt() below or the CLI pipe, both of which decode through defmt-print.
Decoding defmt logs with rtt_defmt()
rtt_defmt(elf, channel=0) opens an RTT session and pipes it through defmt-print (preinstalled on the Lager Box), yielding decoded log lines instead of raw bytes. The elf must be the exact firmware flashed on the target — defmt needs its symbol metadata to decode.
from lager import Net, NetType
import time
dbg = Net.get('debug1', type=NetType.Debug)
dbg.connect(ignore_if_connected=True) # reuse a running gdbserver if one is up
dbg.flash('build/app.elf') # skip if already flashed; same ELF you decode against
dbg.reset() # restart to capture boot logs
# Capture a bounded ~10s window of decoded logs
with dbg.rtt_defmt(elf='build/app.elf', channel=0) as logs:
deadline = time.time() + 10
while time.time() < deadline:
line = logs.read_line(timeout=1.0) # decoded str, or None
if line:
print(line)
assert 'panic' not in line.lower(), f"firmware panicked: {line}"
rtt_defmt() returns a context manager exposing:
| Method | Description |
|---|
read_line(timeout=None) | Next decoded log line as str, or None on timeout / stream end |
iteration (for line in logs:) | Yield decoded lines until the stream ends |
Like the CLI pipe, the RTT stream never ends on its own — bound your read loop with a time budget or line count, then exit the with block.
Parameters:
| Parameter | Type | Default | Description |
|---|
elf | str | required | Path to the firmware ELF flashed on the DUT (relative paths resolve against the script’s working dir on the box) |
channel | int | 0 | RTT channel number |
defmt_print_bin | str or None | None | Override the defmt-print binary (path or name on PATH) |
read_timeout | float | 0.5 | Poll interval (seconds) for the internal RTT read loop |
CLI Alternative: For interactive tailing, pipe the CLI directly: lager debug <net> gdbserver --box <box> --rtt 2>/dev/null | defmt-print -e build/app.elf. See the CLI Debug Reference. Use rtt_defmt() when you need to assert on log content inside a test script; use the pipe when you just want to watch logs.
Supported Devices
J-Link supports a wide range of ARM Cortex-M and other microcontrollers. Common device names:
| Manufacturer | Device Name | Description |
|---|
| Nordic | NRF52840_XXAA | nRF52840 |
| Nordic | NRF52833_XXAA | nRF52833 |
| Nordic | NRF5340_XXAA_APP | nRF5340 Application Core |
| Renesas | R7FA0E107 | RA0E1 Series |
| Renesas | R7FA2L1 | RA2L1 Series |
| STMicro | STM32F103C8 | STM32F1 Series |
| STMicro | STM32F407VG | STM32F4 Series |
| STMicro | STM32L476RG | STM32L4 Series |
For a complete list, see SEGGER’s supported devices.
Supported Hardware
| Debug Probe | Features |
|---|
| J-Link | JTAG/SWD debugging, flash programming |
| CMSIS-DAP | SWD debugging (via pyOCD backend) |
| ST-Link | SWD debugging (via pyOCD backend) |
Notes
- Debug nets must be configured with the target device name in the
channel field
- The CLI (
lager debug) is recommended for most use cases
- Python Net API is intended for advanced automation scripts running on the Lager Box
- Always call
disconnect() when finished to release the debug probe
- Use
erase() to perform a full chip erase and clear protection settings
- RTT requires an active debug connection (see RTT Streaming section above)