Skip to content

User guide

Plug the node into USB. Your computer gets a network interface and an IP via DHCP. Point any IRC client at 10.97.83.1:6667. That’s the whole quick start:

/server 10.97.83.1 6667
/nick alice
/join #pizza

The first connection with a new username mints your identity key on the device. From then on you exist on the network as that key — your nick is a label, your key is you.

No IRC client handy? telnet <node> 2323 drops you into the built-in ircii-style TUI with the same commands (/join, /msg, /win 2…), arrow-key editing, input history, PgUp/PgDn scrollback, and a /config modal for local identity/region/router/lane/theme defaults. On lrcd this is live today; you arrive pre-registered as tui<n>/nick to taste. When lrcd has --state-dir, /config saves those TUI preferences in tui.cfg next to the identity seed files and per-channel sequencer cursors that keep same-channel traffic monotone across daemon restarts. Channel ownership, topics, simple modes, channel keys, op grants, and bans are stored there too.

Firmware nodes can serve IRC over USB networking or WiFi, depending on the board profile. ESP32-S3 USB-net builds present an ECM network device at 10.97.83.1; ESP32 WiFi builds can boot an access point (lrc-xxxx, IRC at 192.168.4.1:6667) or join your LAN. The serial console remains the local diagnostic/configuration surface and is the fallback for serial-first board profiles.

Every firmware build declares one of three profiles (lrc::Profile, core/include/lrc/profile.h) that only selects compiled presentation transports and first-boot defaults — once you save config, your device’s own settings win over the profile’s defaults, and every profile still boots with RF transmit disabled until you run tx on. Find yours below; each points at the reference sections later in this guide rather than repeating them, so the shared material (the *lrc service, remote admin, OTA, DCC…) stays in one place.

A. field-hotspot — a pocket node for phones/laptops in the field

Section titled “A. field-hotspot — a pocket node for phones/laptops in the field”

The default profile: boots a WiFi access point (lrc-xxxx) and/or USB-ECM, serves IRC to whoever connects locally, and is typically a client — it registers to a nearby router rather than beaconing lanes itself.

  1. Power it on. Connect your phone/laptop to the lrc-xxxx WiFi (or plug in USB for the ECM interface).
  2. Point an IRC client at 192.168.4.1:6667 (WiFi AP) or 10.97.83.1:6667 (USB-ECM).
  3. If this is a fresh board, run the first-run wizard from the serial console first — region is mandatory before tx on, and the wizard is the guided path through it plus identity backup and join defaults.
  4. tx on yourself once you’ve confirmed an antenna is attached — see the serial console command list.

Up to 8 WiFi AP clients (the on-device DHCP server’s cap); see WiFi modes for ap/client/uplink details and Concurrent WiFi uplink if you also want it on your home network at the same time.

Section titled “B. uplink-router — a backbone node bridging RF to the internet/LAN”

A router: beacons a lane plan, sequences channels, and typically carries a WiFi-client or TCP-backbone link out to other routers rather than serving local WiFi clients itself.

  1. Power it on; it joins your configured LAN as a WiFi station (or wire up lrcd on a Linux/macOS box instead — same role, no radio hardware needed for a pure backbone hop).
  2. Run the first-run wizard — region is mandatory here too, and a router transmitting under an unspecified region is exactly the sharp edge the wizard exists to remove.
  3. Set up WiFi client mode or a TCP peer lane to reach other routers. The guided peering-pin flow in that section replaces hand- typing --peer/--peer-pin on both ends.
  4. Once peered, tx on and confirm with /msg *lrc admin status or the device serial console’s status.

See Remote node administration for the signed ADMIN surface this profile’s operators use to tune telemetry, mailbox, and checkpoint cadence remotely, and Remote fleet updates (OTA) for the fleet-wide update control plane a router operator manages.

C. serial-only — headless, console/TUI-first (no WiFi radio needed)

Section titled “C. serial-only — headless, console/TUI-first (no WiFi radio needed)”

A client profile for boards without WiFi, or deployments where a serial/USB cable is the only expected connection — a bench test rig, a tethered field kit, or a board variant with no WiFi hardware at all.

  1. Connect over pio device monitor (USB CDC) or any 115200 serial terminal.
  2. Run the first-run wizard: wizard at the lrc> prompt.
  3. Type tui to enter the same ircii-style client every other surface uses — The on-device TUI — or stay at the raw console and use say <#chan> <text> for chat without any client at all.
  4. tx on once region is set and an antenna is attached.

This profile has no local WiFi/USB-net IRC listener — the serial/telnet TUI (:2323 if networking is available another way, e.g. peer-linked to an lrcd) is the primary and fallback surface at once, not just the fallback the other two profiles treat it as.

wizard at the serial console (any profile) walks a fresh board through the four things it needs before it’s genuinely useful, in order:

  1. Region — mandatory; TX stays refused until this is set (lrc::region_gate_check(), the same gate the standalone region command uses). Type a region name (EU868, US915, …) or skip to leave it for later — nothing else in the wizard forces you through a step you’re not ready for.
  2. Identity — informational: your device’s identity already exists by the time the console is up (it’s generated/loaded at boot, independent of the wizard), so this step just explains the “your nick is a label, your key is you” model before moving on.
  3. Backup — shows you where to find your seed backup. The console itself doesn’t render the phonetic-hex/QR block (that lives in the *lrc IRC service); this step tells you to run tui then /msg *lrc key export to see it, and asks you to confirm you’ve saved it before continuing.
  4. Join defaults — an optional suggested nick and channel(s) to have in mind when you connect a client. Purely advisory: any client can still /nick and /join whatever it wants regardless of what you enter here.

Type skip at any step to bail out of the rest of the flow — a skipped or completed wizard never re-prompts on a later boot (though the console still prints a one-line, non-blocking hint at boot and when you open the TUI if the device is still unconfigured). Run wizard again any time to resume from wherever your real config says you left off: there’s no separate “wizard progress” to get out of sync with your actual settings.

The wizard never enables TX itself — every step above configures something; tx on stays your own explicit act on every surface, exactly as before the wizard existed.

Every identity’s UID (docs/IDENTITY.md §1) is a 16-hex-character fingerprint of its public key. Comparing 16 hex digits by eye is exactly the kind of thing people get wrong, so anywhere you can see someone’s UID, you can now also see it as a short list of plain-English words — the same phonetic-hex rendering identity backup uses (core/include/lrc/backup_encoding.h), just applied to the shorter UID instead of a full seed.

The ritual:

  1. /whois <nick> — alongside the usual uid=<16hex> line, chIRpChat now sends a second line: chIRpChat fingerprint: <word> <word> …. Sixteen nibbles, sixteen words.
  2. Read that word list to the other person over a channel you both trust is really them (voice call, in person, a previously-verified DM) — or have them read theirs to you.
  3. If the words match what you hear, you’ve confirmed the key behind the nick is the one you expect. If a nick’s fingerprint ever changes unexpectedly, that’s the same signal a changed SSH host key would be — don’t assume it’s just a device swap without asking.

This is TOFU-style verification (trust on first use, confirmed out of band), not a cryptographic proof by itself — the fingerprint comparison is what turns “I trust whoever answered on this nick” into “I trust this specific key,” which is what actually persists across nick changes, reconnects, and device swaps (key export/key import carry the same identity to a new device; the fingerprint carries over with it).

The same fingerprint rendering is used for guided federation peering’s pin exchange — comparing a peering pin’s fingerprint is the same ritual at the router-to-router level instead of the person-to-person level.

/nick, /join, /part, /msg, /notice, /topic, /names, /list, /who, /whois, /mode, /invite, /kick, /quit — all standard. Things worth knowing:

  • /whois bob shows retained radio truth: UID, router, reachability, attestation, known shared channels, and last_heard_unix when the router has a clock — plus a second line rendering that UID as a human-comparable phonetic-hex fingerprint (see Verifying who you’re talking to). Lane/preset fields need future retention before they can be shown honestly.
  • /list over LoRa is paced; results stream in and are cached. Be patient on slow lanes.
  • Channels are #name.rt1 style — the suffix is the router that hosts the channel. Federated routers host suffix-less shared channels (#pizza) that exist across the whole federation. On a solo lrcd started with --solo-channels, /join #pizza opens the local router’s #pizza.rtN channel; explicit .rtN joins stay exact. /msg *lrc namespace shows the active mode and join examples; /msg *lrc namespace #pizza also shows how that channel name resolves and any live local views for the same channel hash. /msg *lrc namespace views [#pizza] lists all local views, optionally filtered to one suffix-free channel hash. /msg *lrc namespace join #pizza joins the resolved view using the same checks as /join. /msg *lrc namespace merge #from #to previews a local move between exact views and reports the non-merged state that will stay separate. /msg *lrc namespace move #from #to moves the current session by joining the resolved target view before parting the source view. A solo .rtN channel stays tied to that router: if rtN is down, the channel is degraded until that router returns or users explicitly move to another view. chIRpChat does not auto-elect a new sequencer for #name.rtN; federated suffix-less channels are the mode that keeps other routers active during a split.
  • Joining a channel replays recent router-owned channel history. Clients that request IRCv3 batch see it grouped as chathistory; clients that request server-time get origin timestamps when the router clock was disciplined and the replaying node’s clock is inside the 10-minute display guard. More: /msg *lrc backfill #pizza 100.
  • ACK-backed DMs can be tracked: /msg *lrc track on adds queued, retry, delivered, and failure notices; /msg *lrc status shows your pending outbound DM queue.

chIRpChat speaks standard IRC (RFC 2812) plus a small, fixed IRCv3 capability set — echo-message, away-notify, labeled-response, batch, server-time (no SASL, no multi-prefix, no chghost yet). A client requesting a capability outside that set gets a standard CAP * NAK for the whole request and is expected to continue registering without it — every well-behaved client does.

Client Status Notes
irssi Tested Scripted at the IRCv3 capability-negotiation spec’s baseline level (CAP LS, register, gracefully continues past a NAK) — irssi’s own docs commit to that level of compliance. Not verified against a byte-level read of irssi’s own irc-core source.
WeeChat Tested Scripted against CAP LS 302 plus the intersection of WeeChat’s documented default IRCv3.2 capability set and what chIRpChat offers (server-time, batch, echo-message).
Halloy Tested Scripted directly from Halloy’s own README, which documents its full ~40-capability IRCv3 support list explicitly — the most source-confident entry in this table.
Textual Untested Its registration/CAP sequence could not be confirmed against source or documentation within this pass’s research budget. Per this project’s honesty rule, an unconfirmed client is listed untested, not silently assumed to work.
Any other RFC 2812 client (HexChat, mIRC, The Lounge, …) Expected to work, unverified chIRpChat’s core protocol surface is standard IRC; any client that degrades gracefully past an unrecognized CAP reply should register and chat normally. Not in the scripted suite below.

Tested means tests/smoke_irc_compat.py scripts that client’s documented registration sequence against a real lrcd and asserts registration, CAP negotiation, channel join, and a chat round-trip all succeed — run it yourself with python3 tests/smoke_irc_compat.py build/lrcd. It runs beside smoke_lrcd.py/smoke_sse.py in the same verify loop. See that file’s docstrings for exactly which sequence was scripted for each client and the citation behind it — a client is only listed as tested here if its sequence traces to that client’s own documentation or source, not a guess at “what clients generally do.”

The first person to create a channel is its founder — bound to their key, not their nick, so ownership survives reboots and device swaps. Signed identity records for loaded founders and ops are pinned in the local identity cache, so routine peer lookups do not evict the records needed to honor later key rotations.

/mode #pizza +t only ops set topic (standard)
/mode #pizza +m only ops may speak (moderated)
/mode #pizza +i only invited users or ops may join
/mode #pizza +k sesame require JOIN key "sesame" for non-ops
/join #pizza sesame join a keyed channel
/invite bob #pizza invite bob into an invite-only channel
/mode #pizza +o bob op by nick (router records bob's key)
/mode #pizza +A only router-attested users may speak
/mode #pizza +S remote speakers need a verified checkpoint first
/msg *lrc chan transfer #pizza bob transfer founding (signed op)

/msg *lrc help lists everything. Highlights:

key export print local lrc1 seed keyfile
key lookup <nick|uid16> fetch and cache a signed identity record
key import <lrc1-keyfile> adopt an identity after reconnect
key rotate rotate chat tag key (signed by identity key)
key rotations [nick|uid16] list bounded local ROTATE archive rows
key forget <nick|uid16> remove cached identity/trust state locally
key revoke <nick|uid16> denylist a UID locally until key unrevoke
key revoked list locally revoked UIDs
require-sig bob demand full signatures on DMs from bob
sign-dm bob sign outgoing DMs to bob
checkpoint [nick|uid16] show recent checkpoint ok/mismatch/unverified archive
routers [rtN] list latest heard router TELEM rollups
routers history [rtN] [N] list retained router TELEM rollups newest-first
routers aggregate [rtN] [N] list hourly router TELEM aggregates newest-first
admin status show local admin policy and safe-key values
admin rtN stats radio signed fetch of current remote TELEM
admin rtN reboot signed callback-gated remote reboot request
namespace [#channel] show solo/federated scope behavior and channel views
namespace join #channel join the view selected by the active namespace policy
namespace merge #from #to preview a local view move; state remains separate
namespace move #from #to move this session between namespace views
namespace views [#channel] list local namespace views, optionally filtered
register rt3 manually pick a router (auto by default)
track on|off / status ACK-backed DM delivery reporting
backfill #chan N pull older history
stats [radio|power|store…] node telemetry
crash show and clear stored crash reports

key export and key import use the local node seed store only; they do not send identity seeds over radio. The current shipped keyfile form is lrc1- followed by eight 8-hex-digit groups. After key import, disconnect all current sessions for that IRC username and reconnect to register with the imported identity. The serial/Telnet TUI keeps the exact keyfile line and also renders phonetic hex word groups plus an ASCII QR block for local transfer.

When lrcd runs with --state-dir, the bounded local checkpoint archive survives daemon restart. unverified means the checkpoint signature was valid for a cached identity, but this node no longer had the canonical message window; the row keeps the signed checkpoint hash, not message contents.

A note on refusal messages here: the *lrc service follows the same “names its reason and its fix” convention as the console (lrc>) and daemon CLI (e.g. tx refused: no region set — 'region <name>' first): a refusal tells you what didn’t work and what to do about it (key lookup bob unknown: not a cached nick, 16-hex uid, or local user — /whois shows a user's uid16). Two deliberate exceptions: bare usage: ... replies (the correct syntax is the fix, and your own just-echoed input is the reason), and the compact signed-ADMIN result strings documented in Remote node administration (set denied: unsupported key, reboot denied: quorum pending 1/2, …) — those travel inside 255-byte radio frames, where brevity is a wire budget, not a style choice.

Remote admin signs an ADMIN request with your identity key and receives a signed router reply. Built commands:

/msg *lrc admin status
/msg *lrc admin rt1 stats radio
/msg *lrc admin rt1 set telem.interval_ms 900000
/msg *lrc admin rt1 set rtrsync.interval_ms 15000
/msg *lrc admin rt1 set rtrsync.delta_ttl_ms 60000
/msg *lrc admin rt1 set rtrsync.tombstone_ttl_ms 600000
/msg *lrc admin rt1 set history.time_skew_guard_s 600
/msg *lrc admin rt1 set directory.limit 1024
/msg *lrc admin rt1 set mailbox.ttl_ms 604800000
/msg *lrc admin rt1 set mailbox.limit_per_uid 32
/msg *lrc admin rt1 set relay.store_forward_limit 8
/msg *lrc admin rt1 set relay.store_forward_ttl_ms 30000
/msg *lrc admin rt1 set admin.quorum 2
/msg *lrc admin rt1 set admin.quorum_window_ms 600000
/msg *lrc admin rt1 set checkpoint.message_interval 32
/msg *lrc admin rt1 set checkpoint.time_interval_ms 900000
/msg *lrc admin rt1 set checkpoint.verify_window 64
/msg *lrc admin rt1 set checkpoint.audit_limit 32
/msg *lrc admin rt1 set tx.enabled off
/msg *lrc admin rt1 set lane.plan 0:P0:0:0:0:0,1:P3:5:10:3:2
/msg *lrc admin rt1 set lane.plan clear
/msg *lrc admin rt1 set identity.revoke 0123456789abcdef
/msg *lrc admin rt1 set identity.unrevoke 0123456789abcdef
/msg *lrc admin rt1 set ota.release_pubkey <64-hex-ed25519-pubkey>
/msg *lrc admin rt1 set ota.nightly_pubkey <64-hex-ed25519-pubkey>
/msg *lrc admin rt1 set ota.wave 0102030405060708,0a0b0c0d0e0f1011
/msg *lrc admin rt1 set ota.wave clear
/msg *lrc admin rt1 set ota.health_window_ms 300000
/msg *lrc admin rt1 set ota.health_max_boots 3
/msg *lrc admin rt1 set ota.promote confirm
/msg *lrc admin rt1 reboot

admin status is local and read-only: it reports the node’s current safe-key values, operator count, quorum settings, persistence mode, pending quorum count, and supported mutating keys. It does not send an ADMIN packet.

Mutating admin is deny-by-default; a router must locally allowlist your identity UID before set or reboot succeeds. Built keys are telem.interval_ms, rtrsync.interval_ms, rtrsync.delta_ttl_ms, rtrsync.tombstone_ttl_ms, history.time_skew_guard_s, directory.limit, mailbox.ttl_ms, mailbox.limit_per_uid, relay.store_forward_limit, relay.store_forward_ttl_ms, admin.quorum, admin.quorum_window_ms, checkpoint.message_interval, checkpoint.time_interval_ms, checkpoint.verify_window, and checkpoint.audit_limit, plus tx.enabled off for targets with a TX-off platform callback, lane.plan for targets with a configured lane-plan store, identity.revoke and identity.unrevoke with a 16-hex-digit UID value, and the OTA rollout family (ota.release_pubkey, ota.nightly_pubkey, ota.wave, ota.health_window_ms, ota.health_max_boots, and ota.promote confirm — see OTA.md §5; ota.promote needs a target with a configured rollout listener, like tx.enabled needs its platform callback). For optional cadence and bound keys, a value of 0 disables the matching telemetry trigger, periodic RTRSYNC emission, RTRSYNC compact-delta retention, history timestamp skew guard, directory cap, offline-mailbox storage, per-UID mailbox cap, relay store-and-forward buffering, checkpoint timer/count trigger, canonical checkpoint verification window, or checkpoint archive retention. rtrsync.tombstone_ttl_ms (bounded to 24 h) sets how long a router-liveness tombstone is retained before it can be pruned; it applies to tombstones created after the change. relay.store_forward_limit is bounded to 256 frames and trims the local relay buffer immediately; relay.store_forward_ttl_ms is bounded to 600000 ms and applies to future queued relay frames. directory.limit takes effect immediately by pruning the remote directory to the new cap; mailbox TTL applies to future offline stores, and mailbox.limit_per_uid trims loaded mailboxes and bounds subsequent stores. Identity revoke keys update the target router’s local generationed UID denylist state, refuse UIDs owned by that router itself, and converge over trusted-peer RTRSYNC; identity.unrevoke writes a clear tombstone so stale revoke rows do not resurrect after restart or peer heal. When lrcd runs with --state-dir, successful changes to the numeric safe keys persist across daemon restart in admin.cfg; ESP32 firmware persists the same numeric safe-key slice in lrckv when the partition is present. lane.plan writes the existing compact laneplan store, not admin.cfg: use anchor for the default single-lane plan, clear to remove the stored plan, or comma-separated lane rows of lane_id:preset:freq_slot:period_s:offset_s:window_s with preset as P0..P7 or 0..7. The first row must be the anchor 0:P0:0:0:0:0. Firmware stores non-anchor rows from signed Admin, but it advertises and retunes to them only after a local operator enables lane retune on from that device’s console; remote lane.plan is schedule intent, not RF authorization. Identity revoke state persists as idrev-<uid>.rev; if a durable write for a numeric safe key or lane plan fails, the signed result is denied as persist failed and the runtime value is left unchanged. tx.enabled off is a one-way safety command: it can force a target into receive-only mode and drop queued RF frames, but remote TX enablement is not built. Use the device’s local tx on console command only after checking the antenna and site rules.

reboot is not a config write. It invokes the target router’s local reboot callback only after allowlist and quorum pass. Targets without a reboot callback return reboot denied: unsupported; callbacks that cannot schedule a reboot return reboot denied: reboot failed; accepted callbacks return reboot ok: reboot queued. On lrcd, queued means the daemon exits cleanly after flushing the signed result so a supervisor or test harness can restart it; firmware queues the same deferred software reset used by the local console reboot command. On lrcd, add allowlisted identities with --admin-operator UID8HEX; require more than one distinct operator with --admin-quorum N (default 1), or change it remotely with admin.quorum after enough current operators approve the change. On firmware, bootstrap the same local allowlist from the device console with admin op add <uid16>, inspect it with admin op list, and clear it with admin op clear; the list is bounded and persisted with the device config. admin.quorum must be nonzero and cannot exceed the router’s distinct allowlisted operator count; admin.quorum_window_ms must also be nonzero and is bounded to 24 h. On restart, a stored quorum value above the current distinct operator count is ignored and the configured default remains active. Future operator-authorized commands use the same signed ADMIN packet family:

/msg *lrc admin rt1 set tx.power 17
/msg *lrc admin rt1 set rf.freq_window "868.0-868.6" # band-pass limits

Two-person rule: --admin-quorum 2 makes the first valid mutating request return set pending: quorum pending 1/2 or reboot pending: quorum pending 1/2. A second allowlisted identity must repeat the same key/value or reboot command within the quorum window before the router applies it. Changing admin.quorum or admin.quorum_window_ms uses the old active quorum for authorization; after a successful change, pending quorum records are cleared.

If a DM reaches your router while your IRC session is offline, the node keeps it in a local mailbox for up to 7 days and delivers it when the same identity reconnects. If the router has already learned a reachable newer attachment for that same identity, it relays toward the active attachment instead of creating a stale standby mailbox copy. On lrcd, --state-dir also preserves mailbox records across daemon restart. XIAO ESP32 firmware images built with the chIRpChat 8 MB partition table mount lrclog and defer mailbox snapshot plus channel history writes to the main service loop so offline mailbox records and bounded channel backfill can survive reboot on hardware. Firmware images without a labelled lrclog partition keep those warm stores hot-only. ESP32 images with the chIRpChat partition table also mount lrckv for local identity seeds, router next-sequence and client last-sequence cursors, verified cached identity records, bounded channel admin records, plus joined-channel restore snapshots and safe Admin config values, router-local registration rows, plus bounded TCP peer policy storage with a legacy first-target mirror, falling back to NVS on older layouts for seed/cursor/join data while registrations and peer policy remain volatile. The same ESP32 lrckv store retains compact abnormal reset reports for /msg *lrc crash or lrc> crash until an operator reads and clears them. Standby routers now exchange dirty mailbox snapshots over target-aware authenticated peer links with MAILBOXSYNC, importing requested row subsets into the local mailbox set and keeping delivered tombstones from resurrecting stale live rows. Dirty routers first exchange compact row inventories, so message text moves only when an authenticated peer requests rows it is missing. Large inventories, requests, or row subsets use bounded LPP fragmentation.

When --state-dir is set, lrcd also remembers the channels your identity explicitly joined. After a client or daemon restart, the next IRC registration rejoins those channels and uses the saved per-router last_seq cursor to ask for missed channel messages through normal CHANSYNC. Explicit PART or KICK removes a channel from that saved list; a dropped TCP connection does not. The latest retained remote router telemetry shown by /msg *lrc routers is also restored after daemon restart, along with the bounded /msg *lrc routers history view.

Defaults work with zero configuration: auto region from build, auto router pick, auto lanes. The built remote-admin surface covers telemetry fetch and allowlisted, optionally quorum-gated telemetry cadence, directory/mailbox bounds, checkpoint cadence, verification-window, archive-retention keys, and callback-gated reboot; future operator-authorized config commands will use the same signed ADMIN packet family. Everything the radio decides is observable (TELEMETRY.md); RF/TX-affecting tuning still needs the broader mutating admin policy.

Firmware builds also declare a profile. The profile only selects compiled presentation transports and first-boot defaults; once config is saved, your device settings win. Current profiles are field-hotspot (client, local USB-ECM and/or WiFi AP), uplink-router (router, WiFi client/TCP lanes), and serial-only (client, serial console/TUI). Every profile still boots with RF transmit disabled until you run tx on.

wifi client HomeNet hunter2
wifi ap FieldNode fieldpass

Those are current firmware serial-console commands. In client mode the node joins your LAN: connect your IRC client to its LAN address, and the node can carry TCP lanes to other chIRpChat nodes over the internet (authenticated, plaintext when peers are pinned — see ROUTING.md). ap mode serves a SoftAP for phones in the field (up to 8 clients — the on-device DHCP server caps there). USB networking keeps working in both. Future mutating ADMIN self set wifi.* commands will expose the same knobs from IRC once the signed admin write path lands.

Pinned lrcd peers authenticate the remote router key and MAC every TCP frame. Two ways to set that up — a guided one (recommended: one value to copy instead of two, plus a confirmation step) and the original hand-typed one (still fully supported):

Guided (one pin, both values, plus confirmation). Operator A generates a short-lived pin encoding their endpoint and key fingerprint:

./build/lrcd --name node1.lrc --rtr 1 --state-dir node1 \
--print-peering-pin 203.0.113.5:6810
# lrcpeer1-203.0.113.5-1a9a-<uid16hex>-<expiry8hex>-<checksum4hex>
# fingerprint: <word> <word> <word> …

--peering-pin-ttl-s N (default 900, 15 minutes) controls how long the pin stays redeemable — after that, generate a fresh one; a pin pasted into the wrong channel or left in shell history stops working on its own. Send the whole token to operator B (chat, email, however you’d normally share a one-time value); operator B pastes it into their own run:

./build/lrcd --name node2.lrc --rtr 2 --state-dir node2 \
--peering-pin-token lrcpeer1-203.0.113.5-1a9a-<uid16hex>-<expiry8hex>-<checksum4hex>
# peering pin redeemed: peer 203.0.113.5:6810, fingerprint: <word> <word> <word> …
# confirm this fingerprint with the other operator before trusting this connection.

That resolves to the exact same --peer/--peer-pin this daemon has always accepted — the guided flow only replaces the hand-typing and adds the confirmation step, it does not add network discovery (the daemon still can’t learn its own externally-reachable address itself; operator A supplies the host they know is reachable, same as they always had to). Both operators must actually compare the printed fingerprint — see Verifying who you’re talking to for why that step matters and isn’t optional in spirit even though nothing enforces it mechanically. A malformed, expired, or tampered token refuses with a specific reason (expired — ask the other operator to generate a new one, checksum mismatch — check for a copy/paste error, malformed — not a lrcpeer1- token) rather than a bare “invalid pin.”

Hand-typed (the original path). Use --print-node-id once per state directory, then configure reciprocal pins directly:

./build/lrcd --state-dir node1 --print-node-id
./build/lrcd --state-dir node2 --print-node-id
./build/lrcd --name node1.lrc --rtr 1 --state-dir node1 --peer-pin <node2-id>
./build/lrcd --name node2.lrc --rtr 2 --state-dir node2 \
--peer 127.0.0.1:6810 --peer-pin <node1-id>

In larger peer meshes, lrcd bounds periodic RTRSYNC anti-entropy fanout without limiting normal chat/control traffic. The default sends each RTRSYNC pass to at most three ready peer routers, prioritizing never-synced or stale peers first. Tune it with --gossip-fanout N, --gossip-stale-ms N, --gossip-seed N, and --rtrsync-interval-ms N.

Unpinned daemon links keep the old unauthenticated TCP framing for firmware TCP lanes and lab experiments; do not use them as a trusted federation boundary. On ESP32 firmware, peer <host> <port> replaces the first outbound lane target and stores it in the bounded lrckv peer policy when that partition is present; any stored pins are preserved. peer shows all active stored targets with connected/ready counts, peer off clears the policy, and firmware redials every saved target after reboot when USB-ECM, WiFi client, AP, or uplink networking is available. If a stored firmware policy contains pins, the outbound lane must complete the daemon-compatible HELLO/PROOF exchange and MACed frame setup before peer reports it ready.

ap and the USB-ECM link serve clients locally; a WiFi uplink lets the node also join an upstream WiFi as a station at the same time — so it sits on your home network while still serving phones over its own SoftAP (APSTA), or while serving a laptop over the USB cable. Set it independently of wifi.mode:

uplink HomeNet hunter2

Use uplink off to clear it. The node then dials peer lanes and is reachable over the upstream LAN.

Two limits are worth knowing: the single radio means the SoftAP follows the uplink’s channel once connected, and there is no NAT — local USB/AP clients reach the node, not the internet behind the uplink. (Internet sharing would need a custom firmware build with IP forwarding compiled in.)

Remote /dcc send bob photo.jpg CTCP offers are intercepted and queued as reliable DCCCTL OFFER controls. Recipients see a *lrc notice and can run /msg *lrc dcc accept <nick> to send the matching DCCCTL ACCEPT control back to the offerer. The core has the sidecar hook for tagged DCCDATA chunks, local receive reassembly, sender-side RESUME replay enumeration, and reliable RESUME bitmap controls. A sidecar that stages bytes can publish a whole-file content hash, the receiver-side reassembler verifies it on completion, and hash-failed buffers are withheld from the local sidecar with a CLOSE HashFail returned to the sender. Accepted OFFER/ACCEPT sessions now expose the derived stream id needed to start the sender-side outbound pump and receiver-side reassembler. That pump can drive initial chunks plus RESUME retransmits through the gateway’s DCCDATA sender seam. Reliable DCCCTL CLOSE controls now give sidecars a tested stream teardown signal for complete, cancel, error, and hash failure outcomes. Reliable DCCCTL LANE_GRANT controls now deliver a tested stream/preset/frequency-slot/token/TTL/airtime-budget grant to sidecars for the future burst scheduler. The sidecar tracker applies grant TTL, renewal, expiry, and matching CLOSE teardown, then the host-tested RF intent adapter converts only ready matching leases into concrete radio parameters and chunk budgets. lrcd issues a receiver-side sidecar grant after ACCEPT, renews it while the receive buffer is incomplete, and uses the active grant on the sender to pace the file-backed pump by preset airtime budget. When a node has a configured local DCC listener address, inbound offers are also advertised to stock IRC clients as CTCP DCC SEND with that local ip32/port instead of the sender’s LAN coordinates. On lrcd, configure that sidecar endpoint with --dcc-listener-ip 127.0.0.1 --dcc-listener-port 6500; dotted IPv4 and decimal IRC ip32 are accepted for the address. The daemon then connects to the sender’s original DCC TCP server after ACCEPT, stages up to 8 MiB in memory, sends DCCDATA chunks, reassembles the receiver side, and writes the expected stock-client DCC TCP byte stream locally only after any advertised content hash verifies. Configured firmware lane retune can use ordinary non-anchor lane windows; DCC-specific board RF driver wiring remains future work.

The device serial console (firmware nodes)

Section titled “The device serial console (firmware nodes)”

pio device monitor on builds with USB CDC, or any 115200 terminal on the board’s Serial UART:

help command list
id identity, profile, role, tx/retune state
status one-screen transport/status dashboard
menu status plus the most useful local commands
stats telemetry counters, heap/PSRAM
usb USB-network status and frame counters
tui open the ircII-style full-screen TUI
fwupdate enter firmware update mode when supported
role client|router <id>|relay persisted; reboot to apply
tx on | tx off TRANSMIT MASTER SWITCH — ships off;
never enable without an antenna attached
lane retune [on|off] local opt-in for stored non-anchor lane plans
admin op list show locally allowlisted Admin operator UIDs
admin op add <uid16> add a signed-Admin operator on this device
admin op clear clear the device Admin allowlist
wifi ap [ssid] [psk] serve IRC at 192.168.4.1:6667 (≤8 clients)
wifi client <ssid> [psk] join your LAN, serve IRC there
uplink [off|<ssid> [psk]] concurrent WiFi STA uplink (APSTA / + USB)
peer [off|<host> <port>] persisted TCP lane target to an lrcd router
say <#chan> <text> chat without any client at all
crash print retained boot/crash reports, if any
reboot restart the node

say announces your membership to the network before its first message in a channel, so remote users see a real nick — the one you picked in the first-run wizard’s join-defaults step if you set one, console otherwise — instead of a UID-derived placeholder. On a client-role board the message is sequenced by the router you’re registered to (it goes out unstamped and comes back stamped), exactly like any IRC client’s traffic.

Typing tui upgrades the current console session — the serial port or the :2323 network console — into the same ircII-style full-screen client the daemon serves (/join, /msg, /win, PgUp/PgDn scrollback, /config). It is the same core engine, just a local IRC client of the node’s own gateway, so it can never drift from what remote clients see.

It is allocated only on demand and freed on /quit or disconnect, so it costs no RAM when unused, and a free-heap guard refuses to open it if the node is low on memory. Scrollback is sized automatically to the node’s pressure — compact while a WiFi AP is up (the AP itself costs ~50 KB of heap), roomier otherwise. While a serial TUI is open the node’s async [lrc] … logs are routed into its status window instead of scribbling over the screen. On a raw serial terminal, turn local echo off (the TUI draws its own input line); the :2323 console negotiates that over telnet automatically.

status is intentionally read-only and uses state the node already keeps: radio frame counters, TX queue depth, IRC session/channel counts, local identity count, router registrations, retained remote attachments, pending ACKs, remote TELEM rollups, USB/WiFi/peer link status, memory on ESP32, and battery ADC data only on variants that already expose a battery sense macro. It does not start a new sampler or retain per-message display history. On variants with LRC_PIN_BUTTON, a short user-button press prints the same status to Serial. Variants with a non-strap safe update button can also use a two-second hold for firmware update mode. On Heltec V3/V4, the BOOT button is GPIO0 and may be tied to USB auto-reset hardware, so use fwupdate from the console or the board’s normal BOOT/reset procedure instead of firmware long-hold detection.

Firmware update entry paths:

Board/build Preferred path
Heltec V4 (heltec_v4) After a CDC-capable chIRpChat image is running, pio run -e heltec_v4 -t upload --upload-port /dev/cu.usbmodem... should use the configured 1200-bps touch to restart into the ROM uploader automatically. First flash, broken CDC images, or host USB flakiness may still need manual BOOT/reset or direct esptool at a lower baud.
Heltec V3 (heltec_v3) PlatformIO/esptool normally use the CP2102 DTR/RTS reset sequence automatically. fwupdate is also available from the console, but GPIO0 long-hold is deliberately disabled because auto-reset hardware can pull BOOT low.
XIAO Wio (xiao_wio_sx1262) fwupdate is available, and holding the safe user button for about two seconds also requests firmware update mode.
USB-network build (xiao_wio_usbnet) The serial console is replaced by USB ECM plus network console. If enumeration is broken, hold BOOT while plugging in to reach the ROM bootloader.

Heltec V3/V4 builds present the same state on the built-in 128x64 SSD1306 OLED as a five-page status carousel rather than one cramped dump. Each page has an inverted title bar with a n/5 indicator and a TAP PRG > NEXT PAGE footer:

Page Shows
1 Home role/preset, the TX state and the IRC connect address both in double-height “hero” text (RX ONLY/TX ON, then the IP or NO LINK), plus uptime
2 Radio preset/profile, RX/TX counts, TX queue, dedup/fail counters, last-packet RSSI and SNR
3 Channels IRC users, registered sessions, channel views, memberships, local identities, reachable/retained remote attachments
4 Network WiFi mode, USB-net state, uplink SSID, peer links, the address to connect to (USB-net or active WiFi IP), and the IRC (6667) and console/TUI (2323) ports
5 System firmware/board, free heap and PSRAM, uptime, and a TX/retune safety recap

A short tap on the user button advances the carousel one page and wraps around; it also still mirrors full status to Serial. The screen is a read-only live status surface — it performs no actions and retains no message log. RSSI/SNR are captured from the last decoded packet and read -- until the first receive.

Firmware ships as signed releases (see RELEASING.md) — each GitHub Release carries a .bin per board, a merged-flash .bin for ESP32 boards, SHA-256SUMS, and a signed .lrcmanifest per artifact. Three ways to get an update onto a device, in the order most people should reach for them:

1. Web flasher (primary path, once WS-SITE ships)

Section titled “1. Web flasher (primary path, once WS-SITE ships)”

The project site’s web flasher (Chrome or Edge, WebSerial — Firefox and Safari do not support WebSerial and cannot be used for this) will let you pick your board and update channel from a page and flash over USB with no local tools installed. This is the intended primary path for most users and is tracked as WS-SITE (Wave 3); this section documents the shape of that flow now so the CLI fallback below has something to be a fallback to:

  1. Plug the board in over USB.
  2. Open the flasher page, click Connect, and pick the matching serial port from the browser’s device picker.
  3. Choose stable or beta channel; the page fetches the matching release’s merged-flash image and manifest and shows the signed fw_version/ board/profile before writing anything.
  4. Click Flash. The browser writes the merged image directly — no manual offsets, no separate bootloader/partition-table files.

If a board needs manual bootloader entry first (see the BOOT-button note below), the flasher page will say so before you click Flash, not after it fails partway through.

Every release attaches per-board .bins and (for ESP32 boards) a merged-flash image so a full re-flash is one command:

Terminal window
# Merged image — the whole flash in one file, offset 0x0. Preferred: no
# partition-table offsets to get wrong.
esptool.py --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 \
xiao_wio_sx1262-merged.bin
# Equivalent from a source checkout with PlatformIO instead of a release
# download (builds locally rather than flashing a signed release artifact):
pio run -d firmware -e xiao_wio_sx1262 -t upload

If you only have the unmerged <env>-firmware.bin (no -merged.bin was published for your board — nRF52/rak4631 ships this way, see RELEASING.md), write it to the app partition offset from firmware/partitions_lrc_8mb.csv instead of 0x0:

Terminal window
esptool.py --chip esp32s3 --port /dev/ttyACM0 write_flash 0x10000 \
xiao_wio_sx1262-firmware.bin

Verify what you downloaded before flashing it — every release publishes SHA-256SUMS and a signed manifest per artifact:

Terminal window
sha256sum -c SHA-256SUMS --ignore-missing
lrc-manifest verify xiao_wio_sx1262.lrcmanifest <release-pubkey-hex> \
--image xiao_wio_sx1262-firmware.bin

lrc-manifest is the same tool the release workflow uses to build and sign these manifests (tools/lrc_manifest/ in the source tree) — see RELEASING.md for the full manifest format and how to build the CLI yourself if you’d rather not trust a prebuilt copy.

Boards with a working console can be told to enter firmware-update mode without touching a button at all — see fwupdate in the serial console command list above and the per-board table there for which boards support the two-second button-hold alternative.

The ESP32-S3 native-USB BOOT-button quirk (read this before your first flash)

Section titled “The ESP32-S3 native-USB BOOT-button quirk (read this before your first flash)”

The xiao_wio_sx1262, xiao_wio_uplink_router, and xiao_wio_usbnet builds’ console/USB run on the ESP32-S3’s native USB-Serial-JTAG peripheral rather than a CP2102/CH340 USB-serial bridge chip (Heltec V3 has one of those; Heltec V4 is also native-USB but ships the CDC + 1200-bps-touch workaround described in the table above, so its pio run -t upload self- enters download mode — the XIAO builds do not have that workaround yet). Native-USB-JTAG boards without it behave differently from bridge-chip boards in one way that surprises almost everyone the first time:

  • Flashing itself works fine and reports success. esptool/pio run -t upload complete cleanly.
  • The board does not automatically cold-boot the new app afterward. esptool’s usual reset sequence (toggling RTS/DTR) does not reliably start the freshly-flashed application on this USB peripheral — the board can sit in the ROM downloader (waiting for download) instead of running your firmware, and it can look like the flash “didn’t take” even though it did.
  • If the board is powered by anything other than the flashing cable (a debug probe, a battery, a second USB port), disconnecting only the flashing cable is not enough — the board stays in whatever mode it was last put in. You need a full power cycle: disconnect every power source, wait a moment, then reconnect.

The fix, in order:

  1. First flash onto a board that isn’t already running a compatible image: hold the BOOT button, plug in (or tap reset while holding BOOT), then release BOOT once the OS enumerates the port. This forces the ROM downloader regardless of what the previous firmware was doing.
  2. Flash as usual (web flasher, esptool, or pio run -t upload).
  3. Fully power-cycle the board — unplug everything powering it, not just the data cable — before expecting the new app to run. A bare USB-C unplug/replug is usually enough if that cable is the only power source; if you’re using a debug probe or any other power feed alongside USB, disconnect that too.
  4. Every flash after the board is already running a chIRpChat image with a working console can instead use fwupdate from the console (see above), which requests download mode in software — no BOOT button, no power cycle, because the running app asked for the reset itself instead of esptool guessing at it externally.

This is a property of the chip’s native-USB-JTAG peripheral, not a bug in this firmware or in esptool — boards on a USB-serial bridge chip (CP2102, CH340) don’t have this problem because that bridge chip drives a real hardware reset line the same way every time.

Status: everything up to the flash write is built and host-tested — manifest verification, staging over the real DCC transfer path (with loss/resume recovery), the live ota.* signed-Admin keys, and the health-gate decision logic. The actual on-device flash write is not built yet (a bricked router is a dead cell — that step needs human sign-off; see OTA.md §1/§8). Until then, use the update paths above (web flasher, esptool/pio, fwupdate) to actually install a new image; the walkthrough below is the remote rollout control plane as it exists today. The ota.* keys are ordinary signed-Admin safe keys — deny-by-default allowlist, quorum-gateable, sent from any IRC client exactly like the Remote node administration commands above.

1. Provision the release trust root. The release public key is operator-provisioned config, not baked into lrcd — a key rotation is a config update, not a re-flash:

/msg *lrc admin rt1 set ota.release_pubkey <hex64-from-RELEASING.md-secrets>
/msg *lrc admin rt1 set ota.nightly_pubkey <hex64> # optional: nightly-canary channel

2. Start a rollout for a release. The daemon-side coordinator (OtaFleetCoordinator) verifies the manifest against the provisioned key(s), rejecting board/profile/version mismatches before anything is offered; the transfer itself rides the normal DCC machinery (a reserved otastage stream name a receiving node only accepts after its own manifest/staging checks pass — see OTA.md §3). A dedicated lrcctl ota offer convenience subcommand is small daemon glue still to land; the underlying Node transport and admin hooks are live.

3. Configure the canary wave. Pick a small set of nodes to update first:

/msg *lrc admin rt1 set ota.wave 0102030405060708,0a0b0c0d0e0f1011

An unpromoted release with an empty or non-matching wave offers to nobody — this is deliberate (see OTA.md §5): forgetting to configure a wave never silently becomes a fleet-wide push.

4. Watch the canary wave’s health. Each canary node stages the image with a resume checkpoint that survives its own reboot, verifies the whole-image SHA-256 before it will arm, and — once armed and (eventually) applied — must clear a health gate within a window (default 5 minutes: mount its partitions, reach registered/serve a WELCOME, and show no crash report) before it commits. A node that fails the gate rolls back automatically; a bad push costs that node one reboot cycle, not a bricked device. Tune the gate with:

/msg *lrc admin rt1 set ota.health_window_ms 300000
/msg *lrc admin rt1 set ota.health_max_boots 3

5. Promote to the fleet. Once the canary wave looks healthy, a second, explicit signed action widens eligibility to every node:

/msg *lrc admin rt1 set ota.promote confirm

This mirrors admin.quorum: if the target router has quorum configured above 1, promotion needs that many distinct allowlisted operators to agree within the quorum window, the same as any other mutating Admin SET. (ota.promote is an action, not stored state — a target without a configured rollout listener denies it as unsupported rather than pretending it took effect.)

6. Abort at any point. Any in-flight stage can be torn down (discards staged bytes, clears the persisted resume checkpoint; the receiving node cancels the stream to the sender with the standard DCC CLOSE) — a first-class action, not a special case.

Every step above is observable without extra plumbing: OTA counters (ota.manifest_verified, ota.staging_started, ota.chunks_staged, ota.hash_fail, ota.armed, ota.health_pass, ota.rolled_back, and more — see TELEMETRY.md) ride the same /msg *lrc stats, /metrics, and future chirpscope rollout view every other counter in this codebase does.

Each TCP connection with a distinct IRC username gets its own identity key. A household XIAO serves three laptops as three fully distinct network identities simultaneously. (/msg *lrc whoami shows which identity your session is bound to.)