Skip to content

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 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 gateway is a real (subset) RFC 1459/2812 server, not a shim:

  • Connection registration: NICK, USER (username + realname honored), PING/PONG liveness, QUIT, ISUPPORT/005, and IRCv3 CAP LS negotiation for echo-message, away-notify, labeled-response, batch, and server-time. IRCv3 message tags from clients are tolerated syntactically; history replay uses batch and emits server-time when 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 — /list on LoRa is paced and cached), MODE (channel modes mapped to router channel records), INVITE for invite-only channels, KICK, WHO, WHOIS (shows retained chIRpChat facts: UID fingerprint, current router, reachability, attestation, known channels, and clock-backed last_heard_unix; lane/preset need future retained metadata). WHOIS also emits RPL_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 a 421 from the handshake/keepalive path.
  • Channel namespace: #name.rt1 = channel scoped to router rt1; #name inside a federation = the shared channel (ROUTING.md). The gateway enforces explicit .rtN scope isolation, and a solo node can canonicalize /join #pizza to 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 #channel performs a policy-resolved local JOIN to the exact view without mutating routing state; /msg *lrc namespace merge #from #to previews the exact source and target views, local membership, and non-merged channel state; /msg *lrc namespace move #from #to joins 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.rt1 is degraded while rt1 is 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 USER username, exactly as the user model expects. Durable client state is identity-scoped too: explicit JOIN/PART/KICK updates 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 BYE upstream → router dereregisters and channel members see QUIT. Router reachability changes learned through RTRSYNC render labeled *lrc channel notices plus temporary IRC QUIT/JOIN netsplit 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 and PING/PONG/QUIT stay outside the limiter so liveness cannot be wedged by the guard itself.

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.

Stock clients send PRIVMSG nick :\x01DCC SEND name <ip32> <port> <size>\x01 expecting peer-to-peer TCP. The gateway:

  1. Parses the CTCP before anything hits radio, so RFC1918 peer addresses do not leak into the mesh. The built slice converts remote DCC SEND offers into DCCCTL OFFER, renders an accept prompt on the recipient, and sends DCCCTL ACCEPT when 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 CTCP DCC SEND with that local ip32/port, replacing the sender’s original LAN coordinates before they reach the local IRC client. lrcd exposes that advert endpoint through --dcc-listener-ip and --dcc-listener-port; when configured it also opens that listener for the receiver’s stock client.
  2. Translates offer/accept into DCCCTL and the byte stream into DCCDATA chunks, 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 tagged DCCDATA chunks to a local DccDataSink, and DccReassembler stages chunks into a preallocated file buffer while generating compact RESUME bitmaps for missing chunks. Node::send_dcc_resume sends those bitmaps over the existing reliable DCCCTL path and delivers inbound RESUME controls to a sender-side sidecar sink. DccChunkScheduler stages 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. DccOutboundTransfer wraps 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 existing content_hash64 OFFER field, and DccReassembler verifies completed receive buffers when that nonzero hash is supplied; hash-failed buffers are not deliverable. DCCCTL CLOSE now has a strict stream/status payload, a reliable Node::send_dcc_close helper, inbound delivery to a local close sink, and telemetry for delivered closes. DCCCTL LANE_GRANT now 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. DccBurstGrantTracker consumes 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 with LaneSchedule::runtime_decision so callers can distinguish burst ready, yield, and anchor fallback states with a chunk budget bounded by grant airtime, usable lane time, and caller cap. DccBurstLeaseMachine turns 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. DccBurstRfIntent is 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. lrcd now 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.
  3. lrcd wires 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 requested DCCDATA chunks back from that file. The receiver side drains client ACKs, writes only deliverable reassembled bytes, returns CLOSE HashFail on hash mismatch, and closes the local DCC socket. Embedded PSRAM/flash staging and actual burst-lane retune/scheduling remain future gateway work.

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/28 and DHCP offers .2-.9 to the host. IRC is at 10.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 ap or client; saved config always wins. ap mode serves the same stack over a SoftAP for phones; client mode joins a home network, where the node serves IRC/TUI/telemetry to the LAN and can carry TCP lanes to other nodes/routers. Pinned lrcd peers 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 simple say #chan text chat. Typing tui on either the serial console or the :2323 network console upgrades that session to the same core Tui engine lrcd serves — 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.

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.

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.