Files
lobbs/docs/lobbs-internals.md
T

17 KiB
Raw Blame History

LoBBS Internals

https://discord.gg/DMrGcGQfMN

Player commands are in the daily user guide. The machine wire format is in the Machine API. Why plugins work the way they do, how to define your own hooks, and the new-app checklist are in the plugin author's guide.

Request flow

  1. A DM line starting with / may include an optional request id (/42 mail list).
  2. Page-only verbs (/p2, /p 2, /43 p2) load the cached LoBBSResponse for that sender and render a page.
  3. Otherwise slash_cmd runs. Each plugin compares the verb and returns quietly when it is not the owner.
  4. Handlers build a LoBBSResponse and call lobbsCommandReplyResponse. Successful replies are cached, then page 1 is rendered.
  5. Without a request id, plain text uses {p n/t} footers. With an id, machine replies use <id>ok [n:max].

There is no central unknown-command reply. If no module owns the verb, the node stays silent.

One verb, rest untouched

After peeling /[id], the registry takes one verb token and leaves the rest on ctx.rest.

Example: /mail send ben this is my long message keeps this is my long message intact. Mail peels send, then ben, then treats the rest as the body. Nothing re-tokenizes the whole line up front.

Handlers use lobbsArgShift, lobbsArgPeek, lobbsArgRest, lobbsArgShiftUint, lobbsArgPeekIsUint, and lobbsArgShiftMany on ctx.rest. Do not use partial atoi on numeric tokens.

Subcommands use one LoBBSVerb table for dispatch (lobbsDispatchSub) and help (lobbsHelpForTable). Rows with fn == nullptr are help-only. LOBBS_V_LOGIN and LOBBS_V_SYSOP gate dispatch and hide sysop rows from help when the caller is not a sysop.

Paging and reply cache

Every successful command answers with page 1 and stores the full result set in RAM for that sender's node id.

Constant Value Role
LOBBS_REPLY_CACHE_TTL_SEC 300 About five minutes, reset on each page read
LOBBS_REPLY_CACHE_MAX_BYTES 8192 Larger payloads are not cached
LOBBS_REPLY_CACHE_MAX_ENTRIES 32 Oldest entry dropped when full

A new successful command replaces the cache. Errors do not change it. Over-limit payloads send page 1 and clear the cache, so /p2 replies No cached reply.. Past the end: No such page.

Follow with /p2 or /43 p2 (request id optional). Plain-text serialization runs display_human on each record before paging.

Help and /hi

/help with no remainder builds a topic list: built-in rows (help, hi, pN) plus rows from help_topics. Logged-in users see feature topics. Sysop-only topics follow each plugin's rules.

/help mail or /help mail send runs help_for_topic. The query is in args as LODB_F_TITLE. Plugins set LODB_F_DESCRIPTION on the value record when they recognize the query. If nothing matches, the reply is No help found for ….

/hi is a welcome screen, not the command catalog. /status uses status_lines.

Hook bus

Source: LoBBSHooks.h. Modeled on WordPress actions and filters. The bus is shaped so a handler could later come from a script instead of compiled C++. Why it works this way, and how to define hooks of your own, is in the plugin author's guide.

Rules:

  • Hooks are named by string, matched case-insensitively. The bus stores the name pointer, so pass a string literal. Any number of handlers, added at runtime, no cap. lobbsHooksReset() clears all.
  • There is no list of allowed names. Firing a name nobody registered does nothing. Registering a name nobody fires is never called.
  • Every hook receives args as a LoScalar. Data crosses the bus as LoScalar fields, not C++ types.
  • No hook claims or short-circuits. Every handler runs.
  • Priority is fixed at registration (lower first, ties keep registration order). Bands: HELP 0, AUTH 10, FEATURE 20, STATUS 30, TIME 31. Install registers at AUTH - 1.
  • A handler registered with the wrong kind for a name is logged and skipped.
  • Handlers talk only through ctx, args, and the value. No handler calls another plugin.

Three kinds:

  • Action: (ctx, args), fire and forget.
  • Record filter: (ctx, LoScalar &value, args). Caller seeds value, each handler edits it or leaves it.
  • List filter: (ctx, std::vector<LoScalar> &value, args). Same, over a list.
Hook Kind Initial value args Role
slash_cmd action n/a verb (LOBBS_ARG_VERB), rest (LOBBS_ARG_REST) Compare with lobbsSlashVerbIs, parse ctx->rest
seed action n/a empty Demo and test builds (LOBBS_SEED). Each app seeds its tables
help_topics list built-in topics empty Append {title, description} rows
help_for_topic record title = topic title = query Set description when the topic matches
status_lines list empty empty Append {title, description} (Users / 12 total renders as Users (12 total))
display_human record generic title line source record Rewrite title for records you recognize
config_keys list empty empty Append key definition rows (default, min, max in fields 0–2)
config_validate record title = key, 0 = value key definition record Set LODB_F_ERROR to reject; empty means ok
config_changed action n/a title = key, field 0 = effective value Apply new settings after set, reset, or database open
install_mounts list empty empty Append {title = mount name} rows a database may be installed on

These are the hooks core and the bundled apps fire. Apps add their own names the same way.

Something happened: action. Contribute to one shared value: record filter. Contribute to a shared list: list filter. A new need gets a new hook name, not a new kind.

Result sets and rendering

Handlers build LoBBSResponse (ok, error, records) with lobbsRecordPush, lobbsResponseAppendRecord, or lobbsResponseSetError, then call lobbsCommandReplyResponse. lobbsCommandReply and lobbsCommandReplyError wrap it. Handlers never page.

Human: generic title (description) per record, then display_human, packed into 200 bytes with {p n/t}.

Machine: records encoded as LoScalar lines, sliced into pages with <id>ok [n:max]. See the Machine API guide.

lobbsMsgRegisterDisplay() (wireup) registers one display_human handler for mail and news list and read rows. Mail and news command modules do not register their own display_human filters.

LoFS and install

LoFS exposes / as a virtual root listing mounts. Writable paths start with /<mount>/… (optional sd, optional extra when built with LOBBS_EXTRA_QSPI=1, flash2 on nRF52840, flash). Mount roots cannot be removed, renamed, or used as copy sources. Cross-mount mv copies files then deletes the source. Directories require same-mount rename.

/flash2 is a second nRF52840 internal-flash partition, 100 KiB at 0xD4000–0xED000 between the app and InternalFS. The linker scripts src/platform/nrf52/nrf52840_s140_v6.ld (set on nrf52840_base) and nrf52840_s140_v7.ld end FLASH at 0xD4000 and provide __flash2_start / __flash2_end. LoFS reads them as weak symbols, so nRF builds on other linker scripts have no /flash2. The partition is a second Adafruit_LittleFS with 128 B blocks over flash_nrf5x. It is formatted when mount fails or by SysOp /format flash2 (LoFS::format, which erases the pages first). Its block IO takes InternalFS's lock because the flash_nrf5x page cache is shared and the BLE task writes bonds to InternalFS.

/extra is optional onboard QSPI flash (2 MiB, 4096 B sectors, 256 B read/prog, 512-block lookahead). LoFS uses LoFSQspi (nrfx_qspi, FASTREAD/PP at SCK/8, no quad-enable). Boot sends release-from-deep-power-down (0xAB) and software reset (0x66/0x99), then reads the JEDEC ID and expects Puya 0x85. A wrong or missing ID skips the mount and never formats. A mount failure on a good ID formats, as does /format extra. /format flash runs FSCom.format() then nodeDB->saveToDisk(), the same recovery Meshtastic uses after a failed save. Formatting the install mount reruns lobbsInstallInit, so the node goes Blank unless another mount holds an install. Every custom instruction holds IO2/IO3 high: they are WP#/HOLD# while the quad-enable bit is clear, so LoFSQspi polls the status register (0x05) itself instead of nrfx_qspi_mem_busy_check, which sends it with HOLD# low.

LoFS::list callbacks get (ctx, basename, isDirectory, size). size is the file length, 0 for directories and mounts.

Each mount has a dbSafe flag (LoFS::mountDbSafe). sd, extra, and flash2 are db-safe. flash is db-safe only when there is no flash2 mount, because a failed Meshtastic save on nRF52 formats InternalFS. Install points come from the install_mounts list filter. Core registers a provider that lists present db-safe mounts in mount-table order, which is preference order (sd, extra, flash2, flash). /install accepts only listed mounts, the install hint and usage show them, and demo seeding uses the first one.

Shared mounts (/flash) keep LOFS_SHARED_RESERVE_BYTES free (16 KiB nRF52, 128 KiB other hardware, 0 Portduino; override with -D). LoFS::hasRoom(path, bytes) rounds bytes up to the block size, adds slack for metadata, adds the mount reserve, and compares with free space. It returns true when the mount reports no size. LoDB checks it before every record write and returns LODB_ERR_FULL. FsCommands checks it for /mkdir, /cp, /upload, and cross-mount /mv. Apps map LODB_ERR_FULL to Disk full. via lobbsDbErrorText. Deletes are never blocked.

LoBBS home: everything lives under /<mount>/lobbs (LOBBS_HOME_DIR). install.ls (field LOBBS_INSTALL_FIELD_VERSION = LOBBS_VERSION at install) marks the install and is written last. db/ is the LoDB (LoDb("db") opened at the home). apps/<app>/ and home/<user>/ are reserved conventions. At boot lobbsInstallInit walks install_mounts in order and takes the first mount holding lobbs/install.ls. Later hits are logged and ignored. No marker lives on /flash.

State Behavior
Blank No lobbs/install.ls on any install mount. Only /help and /install
Ready Marker found and LoDb::open(home) succeeded
Offline Marker found but the database failed to open. Non-help commands error

/install authorization matches admin PKI in AdminModule (local client or encrypted DM with a configured admin key). Only /install creates SysOp accounts.

Sessions

Logins live in RAM, not LoDB: AuthDal holds a vector of {nodeId, userUuid, lastActiveMs, cwd} slots, keyed by sender node id. Slot count and idle expiry come from session.max and session.idle in the config registry (defaults 16 and 86400 s). Each lookup refreshes lastActiveMs. A slot idle past the expiry, or whose user row is gone, is freed on lookup. Logging in reuses the node's slot, takes a free or expired one, or evicts the longest idle. Shrinking the limit keeps the most recently active. Sessions end on reboot, and clearSessions() runs whenever the database opens. Logins never write flash; overrides live in the config table.

Config registry

SysOp /config lists, reads, sets, and resets uint32 settings. Plugins declare keys on config_keys via lobbsConfigPushKey. /help config key replies with the key's help text, default, and range. Validation runs on config_validate (core registers a min/max range check at help priority). Successful sets upsert one row in table config (title = key); reset deletes the row so defaults cost no flash. config_changed fires after each set or reset and for every key when the database opens (lobbsInstallDatabaseOpened).

SysOp file commands, chunked upload, and path rules are documented in the SysOp guide.

LoScalar

One record is one line of numbered fields, number:value, joined by | in ascending field order, numbers 0..99. Values escape \, |, and newline with a backslash. Typed accessors cover strings, uint32, uint64, bools, and 32-byte keys as hex. encode takes a byte limit and fails rather than write past it.

95:1790000000|96:1790000000|97:see you at the trailhead|99:9168245037165234184

Why this shape:

  • One format everywhere. The same line is a row on disk, a record in a LoBBSResponse, a hook argument, and a line in a machine reply. Nothing translates between a storage format and a wire format, and a client decodes exactly what the node stored.
  • You can read it. /cat on a row file, or the SD card in a laptop, shows the fields as text. Debugging a node in the field needs no decoder.
  • No schema compiler. A record type is a *Records.h header of field numbers. A new field is a new constant, with no generated code and no build step.
  • Numbers instead of names. A field costs two digits and a colon, not a key name. That matters when the whole reply is 200 bytes.
  • Decoding keeps every field, including numbers the reader does not know. Two apps, or a firmware and a client a release apart, can share a record without agreeing on all of it.
  • Flat on purpose. There is no nesting and no quoting. A news body full of quotes needs no JSON parser. A structured value, like the wall grid, is one string field the owning app parses.
  • Small. The codec is one class with no dependencies, which fits nRF52 flash and the 512-byte stack cap.

On nRF52, do not format uint64_t with %llu. Use loU64ToDec from loutil/LoUtil.h. loHumanBytes prints short sizes (9B, 1.2K, 1.8M).

LoDB

LoDb::open(root) stores tables at <root>/<db>/<table>/ (LoBBS: /<mount>/lobbs/db/<table>/), one <16 hex uuid>.ls file per row. Writes go to <path>.w then rename. API: registerTable, insert, get, update, upsert, deleteRecord, select, count. Row cap LODB_MAX_RECORD_BYTES (1024). Uuid from lodb_new_uuid. Prefer upsert for keyed or singleton rows. Runtime quotas and session limits use the config registry, not per-app singleton tables.

Field numbers

App fields use 0..93 (LODB_F_USER_LIMIT is 94). Each app's *Records.h is the schema. System fields count down from 99.

Number Name Who writes it
99 LODB_F_ID LoDB, the row uuid
98 LODB_F_TITLE The app. Human renderer line
97 LODB_F_DESCRIPTION The app. Mail, news, yarn bodies
96 LODB_F_CREATED LoDB unless set. Unix seconds
94 LODB_F_ERROR Any filter. User-facing error
95 LODB_F_UPDATED LoDB unless set. Unix seconds

Strip 95 and 96 before update to get default stamps. Times are Unix seconds, not milliseconds.

Stack budget

LoBBSStackGuard.h: on nRF52 every LoBBS function is capped at 512 bytes of stack (-Wstack-usage=512 as an error). Commands run on the 4 KB loop task shared with the router. Include the guard last in every LoBBS .cpp. Keep large buffers off the stack.

Verifying

Build pio run -e rak4631 and run trunk fmt before commit. Native tests are not green on this branch. See .cursor/rules/lobbs-verification.mdc.

Plugin checklist

See lobbs-plugins.md.