Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Guide

nano-ros config is language-agnostic and has one home per concern — no setting lives in two places, and nothing is merged across files. The authored surfaces are a universal system.toml, package.xml, and launch XML — plus Kconfig for the embedded build (Zephyr). Cargo.toml and CMakeLists.txt carry language-toolchain facts and nothing else. This guide covers what each file owns, the network identity an embedded image bakes at build time, and the standalone config.toml for hand-written no_std apps.

Retired surfaces you may find in old material: the nros.toml per-package/workspace file (rejected by the CLI), the old config.toml [network]/[zenoh]/[scheduling] schema, every [package.metadata.nros.{node,entry,component,deploy.*}] table, and the package.xml <nano_ros deploy=… board=… rmw=…/> tuple. The last two moved into system.toml under RFC-0098; readers refuse them rather than reading them. Design of record: RFC-0004.

One home per concern

FileOwnsPer
system.tomlEverything about the deployment — [system] (RMW, domain, capability axes like features = ["param_services"] and the [safety] block, tiers), [[component]] (what runs, and its entities), and one [image.<id>] per target (board, locator, and the image’s address on a network, net = { eth0 = { network = "qemu", host = 10 } }; a project’s own networks are [network.<name>]). One schema for Rust, C and C++, and the same file whether the project is one package or a workspace.package, or bringup pkg
Cargo.tomlRust toolchain facts only: crate, language deps, the RMW feature menu (rmw-zenoh/rmw-cyclonedds/rmw-xrce) the generated entry selects from. No board, no target triple, no deploy block.Rust project
CMakeLists.txtC/C++ toolchain facts only: targets and language deps, via find_package(nano_ros) + nano_ros_add_executable / nano_ros_auto_add_library + nros_components_register_node (RFC-0057; nano_ros_node_register remains as the compat spelling) / nano_ros_entry. No platform, board or RMW set().C/C++ project
build/<image>/nros-cargo.tomlGENERATED, never edited — every cargo setting the board choice implies: [build] target, [target.*] runner and rustflags, the cross compiler, the derived pool sizes as [env], the nano-ros profiles, and the [patch.crates-io] rows that resolve nano-ros’s registry-style names into your checkout. Written by nros sync, passed to cargo with --config.image
package.xmlROS package identity + msg <depend>s (codegen input for nros generate)all
config.toml (standalone)Hand-written no_std direct-mode apps only — [node] / [[transport]] / [node.rt], compile-baked via Config::from_toml(include_str!(…)). Apps that use nros::main!() / codegen do not have one — they use [image.<id>] above.embedded single-node app (no codegen)

Boundary rule. If a knob changes what is compiled/linked as a language choice — a C++ standard, an RMW feature the entry may select — it lives in the build file. Everything else about the deployment (components, board, domain, RMW, and the physical link + router address a target boots with) lives in system.toml. Nothing a board implies is authored anywhere: it is derived from the board descriptor into the generated settings file.

Config home by language × scale

Since RFC-0098 this table has one answer in all four cells, which is the point of it:

Single-nodeWorkspace
Rustsystem.toml beside Cargo.tomlsystem.toml in the bringup pkg
C / C++system.toml beside CMakeLists.txtsystem.toml in the bringup pkg

A single-package leaf and a workspace bringup use the same schema, so the resolver has one input shape and you have one thing to learn. Resolution is a fixed precedence ladder, not a merge: explicit CLI/build flag (--rmw / -DNROS_*) > system.toml ([image.<id>] > [system]) > the board descriptor’s [knobs] > built-in default. nros config show prints the resolved effective config with per-value provenance; nros check flags values still sourced from legacy files.

Full design rationale: RFC-0004 (configuration & transports); RMW backend selection & lowering is RFC-0031. For the multi-RMW runtime topic-forwarding bridge (a separate file/feature), see nros-bridge.toml.

Embedded image config (codegen apps — the common case)

An embedded app built through nros::main!() (Rust) or the CMake entry codegen (C/C++) declares its board and its network + router config per image, in system.toml. The toolchain bakes it at compile time — there is no config file on the device and nothing is parsed at runtime. The file is the same in both languages:

# system.toml (e.g. examples/mps2-an385-baremetal/rust/talker)
[system]
name      = "qemu_bsp_talker"
rmw       = "zenoh"
domain_id = 0

[[component]]
pkg      = "qemu_bsp_talker"
class    = "qemu_bsp_talker::Talker"
name     = "talker"
dispatch = "deferred"          # optional: callbacks hand off to a ring

[image.mps2-an385-baremetal]
board   = "qemu-mps2-an385"
locator = "tcp/10.0.2.2:10500"
net     = { eth0 = { network = "qemu", host = 10 } }

The [image.<id>] key is the image’s name — what you pass to nros build <image> — and board is the only thing that has to change to retarget it. nros::main!() bakes the image’s network fields into a DeployOverlay that BoardEntry::run_with_deploy applies onto the board’s boot Config; the C/C++ entry codegen bakes the same values into its generated glue. Nothing is written in Cargo.toml, in package.xml or on a cmake command line.

Networks and addresses

A network fact has three owners (RFC-0103 D10): the board states its interfaces, the project states its networks, and the image states its address on one. The image’s half is net, one entry per interface:

[image.talker]
board = "qemu-mps2-an385"
net   = { eth0 = { network = "qemu", host = 10 } }   # 10.0.2.10 on QEMU's network

qemu is built in: QEMU’s own user-mode (SLIRP) network, 10.0.2.0/24 with gateway and router host 10.0.2.2. Every -nic user makes a private copy of it, so two images on qemu never meet. Any other network is the project’s, stated once and named by every image on it:

[network.lab]                  # e.g. a switch or a Linux bridge with boards on it
subnet  = "192.168.50.0/24"
gateway = "192.168.50.1"
router  = "tcp/192.168.50.1:7447"    # the locator its images dial by default

[image.sensor]
board = "qemu-mps2-an385"
net   = { eth0 = { network = "lab", address = "192.168.50.20" } }

Per interface:

  • network defaults to the board’s (qemu on every QEMU board), so net = { eth0 = { host = 10 } } is enough there.
  • host is the last octet within the network’s subnet; address is the full IPv4, or "dhcp" on a network with dhcp = true (and a board whose interface runs a DHCP client). An interface on qemu that states no address takes 10.0.2.15, the address a plain -nic user guest picks.
  • mac is optional. Left out, it is derived from the board interface’s mac_prefix and the system, image, network and interface names, so two images never share one by accident.
  • The gateway and netmask come from the network, so an image never states them.
  • An image dials its own locator, else its network’s router, else [system] locator. On a network other than qemu one of the three must be stated.

A board may have several interfaces. An image addresses each on its own network, net = { eth0 = { … }, eth1 = { … } }.

These fail the build, naming the image (and, for a duplicate, both images):

  • an address outside its subnet, equal to the gateway, or on a network that does not exist;
  • two images with the same static address, or the same MAC, on one network other than qemu;
  • net on a board whose network belongs to the host operating system (linux, freertos-posix, threadx-linux), or on a board that declares no interfaces (Zephyr boards take their address from Kconfig);
  • the old flat ip / gateway / netmask keys on [image.<id>].

The board’s half is [board.net] in its descriptor: network (its default network) and interfaces, each a kind (eth0 = "ethernet") or a table (eth0 = { kind = "ethernet", mac_prefix = "52:54:00", dhcp = true }). A board states no address: [board.net] ip / mac are refused.

The resolved answer for an image is written to build/nros/net/<image>.toml (beside the workspace or leaf) and reaches the firmware as NROS_IMAGE_NET.

An emulated image joins its network through the network’s attach: "slirp" (QEMU’s own user-mode network, what qemu is), { bridge = "<dev>" } or { tap = "<dev>" } on the host. A network with no attach is physical only, and an emulated image on it is refused at launch. nros ws qemu-nic <leaf> --model <nic-model> prints the -nic value for a leaf’s image, for a script that starts QEMU itself:

[network.br]
subnet = "192.0.3.0/24"
gateway = "192.0.3.1"
router = "tcp/192.0.3.1:7447"
attach = { bridge = "qemu-br" }   # QEMU: -nic bridge,br=qemu-br,…

Per-image build knobs

A build knob that one image needs, and that its board does not state, goes in [image.<id>.knobs.<group>]. The table has the same shape as a board descriptor’s [knobs.<group>]:

[image.zephyr_xrce.knobs.zephyr]
heap_size = 98304            # NROS_ZEPHYR_HEAP_SIZE

[image.zephyr_xrce.knobs.xrce]
max_subscribers = 1          # NROS_XRCE_MAX_SUBSCRIBERS (-1, the default, derives it)

[image.linux.knobs.log]
max_level = "warn"           # NROS_LOG_MAX_LEVEL (a level name or 0..6)

[image.linux.knobs.executor]
max_cbs = 16                 # NROS_EXECUTOR_MAX_CBS

Each key is a row of the knob registry (packages/tooling/nros-zephyr-build/knobs.toml). The key is the row’s board key where it has one (executor.max_cbs, zenoh.tx.batch); otherwise it is the env name without NROS_, split at the first _ (NROS_ZEPHYR_HEAP_SIZE becomes zephyr.heap_size). An unknown key, a value of the wrong type or a value outside the knob’s range stops nros sync / nros build with an error that names the key. The value reaches the build exactly as the knob’s NROS_* variable would: through the generated cargo config on the cargo road, the image env file on the cmake road, and the Kconfig defaults on west. It sits on the image rung (above the board, below an exported variable).

[image.<id>] env = { NAME = "value" } still carries an application’s own build variable, a name the registry does not hold. An env row that names a registry knob is an error that names its knobs spelling (release 0.6.0 accepted it with a warning).

RT/stack/priority for a single-node embedded app comes from board-crate Cargo features + Kconfig (prj*.conf on Zephyr), not a config file. Multi-node RT is declared in system.toml ([tiers.<name>.<rtos>] + [[node_overrides]]) — see RFC-0015.

Periodic timers after a stall

If a tier is preempted past several periods of a periodic timer, the backlog is coalesced, not replayed: the timer fires ONCE and the missed periods are counted. Phase is preserved, so activations stay on the declared cadence grid rather than drifting by each stall. This is the default (TimerOverrunPolicy::Skip) and matches rclcpp and Zephyr’s k_timer.

Replay is available — Executor::set_timer_overrun_policy(.., CatchUp) — and is the right choice for a counter or accumulator, where every tick carries state that must not be lost. It is the wrong choice for a control loop: the replayed activations compute with stale inputs and emit a burst of commands inside a fraction of a period.

Overruns are counted per timer (Executor::timer_overruns) and reported as the timer-overrun-runtime violation. Watch that rather than a publish-rate check: under CatchUp a replayed burst makes the achieved rate look nominal — measured 100.03 Hz on a declared 100 Hz loop while the tier was stalling for up to 611 ms at a time — so a rate monitor reports health during exactly the fault it exists to catch. Rationale and the measurements: RFC-0002 § 4.4a.

Timer periods are microseconds throughout, including across the C ABI.

Standalone config.toml — hand-written no_std apps (no codegen)

A hand-written single-node app that writes its own main() and bypasses nros::main!() keeps its net config in a file, not hardcoded in Rust: a sibling config.toml carrying [node] / [[transport]] / [node.rt], compile-baked with include_str! and parsed by the board crate’s Config::from_toml.

# config.toml

[node]
domain_id = 0              # ROS 2 domain ID (0–232)
# namespace = "/"
# rmw = "zenoh"            # ACTIVE backend; must match a LINKED backend
                           # (the build file picks what is linked)

# One transport per session. A single ethernet/wifi/serial entry is the
# common case. In-process topic forwarding is the separate [[bridge]] path.
[[transport]]
kind    = "ethernet"       # ethernet | wifi | serial | can
ip      = "10.0.2.10/24"   # CIDR — the prefix rides the address
mac     = "02:00:00:00:00:00"
gateway = "10.0.2.2"
locator = "tcp/10.0.2.2:7447"

[node.rt]                  # scheduling / real-time (RTOS); omit for defaults
app_priority         = 12
app_stack_bytes      = 65536
zenoh_read_priority  = 16
zenoh_lease_priority = 16
poll_priority        = 16
poll_interval_ms     = 5

Per-kind transport fields:

kindfields
ethernetip (CIDR), mac, gateway, interface
wifissid, password, optional static ip/gateway
serial / candevice, baudrate
allid, rmw, locator

ip is CIDR (10.0.2.10/24) — the board derives the prefix or netmask from it. A serial locator carrying # (serial/UART_0#baudrate=115200) is fine — quote it.

Consumption (compile-baked; no filesystem on the target):

use nros_board_mps2_an385::{Config, run};

fn main() -> ! {
    run(Config::from_toml(include_str!("../config.toml")), |config| {
        let exec = ExecutorConfig::new(config.zenoh_locator)
            .domain_id(config.domain_id);
        // ...
    })
}

Pick by whether the app uses codegen: nros::main!() app → system.toml [image.<id>] (above); hand-written main() → config.toml. Only the old config.toml schema ([network]/[zenoh]/[scheduling]) is retired.

Retired files

  • nros.toml — retired in full. A workspace-root nros.toml is rejected by the CLI (NrosTomlNotSupported; migrate with the nros-v0.5.0 tag’s nros migrate workspace — the one-shot verb is retired on newer trees, #186); the legacy per-package overlay is a deprecated fallback that nros check flags; the embedded-runtime role never shipped (no example ever declared one). If a doc tells you to write nros.toml, it predates the migration — the content belongs in system.toml.
  • Old config.toml schema ([network]/[zenoh]/[scheduling]) — retired; superseded by [image.<id>] (net) + board features / Kconfig (RT). The direct-mode config.toml above is the kept, supported shape.

Build-time environment variables

Read during cargo build (by build.rs) or by justfile recipes. Set in .env or export in your shell.

SDK paths

You normally set none of these. nros setup puts each vendored source tree in the shared store, and every build finds it through one ladder (RFC-0103 D3): an environment override, then your project’s [sources] in system.toml, then a checkout submodule you are editing, then the store, then the checkout’s own copy. Ask where a tree came from with nros locate <name> --why.

Set one of these variables only to build against a tree of your own:

VariableTree
FREERTOS_DIR / LWIP_DIRFreeRTOS kernel / lwIP
THREADX_DIR / NETX_DIRThreadX / NetX Duo
NUTTX_DIR / NUTTX_APPS_DIRNuttX RTOS / apps
CYCLONEDDS_SOURCE_DIRCyclone DDS source

For a tree that belongs to one project, prefer [sources] <name> = "<path>" in its system.toml (project-relative) over an exported variable, which every build in that shell inherits.

FREERTOS_PORT, FREERTOS_CONFIG_DIR, THREADX_CONFIG_DIR and NETX_CONFIG_DIR are not yours to set: they are facts about the board, stated in its nros-board.toml and carried to the build from there.

Buffer-tuning vars (ZPICO_*, XRCE_*, NROS_*) are optional — see the Environment Variables Reference.

Binary-size knobs (embedded)

On a constrained MCU, two build-time env vars — carried in the image’s generated build/<image>/nros-cargo.toml [env], like the other NROS_* tuning, and set there from the board descriptor’s [knobs] or from your own environment — shed the parts a brokered client doesn’t need:

VariableDefaultEffect
NROS_LINK_IPonNROS_LINK_IP=0 on a serial-only node drops the IP link layer — zenoh-pico’s TCP/UDP link C (Z_FEATURE_LINK_TCP/UDP=0) and (with --gc-sections) the smoltcp platform impl. Serial link stays.
NROS_SMOLTCP_MAX_SOCKETS / NROS_SMOLTCP_MAX_UDP_SOCKETS4 / 2Sized for DDS RTPS (3 UDP/participant). A zenoh/XRCE client multiplexes everything over one session → set both to 1 to drop the spare socket buffers (≈8 KB each).
NROS_HEAP_SIZEper-platform-crate (64 KB nros-platform-mps2-an385, 32 KB nros-platform-stm32f4)Decimal bytes for the bare-metal static heap. The defaults are generous; size to the RMW’s working set (table below). E.g. NROS_HEAP_SIZE = "24576" on a zenoh-pico node cut the mps2-an385 .data 66 → 25 KB (−41 KB).

Static-heap sizing by backend (bare-metal FreeListHeap, set via NROS_HEAP_SIZE):

BackendPeak working setRecommended heapNotes
zenoh-pico (TCP)~16 KB24–32 KB (≈2× for fragmentation)peer middleware; alloc-based session/buffers
zenoh-pico (serial)lighter than TCP16–24 KBno TCP link buffers; verified running at 16 KB
XRCE (Micro-XRCE-DDS)~3 KB (micro-ROS figure)~8 KBstatic pools, discovery offloaded to the agent — the RAM-minimal backend; a measured bare-metal XRCE figure is pending an example (no bare-metal XRCE example ships yet — XRCE bare-metal needs a custom-transport injection)

Measured footprint

Honest, reproducible numbers per (platform, transport, backend, profile) — built with the in-tree examples, the release profile is cargo’s default (opt-3), the size profile is the scaffolded [profile.size] (opt-s + lto

  • strip, see the size-tuned release profile notes). RAM = data + bss. All cells are after --gc-sections + the size knobs above are applied where noted; the serial cell ships with the recipe below.
platformtransportbackendprofiletext (flash code)databssRAM total
mps2-an385-baremetal (cortex-m3)ethernet (smoltcp)zenoh-picorelease177.4 KB67.0 KB91.7 KB158.7 KB
mps2-an385-baremetalethernetzenoh-picosize158.3 KB67.0 KB91.7 KB158.7 KB
mps2-an385-baremetalserial (no IP stack)zenoh-picorelease128.6 KB25.2 KB75.8 KB101.0 KB
mps2-an385-baremetalserialzenoh-picosize + recipe116.1 KB25.2 KB75.8 KB101.0 KB
stm32f4 (thumbv7em-eabihf, cortex-m4) ¹ethernetzenoh-picorelease186.9 KB13.7 KB123.0 KB136.7 KB
stm32f4 ¹ethernetzenoh-picosize138.1 KB13.7 KB123.0 KB136.7 KB
mps2-an385-freertos (cortex-m3 + lwIP, RTOS-reused stack)ethernet (lwIP)zenoh-picorelease240.6 KB10.7 KB3.3 MB3.3 MB
mps2-an385-baremetal (serial)serial (custom XRCE transport)XRCEsize, heap 24 KB, tight XRCE pools60.3 KB25.2 KB (heap 24 KB)8.8 KB~34 KB
micro-ROS reference (XRCE)serialXRCE-DDS Client-Os< 75 KB—~3 KB~3 KB peak

¹ The two stm32f4 rows are historical: the board crates left the tree in a later cleanup, so nothing in-tree reproduces them today. They stay because a Cortex-M4F-with-its-own-stack data point is still the closest published figure for that class, and deleting a measurement is worse than dating it. The chip crate the numbers were built against (nros-platform-stm32f4) is still here.

The XRCE row uses tight per-session XRCE pools — delivered through the image’s generated [env] and read by nros-rmw-xrce-cffi’s build.rs: NROS_XRCE_STREAM_HISTORY=4, NROS_XRCE_CUSTOM_TRANSPORT_MTU=512, NROS_XRCE_MAX_SUBSCRIBERS=1, NROS_XRCE_MAX_SERVICE_SERVERS=1, NROS_XRCE_MAX_SERVICE_CLIENTS=1, NROS_XRCE_SUBSCRIBER_RING_DEPTH=1, NROS_XRCE_BUFFER_SIZE=256. Vendor defaults grow xrce_session_state_t to ~390 KB (which wouldn’t fit a 24 KB heap); these knobs drop it to ~12 KB. Defaults are unchanged for hosted / non-tight-RAM consumers — the env vars are pure opt-in.

How to read this:

  • The size profile (opt-s) shrinks .text by ~10–26 % with .bss/.data unchanged (opt-level doesn’t touch static buffers — those are the env knobs above). -Oz is not used — on smoltcp examples it grows .bss +24 KB by defeating opt-3’s per-socket dead-buffer DCE (see the size-tuning notes above).
  • Switching ethernet → serial sheds ~50 KB text + ~42 KB .data (no smoltcp stack, no IP link C, tuned heap) — the structural lever.
  • FreeRTOS + lwIP cells .bss is dominated by lwIP’s heap + FreeRTOS task stacks (3 MB is the configured headroom, not nano-ros overhead).
  • The micro-ROS / XRCE row is a reference, not a nano-ros measurement — no bare-metal XRCE example ships yet (needs a custom-transport injection); the path to parity is XRCE + serial + static pools.

Size-minimal recipe

Smallest measured nano-ros configuration today (mps2-an385-baremetal serial talker, 116 KB text / 101 KB RAM):

# Cargo.toml
[profile.size]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
debug = false
strip = true

The gc-sections and link-script rustflags are not yours to write — they are the board’s, and they arrive in the generated build/<image>/nros-cargo.toml [target.<triple>] block:

# build/<image>/nros-cargo.toml — GENERATED, shown for orientation
[target.thumbv7m-none-eabi]
rustflags = [
    "-C", "link-arg=-Tlink.x",
    "-C", "link-arg=--gc-sections",   # 204.8 — strip unreferenced fns/data
]

The knobs that are yours are ordinary environment variables. The generated [env] never uses force, so a value you set in the calling environment wins:

NROS_LINK_IP=0 \
ZPICO_NO_SMOLTCP=1 \
NROS_HEAP_SIZE=131072 \
NROS_SMOLTCP_MAX_SOCKETS=1 \
NROS_SMOLTCP_MAX_UDP_SOCKETS=1 \
    nros build <image>

The profile is not one of them: name it on the image, as [image.<id>] profile = "size", and nros build passes it to whichever tool it drives.

NROS_LINK_IP=0 drops zenoh-pico’s TCP/UDP link C (204.7) and ZPICO_NO_SMOLTCP=1 skips the smoltcp glue on bare-metal. On the heap floor: the per-entry executor backing is a single ~75 KB allocation, so a nros::main! image needs ≥128 KB (the #176 board default) — the pre-271 24 KB “zenoh-pico working set” figure OOMs at boot. HEAP is .bss (no flash cost); shrink below the default only on a non-nros::main! direct-mode image with a measured smaller peak. A knob you always want for a board belongs in that board’s descriptor [knobs], not on every command line.

nros new --platform baremetal already scaffolds the [profile.size] shape (Phase 204.7/204.8).

The deeper RAM win waits on XRCE on bare-metal (the ~3 KB-class client + static pools, with discovery offloaded to the agent) — tracked separately; zenoh-pico’s SUBSCRIBER_BUFFERS + alloc-based session are what keep this row’s .bss ~76 KB.

Cargo features (which RMW/platform is linked)

Features select the linked RMW backend, platform, and ROS edition. The system.toml rmw ([image.<id>], else [system]) picks which linked backend is active — the two are different layers (link vs run). Matrix + mutual-exclusion rules: Platform Model.

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi",            # generic C-vtable runtime registry
    "platform-bare-metal", # or platform-{freertos,nuttx,threadx,zephyr,posix}
    "ros-humble",          # or ros-iron
    "std", "alloc",        # optional, target-dependent
] }
# Exactly one RMW backend crate; its registration runs before main:
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-bare-metal", "link-tcp", "ros-humble"] }
# …or nros-rmw-xrce-cffi / the cyclonedds CMake backend

Runtime environment (native host only)

On the native host build, ExecutorConfig::from_env() reads at process start (embedded targets bake their config at build time instead):

VariableDescriptionDefault
ROS_DOMAIN_IDROS 2 domain ID0
NROS_LOCATORRouter addresstcp/127.0.0.1:7447
NROS_SESSION_MODEclient / peerclient
ZENOH_TLS_ROOT_CA_CERTIFICATE*TLS CA cert (path / base64)(none)

By deployment scenario

ScenarioConfig sourceCargo featuresNotes
Desktop (POSIX)env (ExecutorConfig::from_env())rmw-cffi, platform-posix, std + zenoh deprun the router locally (ros2 run rmw_zenoh_cpp rmw_zenohd, ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false')
QEMU bare-metalsystem.toml [image.<id>] ip/mac/gateway/locatorrmw-cffi, platform-bare-metal, ros-humble + zenohTAP/slirp bridge
FreeRTOS hardware[image.<id>] + board features (RT)…, platform-freertos, …FREERTOS_DIR/LWIP_DIR
ESP32 WiFi[image.<id>] (ssid/password via build env)…, platform-bare-metal, …SSID/PASSWORD build env
Zephyr moduleKconfig overlay (prj-<rmw>.conf)(Kconfig → features)
Hand-written no_std (no codegen)standalone config.toml → Config::from_tomlper-boardnet config in a file, not in code
Minimal RAM (XRCE serial)config.toml [[transport]] kind="serial" or [image.<id>]… + xrce depXRCE_* buffer tuning

.env

cp .env.example .env   # uncomment + adjust; gitignored; auto-loaded by just + direnv