Architecture
chIRpChat lets an unmodified IRC client chat across a LoRa radio network that may or may not touch the internet. The device in your hand is the IRC server; the radio network behind it is invisible.
┌─────────────┐ 6667/TCP ┌──────────────────────────────┐ LoRa lanes ┌────────────┐│ stock IRC │◄────────►│ lrc-node (XIAO etc.) │◄──────────────►│ lrc-router ││ client │ USB-ECM/ │ ┌────────┐ ┌──────────────┐ │ ▲ │ sequencer ││ (irssi, │ WiFi │ │ IRC │ │ chIRpChat │ │ │ relays │ store ││ WeeChat, │ │ │ gateway│◄│ (lrc-core) │ │ ▼ │ federation ││ HexChat…) │ │ └────────┘ │ codec·dedup· │ │ TCP "lanes" │ │◄──► other└─────────────┘ │ ┌────────┐ │ paths·lanes │ │◄──────────────►│ │ routers telnet/serial ───────►│ │ TUI │ └──────────────┘ │ (LoRa-over-TCP)└────────────┘ (TCP/LoRa) (ircii-style) │ └────────┘ │ └──────────────────────────────┘One C++17 core (core/) is compiled three ways:
| Target | HAL | Binary |
|---|---|---|
| ESP32-S3 / nRF52 client | Arduino/RadioLib/TinyUSB | lrc-node firmware |
| ESP32-S3 / nRF52 router | same, router feature set | lrc-router firmware |
| Linux / macOS / Raspberry Pi | POSIX sockets, files, SPI HAT | lrcd daemon |
The core contains no Arduino.h, no sockets, no malloc in hot
paths — it is pure logic against interfaces (lrc::Clock, lrc::Rng,
lrc::Lane, lrc::Store, lrc::Sink). That’s what makes the test suite
able to simulate whole networks on the host (see tests/), and it is the
property contributors must preserve (AGENTS.md).
Subsystem map
Section titled “Subsystem map”| Subsystem | Doc | Code |
|---|---|---|
| Wire format, dedup, fragmentation, recovery | PROTOCOL.md | core/src/packet.cpp, dedup.h, frag.cpp |
| Keys, tags, checkpoints, rotation | IDENTITY.md | core/src/ident.cpp, vendor/ed25519 |
| Registration, paths, federation, channel admin | ROUTING.md | core/src/router.cpp |
| Lanes, presets, probing, DCC bursts | RADIO.md | core/include/lrc/presets.h |
| IRC gateway | this file, §IRC gateway | core/src/irc/ |
| Flash/PSRAM layout, message store | STORAGE.md | core/src/chanstore.cpp, core/src/channel_log.cpp |
| Sleep/power | POWER.md | firmware |
| Boards, RF safety | HARDWARE.md | firmware/variants/ |
| Counters, health, field debugging | TELEMETRY.md | core/include/lrc/telemetry.h |
| User-facing configuration | USERGUIDE.md | — |
The IRC gateway
Section titled “The IRC gateway”The gateway is a real (subset) RFC 1459/2812 server, not a shim:
- Connection registration:
NICK,USER(username + realname honored),PING/PONGliveness,QUIT,ISUPPORT/005, and IRCv3CAP LSnegotiation forecho-message,away-notify,labeled-response,batch, andserver-time. IRCv3 message tags from clients are tolerated syntactically; history replay usesbatchand emitsserver-timewhen the sequencing node had a disciplined clock and the replaying node’s clock is inside the 10-minute display-skew guard. - Channels:
JOIN/PART(broadcast to members as required),PRIVMSG,NOTICE,TOPIC,NAMES,LIST(served from the router’s channel directory over a paged CHANSYNC query —/liston LoRa is paced and cached),MODE(channel modes mapped to router channel records),INVITEfor invite-only channels,KICK,WHO,WHOIS(shows retained chIRpChat facts: UID fingerprint, current router, reachability, attestation, known channels, and clock-backedlast_heard_unix; lane/preset need future retained metadata).WHOISalso emitsRPL_WHOISIDLE(317): idle seconds for a local session from the server clock, signon epoch from the bridge’s wall clock where available. - Client status / server-info commands a real IRC client pings on connect:
ISON(303),USERHOST(302),VERSION(351),TIME(391),LUSERS(251) — each removes a421from the handshake/keepalive path. - Channel namespace:
#name.rt1= channel scoped to routerrt1;#nameinside a federation = the shared channel (ROUTING.md). The gateway enforces explicit.rtNscope isolation, and a solo node can canonicalize/join #pizzato its primary scope (#pizza.rt1) before creating local IRC state./msg *lrc namespace [#channel]explains the active policy and a channel’s current local views;/msg *lrc namespace views [#channel]lists open local views without joining anything;/msg *lrc namespace join #channelperforms a policy-resolved local JOIN to the exact view without mutating routing state;/msg *lrc namespace merge #from #topreviews the exact source and target views, local membership, and non-merged channel state;/msg *lrc namespace move #from #tojoins the resolved target view and then parts the source view for the current IRC session. Solo scopes do not auto-elect a replacement sequencer:#name.rt1is degraded whilert1is down unless users deliberately move to another scope. Federated channels continue on reachable router streams during a split and backfill the missing stream after it returns. - Multi-client, multi-identity: each TCP connection to port 6667 is an
independent IRC session. Sessions whose username matches a stored
identity bind to it; unknown usernames mint a fresh identity (key
generated on-device, exportable later). A family shares one XIAO: three
laptops, three nicks, three keys, one radio. Session→identity binding is
by
USERusername, exactly as the user model expects. Durable client state is identity-scoped too: explicitJOIN/PART/KICKupdates a persisted joined-channel snapshot, IRC re-registration restores that snapshot, and a plain disconnect leaves it intact. - Liveness chain: IRC client misses PINGs → gateway drops session → if no
session holds the identity and no standby device is registered, gateway
sends
BYEupstream → router dereregisters and channel members seeQUIT. Router reachability changes learned throughRTRSYNCrender labeled*lrcchannel notices plus temporary IRCQUIT/JOINnetsplit transitions without deleting directory attachments. The radio network’s presence view is the IRC presence view. - Flood protection: each registered IRC session gets a small fixed-window
command bucket. Excess registered commands are dropped with one throttle
numeric per window and counted in
irc_flood_drop; registration andPING/PONG/QUITstay outside the limiter so liveness cannot be wedged by the guard itself.
The *lrc service
Section titled “The *lrc service”Anything that doesn’t map onto classic IRC appears as a service user
(/msg *lrc help): ACK-backed DM delivery tracking/status, local seed
keyfile export/import through the node SeedStore, local cached-peer
key forget/revoke, identity status, lane stats, router
selection, telemetry queries, signed remote telemetry fetches
(/msg *lrc admin rt1 stats radio — an ADMIN
packet), local read-only admin policy/status, and deny-by-default mutating
Admin for telem.interval_ms, RTRSYNC cadence/delta retention, and non-RF
checkpoint policy keys covering cadence, verification windows, audit retention,
stored lane-plan intent, and callback-gated remote reboot, all with optional
distinct-operator quorum.
On lrcd, --state-dir
persists those safe keys in admin.cfg after quorum succeeds; ESP32 firmware
persists the same safe numeric keys in lrckv when the partition is present,
and signed lane.plan writes the existing laneplan store. Firmware activates
stored non-anchor lane rows only after the local lane retune on RF opt-in.
Broader RF/TX and remote runtime-retune administration remains the
operator-authorized future
ADMIN surface specified in USERGUIDE.md.
The read-side companion is lrcctl (daemon/lrcctl): a zero-dependency Python
CLI that reads the :8462 metrics port and renders node health (status/fed/
radio/counters, plus a watch live dashboard). The write path is the IRC
*lrc service and signed-ADMIN packets; lrcctl is the “how is this node
doing?” check without opening an IRC client. IRC OPER (with --oper-credential NAME:SECRET) and UID-bound operators (--admin-operator) grant oper override
(SAJOIN/SAMODE/SAPART/KICK) to resolve abuse.
DCC interception
Section titled “DCC interception”Stock clients send PRIVMSG nick :\x01DCC SEND name <ip32> <port> <size>\x01
expecting peer-to-peer TCP. The gateway:
- Parses the CTCP before anything hits radio, so RFC1918 peer addresses do
not leak into the mesh. The built slice converts remote
DCC SENDoffers intoDCCCTL OFFER, renders an accept prompt on the recipient, and sendsDCCCTL ACCEPTwhen the recipient runs/msg *lrc dcc accept <nick>. When an embedding configures a local DCC listener address, the receiver gateway also emits a stock-client CTCPDCC SENDwith that local ip32/port, replacing the sender’s original LAN coordinates before they reach the local IRC client.lrcdexposes that advert endpoint through--dcc-listener-ipand--dcc-listener-port; when configured it also opens that listener for the receiver’s stock client. - Translates offer/accept into
DCCCTLand the byte stream intoDCCDATAchunks, with burst-lane pacing defined in RADIO.md §DCC burst lanes. The core has the reliable DCCCTL sender/ACK loop, the gateway emits OFFER/ACCEPT controls, the sidecar-facing core primitive can send and deliver taggedDCCDATAchunks to a localDccDataSink, andDccReassemblerstages chunks into a preallocated file buffer while generating compactRESUMEbitmaps for missing chunks.Node::send_dcc_resumesends those bitmaps over the existing reliable DCCCTL path and delivers inbound RESUME controls to a sender-side sidecar sink.DccChunkSchedulerstages sender bytes once, exposes initial chunk views, and maps valid RESUME bits to bounded replay lists. OFFER/ACCEPT correlation now yields the same derived stream id on both endpoints and emits sidecar session callbacks after the local ACCEPT is queued or the remote ACCEPT is received.DccOutboundTransferwraps the scheduler for the future listener adapter: initial chunks and queued RESUME retransmits are pumped through a caller-provided sender, with retransmits queued in fixed storage rather than sent from the receive callback. A sidecar that has staged bytes may now publish the existingcontent_hash64OFFER field, andDccReassemblerverifies completed receive buffers when that nonzero hash is supplied; hash-failed buffers are not deliverable.DCCCTL CLOSEnow has a strict stream/status payload, a reliableNode::send_dcc_closehelper, inbound delivery to a local close sink, and telemetry for delivered closes.DCCCTL LANE_GRANTnow has a strict stream/preset/frequency-slot/token/ TTL/airtime-budget payload, a reliable sender helper, inbound delivery to a local grant sink, and telemetry for delivered grants.DccBurstGrantTrackerconsumes those grants and matching CLOSE controls for one accepted stream, enforcing local stream identity, Clock-based TTL expiry, renewal, and teardown. Pure helpers expose the active grant as a send intent and combine it withLaneSchedule::runtime_decisionso callers can distinguish burst ready, yield, and anchor fallback states with a chunk budget bounded by grant airtime, usable lane time, and caller cap.DccBurstLeaseMachineturns that plan into a host-tested lease state machine, including ready/wait/yield/expired/closed states, token release, and force-minimum beacon cadence while a live lease is in progress.DccBurstRfIntentis the pure retune-adapter handoff: ready matching leases become exact radio parameters plus a chunk budget, while unready states preserve close/release control without authorizing a retune.lrcdnow issues a receiver-side sidecar grant after ACCEPT, renews it while the receive buffer is incomplete, and consumes the active grant on the sender to cap the file-backed sidecar pump by preset airtime budget; actual board RF driver wiring remains the future device adapter. lrcdwires local TCP sidecars into the accepted-session, tagged chunk, reassembler, RESUME, CLOSE, and outbound pump primitives, then serves the receiving client’s expected DCC TCP flow locally. The sender side connects to the original stock-client DCC server after ACCEPT and sends classic 4-byte cumulative DCC TCP ACKs while staging source bytes in an anonymous temp file, then reads requestedDCCDATAchunks back from that file. The receiver side drains client ACKs, writes only deliverable reassembled bytes, returnsCLOSE HashFailon hash mismatch, and closes the local DCC socket. Embedded PSRAM/flash staging and actual burst-lane retune/scheduling remain future gateway work.
Network presentation (USB / WiFi)
Section titled “Network presentation (USB / WiFi)”Firmware builds declare one LRC_PROFILE so boards are profiles, not forks.
Profiles choose only compiled presentation transports, initial role, and
fresh-config defaults; LPP wire behavior is unchanged and TX still defaults
off in every profile.
| Profile | Transports | Fresh role/defaults |
|---|---|---|
field-hotspot |
USB-ECM-capable builds and/or WiFi SoftAP serving IRC | client, WiFi ap where ESP32 WiFi is present |
uplink-router |
WiFi station and TCP peer lanes | router, rtr=1, WiFi client awaiting credentials |
serial-only |
CDC serial console/TUI, no WiFi assumption | client, WiFi off |
- USB: ESP32-S3 USB-net builds present a TinyUSB ECM network device.
The device is
10.97.83.1/28and DHCP offers.2-.9to the host. IRC is at10.97.83.1:6667, the console/TUI surface is at:2323, and telemetry JSON is at:8462. nRF52 uses TinyUSB ECM the same way. (Meshtastic exposes only CDC+protobuf — the netdev approach is what lets unmodified clients work, and it’s the project’s defining UX bet.) - WiFi (ESP32): profile defaults may start in
aporclient; saved config always wins.apmode serves the same stack over a SoftAP for phones;clientmode joins a home network, where the node serves IRC/TUI/telemetry to the LAN and can carry TCP lanes to other nodes/routers. Pinnedlrcdpeers use the ROUTING.md RK challenge-response and per-frame MAC; unpinned daemon sockets retain legacy framing only for firmware TCP lanes and lab experiments. Mailbox snapshot exchange uses only the target-aware pinned/MACed peer egress, never the legacy broadcast path. - Serial console: firmware builds expose a line-oriented
lrc>shell over USB CDC where available, or over the board UART. The shell covers local status/menu, telemetry counters, USB/WiFi/peer diagnostics, RF transmit opt-in, lane-retune opt-in, Admin operator setup, crash reports, reboot, and simplesay #chan textchat. Typingtuion either the serial console or the:2323network console upgrades that session to the same coreTuienginelrcdserves — allocated on demand and freed on exit (zero RAM when off), behind a free-heap guard, with scrollback sized to memory pressure (compact under a WiFi AP, roomier otherwise). While a serial TUI is open the node’s async[lrc] …logs route into its status window. See TELEMETRY.md §Field debugging.
The TUI
Section titled “The TUI”A ~ncurses-feel client (ANSI escape rendering, no curses dependency —
embedded-friendly) over telnet :2323 or serial: status bar, channel
windows, /window-style switching, Alt-0..9, Ctrl-N/Ctrl-P, and
screen-style Ctrl-A prefix digits. A small CSI parser handles cursor keys,
Home/End/Delete, PageUp/PageDown scrollback, input history, and split
terminal escape sequences; unsupported escapes are consumed and ignored.
The /config modal renders a navigable form for identity, region, router,
lanes, keymap, and theme, with save/cancel through a Tui::ConfigStore seam
so firmware NVS, lrcd files, and tests can supply their own backends. The
status bar reports unseen windows as [Act: 2,4]. The color theme renders
stable nick-hash colors, mIRC color codes, own-nick mention highlight, and a
colored status bar; the mono theme strips chat color for dumb serial
terminals. The classic ircii command set maps 1:1 onto the same internal
session API the IRC gateway uses. One codepath, two faces; the TUI cannot
drift from the gateway because both are lrc::irc::Session consumers.
Threading model
Section titled “Threading model”Embedded: single logic thread (the core is run-to-completion, event-driven;
ISRs only enqueue), one radio task, one USB/net task. lrcd: poll()-driven
single-threaded event loop — same logic, OS sockets. No locks inside the core;
all state mutation happens on the logic thread. This is the rule that keeps
heisenbugs out of a codebase that will be maintained largely by AI agents.