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 #pizzaThe 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.
Pick your profile
Section titled “Pick your profile”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.
- Power it on. Connect your phone/laptop to the
lrc-xxxxWiFi (or plug in USB for the ECM interface). - Point an IRC client at
192.168.4.1:6667(WiFi AP) or10.97.83.1:6667(USB-ECM). - 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. tx onyourself 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.
B. uplink-router — a backbone node bridging RF to the internet/LAN
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.
- Power it on; it joins your configured LAN as a WiFi station (or wire up
lrcdon a Linux/macOS box instead — same role, no radio hardware needed for a pure backbone hop). - 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.
- 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-pinon both ends. - Once peered,
tx onand confirm with/msg *lrc admin statusor the device serial console’sstatus.
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.
- Connect over
pio device monitor(USB CDC) or any 115200 serial terminal. - Run the first-run wizard:
wizardat thelrc>prompt. - Type
tuito enter the same ircii-style client every other surface uses — The on-device TUI — or stay at the raw console and usesay <#chan> <text>for chat without any client at all. tx ononce 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.
First-run wizard
Section titled “First-run wizard”wizard at the serial console
(any profile) walks a fresh board through the four things it needs before
it’s genuinely useful, in order:
- Region — mandatory; TX stays refused until this is set
(
lrc::region_gate_check(), the same gate the standaloneregioncommand uses). Type a region name (EU868,US915, …) orskipto leave it for later — nothing else in the wizard forces you through a step you’re not ready for. - 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.
- Backup — shows you where to find your seed backup. The console
itself doesn’t render the phonetic-hex/QR block (that lives in the
*lrcIRC service); this step tells you to runtuithen/msg *lrc key exportto see it, and asks you to confirm you’ve saved it before continuing. - Join defaults — an optional suggested nick and channel(s) to have
in mind when you connect a client. Purely advisory: any client can
still
/nickand/joinwhatever 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.
Verifying who you’re talking to
Section titled “Verifying who you’re talking to”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:
/whois <nick>— alongside the usualuid=<16hex>line, chIRpChat now sends a second line:chIRpChat fingerprint: <word> <word> …. Sixteen nibbles, sixteen words.- 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.
- 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.
Plain IRC that just works
Section titled “Plain IRC that just works”/nick, /join, /part, /msg, /notice, /topic, /names, /list,
/who, /whois, /mode, /invite, /kick, /quit — all standard. Things worth
knowing:
/whois bobshows retained radio truth: UID, router, reachability, attestation, known shared channels, andlast_heard_unixwhen 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./listover LoRa is paced; results stream in and are cached. Be patient on slow lanes.- Channels are
#name.rt1style — 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 sololrcdstarted with--solo-channels,/join #pizzaopens the local router’s#pizza.rtNchannel; explicit.rtNjoins stay exact./msg *lrc namespaceshows the active mode and join examples;/msg *lrc namespace #pizzaalso 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 #pizzajoins the resolved view using the same checks as/join./msg *lrc namespace merge #from #topreviews a local move between exact views and reports the non-merged state that will stay separate./msg *lrc namespace move #from #tomoves the current session by joining the resolved target view before parting the source view. A solo.rtNchannel stays tied to that router: ifrtNis 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
batchsee it grouped aschathistory; clients that requestserver-timeget 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 onadds queued, retry, delivered, and failure notices;/msg *lrc statusshows your pending outbound DM queue.
IRC client compatibility
Section titled “IRC client compatibility”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.”
Channel ownership
Section titled “Channel ownership”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)The *lrc service
Section titled “The *lrc service”/msg *lrc help lists everything. Highlights:
key export print local lrc1 seed keyfilekey lookup <nick|uid16> fetch and cache a signed identity recordkey import <lrc1-keyfile> adopt an identity after reconnectkey rotate rotate chat tag key (signed by identity key)key rotations [nick|uid16] list bounded local ROTATE archive rowskey forget <nick|uid16> remove cached identity/trust state locallykey revoke <nick|uid16> denylist a UID locally until key unrevokekey revoked list locally revoked UIDsrequire-sig bob demand full signatures on DMs from bobsign-dm bob sign outgoing DMs to bobcheckpoint [nick|uid16] show recent checkpoint ok/mismatch/unverified archiverouters [rtN] list latest heard router TELEM rollupsrouters history [rtN] [N] list retained router TELEM rollups newest-firstrouters aggregate [rtN] [N] list hourly router TELEM aggregates newest-firstadmin status show local admin policy and safe-key valuesadmin rtN stats radio signed fetch of current remote TELEMadmin rtN reboot signed callback-gated remote reboot requestnamespace [#channel] show solo/federated scope behavior and channel viewsnamespace join #channel join the view selected by the active namespace policynamespace merge #from #to preview a local view move; state remains separatenamespace move #from #to move this session between namespace viewsnamespace views [#channel] list local namespace views, optionally filteredregister rt3 manually pick a router (auto by default)track on|off / status ACK-backed DM delivery reportingbackfill #chan N pull older historystats [radio|power|store…] node telemetrycrash show and clear stored crash reportskey 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 node administration
Section titled “Remote node administration”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 rebootadmin 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 limitsTwo-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.
Offline direct messages
Section titled “Offline direct messages”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.
Configuration philosophy
Section titled “Configuration philosophy”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 modes
Section titled “WiFi modes”wifi client HomeNet hunter2wifi ap FieldNode fieldpassThose 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 and guided peering
Section titled “Pinned lrcd peers and guided peering”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.
Concurrent WiFi uplink
Section titled “Concurrent WiFi uplink”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 hunter2Use 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.)
DCC file transfer
Section titled “DCC file transfer”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 listid identity, profile, role, tx/retune statestatus one-screen transport/status dashboardmenu status plus the most useful local commandsstats telemetry counters, heap/PSRAMusb USB-network status and frame counterstui open the ircII-style full-screen TUIfwupdate enter firmware update mode when supportedrole client|router <id>|relay persisted; reboot to applytx on | tx off TRANSMIT MASTER SWITCH — ships off; never enable without an antenna attachedlane retune [on|off] local opt-in for stored non-anchor lane plansadmin op list show locally allowlisted Admin operator UIDsadmin op add <uid16> add a signed-Admin operator on this deviceadmin op clear clear the device Admin allowlistwifi ap [ssid] [psk] serve IRC at 192.168.4.1:6667 (≤8 clients)wifi client <ssid> [psk] join your LAN, serve IRC thereuplink [off|<ssid> [psk]] concurrent WiFi STA uplink (APSTA / + USB)peer [off|<host> <port>] persisted TCP lane target to an lrcd routersay <#chan> <text> chat without any client at allcrash print retained boot/crash reports, if anyreboot restart the nodesay 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.
The on-device TUI
Section titled “The on-device TUI”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.
Updating your device
Section titled “Updating your device”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:
- Plug the board in over USB.
- Open the flasher page, click Connect, and pick the matching serial port from the browser’s device picker.
- Choose stable or beta channel; the page fetches the matching release’s
merged-flash image and manifest and shows the signed
fw_version/board/profilebefore writing anything. - 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.
2. CLI fallback: esptool / pio
Section titled “2. CLI fallback: esptool / pio”Every release attaches per-board .bins and (for ESP32 boards) a
merged-flash image so a full re-flash is one command:
# 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 uploadIf 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:
esptool.py --chip esp32s3 --port /dev/ttyACM0 write_flash 0x10000 \ xiao_wio_sx1262-firmware.binVerify what you downloaded before flashing it — every release publishes
SHA-256SUMS and a signed manifest per artifact:
sha256sum -c SHA-256SUMS --ignore-missinglrc-manifest verify xiao_wio_sx1262.lrcmanifest <release-pubkey-hex> \ --image xiao_wio_sx1262-firmware.binlrc-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.
3. The device’s own fwupdate path
Section titled “3. The device’s own fwupdate path”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 uploadcomplete 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:
- 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.
- Flash as usual (web flasher,
esptool, orpio run -t upload). - 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.
- Every flash after the board is already running a chIRpChat image with
a working console can instead use
fwupdatefrom 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.
Remote fleet updates (OTA)
Section titled “Remote fleet updates (OTA)”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 channel2. 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,0a0b0c0d0e0f1011An 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 35. 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 confirmThis 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.
Multiple people, one device
Section titled “Multiple people, one device”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.)