mirror of
https://github.com/MeshEnvy/lobbs.git
synced 2026-11-03 11:32:25 +01:00
feat(meshtastic): extract LoBBS plugin library
This commit is contained in:
1 parent
1f7ded8f3f
commit
5bffe8d259
124 files changed
+10907
-39
No files matched your search
@@ -0,0 +1,86 @@
|
||||
# LoBBS Daily User Guide
|
||||
|
||||
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
|
||||
|
||||
If you found LoBBS somewhere on your mesh and are a daily user, this guide is for you.
|
||||
|
||||
DM the node a line that starts with `/`.
|
||||
|
||||
`/login username password` signs in or creates an account after the node is installed. If the node says it needs install, find the SysOp. See the [SysOp guide](lobbs-sysop.md).
|
||||
|
||||
Long answers end with `{p 1/3}`. Send `/p2`, then `/p3`. Paging works on your last answer for about 5 minutes. A new command replaces it.
|
||||
|
||||
## Public
|
||||
|
||||
| Command | Notes |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| `/hi` | Welcome screen and hints |
|
||||
| `/status` | Overall status (paged) |
|
||||
| `/time` | Current time |
|
||||
| `/help [topic]` | Command catalog, or topic help |
|
||||
| `/pN` | Next page of the last reply (e.g. `/p2`) |
|
||||
| `/wall` | Play the Wall game (shared ASCII art) |
|
||||
| `/yarn` | Play the Yarn game (shared story) |
|
||||
|
||||
## Session
|
||||
|
||||
| Command | Notes |
|
||||
| ---------------------- | ---------------------------- |
|
||||
| `/login user password` | Sign in or create an account |
|
||||
| `/logout` | End session |
|
||||
| `/whoami` | Who you are |
|
||||
| `/passwd new confirm` | Change your password |
|
||||
|
||||
A login ends when the node reboots or after a day without commands (the SysOp can change both limits). When every login slot is taken, a new login signs out whoever has been idle longest. Run `/login` again if `/whoami` says you are not logged in.
|
||||
|
||||
## Users
|
||||
|
||||
Login required.
|
||||
|
||||
| Command | Notes |
|
||||
| ------------------ | ---------------- |
|
||||
| `/users list` | User list |
|
||||
| `/users find text` | Search usernames |
|
||||
|
||||
## Mail
|
||||
|
||||
Login required. SysOps reading someone else's inbox: [SysOp guide](lobbs-sysop.md).
|
||||
|
||||
| Command | Notes |
|
||||
| -------------------------- | ---------------------- |
|
||||
| `/mail list` | Your inbox |
|
||||
| `/mail read N` | Read message N |
|
||||
| `/mail N` | Same as `/mail read N` |
|
||||
| `/mail unread N` | Mark unread |
|
||||
| `/mail delete N` | Delete from your inbox |
|
||||
| `/mail send user message…` | Send mail |
|
||||
|
||||
## News
|
||||
|
||||
Login required.
|
||||
|
||||
| Command | Notes |
|
||||
| --------------------- | ---------------------- |
|
||||
| `/news list` | News index |
|
||||
| `/news read N` | Read item N |
|
||||
| `/news N` | Same as `/news read N` |
|
||||
| `/news unread N` | Mark unread |
|
||||
| `/news post message…` | Post news |
|
||||
|
||||
## Wall
|
||||
|
||||
`/wall` shows the grid (no login). Painting needs login.
|
||||
|
||||
Rows `a`–`l`, columns `1`–`12`. `a4x` paints `x` at row a, column 4. `-a4` clears that cell. Several tokens in one command: `/wall a1# a2# b2#`.
|
||||
|
||||
| Command | Notes |
|
||||
| -------------- | --------------------------------------- |
|
||||
| `/wall token…` | Paint. Default quota is 1 cell per hour |
|
||||
|
||||
## Yarn
|
||||
|
||||
`/yarn` shows the tail (no login). Adding words needs login.
|
||||
|
||||
| Command | Notes |
|
||||
| -------------- | ----------------------------------------------------------- |
|
||||
| `/yarn word …` | Add words. Default quota is 1 word (32 characters) per hour |
|
||||
@@ -0,0 +1,179 @@
|
||||
# LoBBS Internals
|
||||
|
||||
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
|
||||
|
||||
Player commands are in the [daily user guide](lobbs-daily.md). The machine wire format is in the [Machine API](lobbs-machine.md). 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](lobbs-plugins.md).
|
||||
|
||||
## 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](lobbs-plugins.md).
|
||||
|
||||
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](lobbs-sysop.md).
|
||||
|
||||
## 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](lobbs-plugins.md).
|
||||
@@ -0,0 +1,55 @@
|
||||
# LoBBS Machine API
|
||||
|
||||
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
|
||||
|
||||
For rich clients and agents over DM. Command names and player behavior are in the [daily user guide](lobbs-daily.md). Field numbers and hook behavior are in [LoBBS internals](lobbs-internals.md).
|
||||
|
||||
## Why a machine mode
|
||||
|
||||
The plain-text reply is written for a person on a phone. A mail row reads `[2]*@ben: see you at the trailhead (3h ago)`. The index, the `*` unread mark, the truncated preview, the relative time, and the `{p n/t}` footer exist for eyes, and plugins can rewrite them through `display_human`. A client that scrapes that line breaks whenever the wording changes.
|
||||
|
||||
A machine reply is the same result set before rendering. That mail row arrives as separate fields: the row id, the read flag, the created time in Unix seconds, and the preview, next to the title line a person would see. A client reads the fields it knows and ignores the rest.
|
||||
|
||||
The request id exists because the mesh is not a socket. DMs arrive seconds or minutes late, some never arrive, and they can arrive out of order. A client with several requests in flight matches each reply to its request by the echoed id. A person typing by hand does not need that, so the id is optional, and its presence is what selects machine mode.
|
||||
|
||||
There is no second API. Every command a person can type, a client can send with an id, and the same handler answers both. A new command works from a client the day it ships.
|
||||
|
||||
Paging uses the same cached reply for both. Each page is its own request with its own id, so a client that loses page 2 asks for page 2 again instead of re-running the command. `[n:max]` tells it how many DMs to collect before it parses.
|
||||
|
||||
## Requests
|
||||
|
||||
Put a number and a space after `/` on a command to get a machine reply. The number only works when a space follows it (`/42 login`, not `/42login`). Without a leading id, replies use plain text and `{p i/n}` footers on the same cached result set.
|
||||
|
||||
Run the command first, then request further pages without re-running it.
|
||||
|
||||
```
|
||||
/42 mail list
|
||||
<42>ok [1:2]
|
||||
…payload…
|
||||
|
||||
/43 p2
|
||||
<43>ok [2:2]
|
||||
…payload…
|
||||
```
|
||||
|
||||
## Reply shape
|
||||
|
||||
Each DM is 200 bytes or less, header included.
|
||||
|
||||
| Case | Header | Body |
|
||||
| ----------------------- | ------------------------ | --------------------- |
|
||||
| Success, one page | `<id>ok` newline | LoScalar record lines |
|
||||
| Success, multiple pages | `<id>ok [n:max]` newline | Slice of the document |
|
||||
| Error | `<id>` | One sentence, no `ok` |
|
||||
|
||||
The `[n:max]` marker is separate from the request id. Use a fresh id on each page request (`/43 p2`, `/44 p3`). The reply echoes the id from that page request. Stop when `n == max`.
|
||||
|
||||
Success bodies are the cached result set encoded as LoScalar lines (`number:value|number:value`), one record per line. Concatenate page bodies in order, then split on newlines. Pages may cut through the middle of a line. Reassemble the full document before parsing records.
|
||||
|
||||
## Paging
|
||||
|
||||
Human paging: `/p2` after `/mail list`.
|
||||
|
||||
Machine paging: `/43 p2` with a new id each page.
|
||||
|
||||
The node caches your last successful reply for about five minutes (reset on each page read). A new successful command replaces the cache. Errors do not change it. Payloads larger than 8 KiB are not cached. Page 1 is sent and `/p2` replies `No cached reply.`. Past the end: `No such page.`
|
||||
@@ -0,0 +1,85 @@
|
||||
# LoBBS Plugin Author's Guide
|
||||
|
||||
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
|
||||
|
||||
How LoBBS plugins fit together, how to add hooks of your own, and checklists for a new app. Hook signatures, paging, storage, and stack limits are in [LoBBS internals](lobbs-internals.md).
|
||||
|
||||
## How plugins fit together
|
||||
|
||||
LoBBS core does not know what mail, news, or the wall are. It peels the request id and the verb, fires `slash_cmd`, and renders whatever result set comes back. Every command, including `/login` and `/help`, is a plugin that subscribed to hooks.
|
||||
|
||||
- Adding or removing an app is one line in `LoBBSWireup.cpp`. Nothing else changes, because nothing else calls it.
|
||||
- Every handler runs and none can claim a hook. A new plugin can add to what LoBBS does, but it cannot quietly switch another one off. What the node does is the sum of what is installed, in priority order.
|
||||
- Plugins never call each other. They meet at named hooks and pass LoScalar records. Remove a plugin and the ones listening to it keep working. Their handlers for its hooks are just never called.
|
||||
- Data crosses the bus as LoScalar fields, not C++ types. A handler that only reads and writes numbered fields could later be a script instead of compiled code.
|
||||
|
||||
## Define your own hooks
|
||||
|
||||
The hooks in the internals table are the ones core and the bundled apps fire. They are not a closed set. A hook is only a name, so your app can fire its own and any other plugin can subscribe without including your headers.
|
||||
|
||||
Fire an action where something happened that others might react to:
|
||||
|
||||
```cpp
|
||||
LoScalar args;
|
||||
args.setUint64(LODB_F_ID, mailUuid);
|
||||
args.setString(LODB_F_TITLE, toUsername);
|
||||
lobbsDoAction("mail_sent", ctx, args);
|
||||
```
|
||||
|
||||
Apply a filter where others might want to change a value before you use it. You seed the default, and the value you read back is what every handler agreed on:
|
||||
|
||||
```cpp
|
||||
LoScalar post;
|
||||
post.setString(LODB_F_DESCRIPTION, body);
|
||||
lobbsApplyFilter("news_before_post", ctx, post, LoScalar());
|
||||
post.getString(LODB_F_DESCRIPTION, body);
|
||||
```
|
||||
|
||||
Another plugin subscribes by name:
|
||||
|
||||
```cpp
|
||||
static void notifyOnMail(LoBBSCommandCtx *ctx, const LoScalar &args)
|
||||
{
|
||||
// args carries the mail id and recipient
|
||||
}
|
||||
|
||||
lobbsAddAction("mail_sent", notifyOnMail, LOBBS_HOOK_PRIORITY_FEATURE);
|
||||
```
|
||||
|
||||
These two hooks are examples. The bundled apps do not fire them.
|
||||
|
||||
Rules for a hook you own:
|
||||
|
||||
1. Prefix the name with your app (`mail_`, `wall_`). Unprefixed names are the shared hooks in the internals table.
|
||||
2. Pass a string literal. The bus keeps the pointer.
|
||||
3. One kind per name. A handler registered with a different kind is logged and skipped.
|
||||
4. Use title (`98`) and description (`97`) for the obvious text. Give other args fields numbers in `0..93`.
|
||||
5. Fire with the `ctx` of the command you are handling, so subscribers see the same sender and session.
|
||||
6. Document it in the same change: a row in the hook table in `LoBBSHooks.h` and in [internals](lobbs-internals.md#hook-bus) with name, kind, initial value, and args.
|
||||
|
||||
## New command app
|
||||
|
||||
1. In `lobbsXRegisterCommands()`, register `slash_cmd`, `help_topics`, and `help_for_topic`. Add `status_lines`, `display_human`, or `seed` when needed.
|
||||
2. One `LoBBSVerb` table drives `lobbsDispatchSub` and `lobbsHelpForTable`. Gate with `LOBBS_V_LOGIN` and `LOBBS_V_SYSOP`.
|
||||
3. In `slash_cmd`, return quietly unless `lobbsSlashVerbIs` matches your verb.
|
||||
4. Build a `LoBBSResponse` and call `lobbsCommandReplyResponse`. Do not page or parse `pN`.
|
||||
5. Talk only through `ctx`, `args`, and the filter value. Do not call another plugin. Do not assume you run first or alone.
|
||||
6. Include `LoBBSStackGuard.h` last in every `.cpp`. Keep functions under 512 bytes of stack on nRF52.
|
||||
7. Add one `lobbsXRegisterCommands()` call in `LoBBSWireup.cpp`.
|
||||
8. Do not `#include` `AuthDal.h` from feature code. Use the session on `ctx` and `AppUtil`.
|
||||
|
||||
## Runtime settings
|
||||
|
||||
1. Register a `config_keys` list filter and call `lobbsConfigPushKey` for each uint32 setting (default, min, max, help text shown by `/help config key`).
|
||||
2. Read values with `lobbsConfigGet(ctx, "your.key")` in command handlers before calling your DAL.
|
||||
3. Optional: register `config_validate` at a priority after `LOBBS_HOOK_PRIORITY_HELP` for cross-field checks.
|
||||
4. Register `config_changed` to apply side effects (resize buffers, refresh caches).
|
||||
|
||||
Mail is the reference implementation for subcommands and help tables.
|
||||
|
||||
## New record type
|
||||
|
||||
1. Pick a table name. App fields use `0..93`. Body in field `97`, short name in `98`.
|
||||
2. Do not set field `99`. Leave `95` and `96` unset on insert. `removeField` them before `update` when you want LoDB to stamp times.
|
||||
3. `registerTable` at app construction. Prefer `LoDb::upsert` for keyed or singleton rows.
|
||||
4. Build uuids with `lodb_new_uuid`. Do not print `uint64_t` with `%llu` on nRF52. Use `loU64ToDec` from `loutil/LoUtil.h`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# LoBBS SysOp Guide
|
||||
|
||||
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
|
||||
|
||||
If you run a LoBBS node, you are the System Operator (SysOp) and this guide is for you.
|
||||
|
||||
Normal player commands are in the [daily user guide](lobbs-daily.md).
|
||||
|
||||
## Install
|
||||
|
||||
[Install on MeshForge](https://meshforge.org/MeshEnvy/lobbs-meshtastic-firmware) or compile and flash from source.
|
||||
|
||||
A new LoBBS node has no database until you install it. Only `/help` and `/install` work until then. The install hint lists the mounts this hardware can hold a database on:
|
||||
|
||||
| Hardware | Install mounts |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| nRF52840 | `sd`, `extra`, `flash2` (100 KB set aside for LoBBS) |
|
||||
| ESP32 and other hardware | `sd`, `extra`, `flash` |
|
||||
|
||||
Mounts are listed in preference order. `sd` appears when a card is present. `extra` (about 2 MB) appears on Seeed XIAO nRF52840 kit and SenseCAP Solar Node builds (`LOBBS_EXTRA_QSPI=1`). On nRF52840 `/flash` is Meshtastic's own 28 KB and is not offered.
|
||||
|
||||
`/install <mount> <user> <pass>` creates the database on that mount and makes `<user>` the SysOp. Use it from the local client (phone app connected over serial/BLE) or from a remote DM encrypted with a configured admin key. If the target already holds a LoBBS database, the same command adopts it and you must supply an existing SysOp username and password. Once installed, `/install` replies `Already installed at /<mount>.`
|
||||
|
||||
Everything LoBBS stores lives under `/<mount>/lobbs`:
|
||||
|
||||
| Path | Contents |
|
||||
| ----------------------------- | ---------------------------------------------------- |
|
||||
| `/<mount>/lobbs/install.ls` | Install record. Its presence marks the install |
|
||||
| `/<mount>/lobbs/db/` | The database (users, mail, news, wall, yarn, config) |
|
||||
| `/<mount>/lobbs/apps/<app>/` | Reserved for per-app files |
|
||||
| `/<mount>/lobbs/home/<user>/` | Reserved for user files |
|
||||
|
||||
On boot LoBBS checks the install mounts in preference order and uses the first one holding `lobbs/install.ls`. Nothing is stored on `/flash`, so a Meshtastic wipe of `/flash` does not lose the install. Pulling the SD card that holds it makes the node look new until the card is back.
|
||||
|
||||
## Demo builds
|
||||
|
||||
Add `-D LOBBS_DEMO_MODE` to `build_flags` (or `PLATFORMIO_BUILD_FLAGS="-D LOBBS_DEMO_MODE" pio run -e <env>`) to wipe `/<mount>/lobbs` on the first install mount every boot and seed demo data. Accounts: `sysop` and `demo01` through `demo12`, password `demo1`. Sample mail, news, wall paint, and yarn text are included for paging tests.
|
||||
|
||||
## SysOp commands
|
||||
|
||||
SysOp-only lines reply `SysOp only.` to everyone else.
|
||||
|
||||
Config keys (uint32, defaults in parentheses): `session.max` (16), `session.idle` (86400 s), `wall.period` (3600), `wall.cells` (1), `yarn.period` (3600), `yarn.words` (1), `yarn.chars` (32), `pager.ttl` (300), `password.min` (5). Run `/config` with no args to list current values and ranges. `/help config key` explains one setting.
|
||||
|
||||
| Command | Notes |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/install mount user pass` | First-time setup. Blank node only; adopts an existing database with SysOp credentials |
|
||||
| `/passwd user new confirm` | Reset a user's password |
|
||||
| `/users kick user` | Log that user out |
|
||||
| `/config` | List settings. `/config key`, `/config key value`, `/config key reset` (SysOp only) |
|
||||
| `/users promote user` | Make them a SysOp |
|
||||
| `/users demote user` | Remove SysOp (not the last SysOp) |
|
||||
| `/mail list user` | Their inbox |
|
||||
| `/mail read user N` | Read their message (does not mark it read) |
|
||||
| `/news delete N` | Delete a news item |
|
||||
| `/time unix` | Set the clock. The time must be after this firmware was built, or the reply is `Invalid time.` |
|
||||
| `/cd [path]` | Set your working directory (default `/`). Each login keeps its own, reset on re-login/reboot |
|
||||
| `/pwd` | Show your working directory |
|
||||
| `/ls [path]` | List a directory, default the working directory (`*` glob on the last name only). Directories end in `/`, files show a size like `9B` or `1.2K` |
|
||||
| `/cat path` | Read a file as text |
|
||||
| `/hex path` | Hex dump a file |
|
||||
| `/rm /path` | Delete a file (absolute path only) |
|
||||
| `/rmdir /path` | Remove an empty directory (absolute path only) |
|
||||
| `/rmtree /path /path` | Recursive delete (absolute path, typed twice) |
|
||||
| `/mkdir path` | Create a directory under a mount (`/flash/...`, etc.) |
|
||||
| `/cp src dst` | Copy a file (no overwrite; directories refused) |
|
||||
| `/mv src dst` | Rename on one mount, or copy then delete across mounts (files only) |
|
||||
| `/upload path [offset:b62]` | Chunked file write at byte offset, or show current size |
|
||||
| `/commit src dst` | Move when file CRC32 matches the 8-hex segment in the source name |
|
||||
| `/stat path` | `file N` or `dir` |
|
||||
| `/df` | One line per mount, e.g. `extra: 1.8M free of 2.0M (12% used) [lobbs]`. Free space excludes the `/flash` reserve. `[lobbs]` marks the install mount |
|
||||
| `/format mount [code]` | Erase a mount. The first call replies with a code; repeat with the code within 2 minutes from the same node. On `/flash` the radio settings are saved again from memory (BLE pairings are lost). On the install mount LoBBS goes blank until `/install`. `sd` is refused (format cards on a PC) |
|
||||
|
||||
SysOps painting the wall or adding yarn do not use quota.
|
||||
|
||||
## Files
|
||||
|
||||
Paths use mount prefixes: `/flash/...`, `/flash2/...` on nRF52840, `/sd/...`, `/extra/...` on builds with `LOBBS_EXTRA_QSPI=1` and a QSPI flash chip. `/` lists mounts. Do not `/rmtree` `<mount>/lobbs` on your install mount unless you intend to wipe the BBS.
|
||||
|
||||
`/flash` is shared with Meshtastic's own settings and node database. LoBBS leaves a reserve free there (16 KB on nRF52, 128 KB elsewhere). Posts, mail, uploads, and copies that would cut into it reply `Disk full.`. Deleting files always works. `/flash2`, `/sd` and `/extra` have no reserve.
|
||||
|
||||
Relative paths work under your session working directory for `ls`, `cat`, `hex`, `stat`, `mkdir`, `cp`, `mv`, `upload`, and `commit`. `rm`, `rmdir`, and `rmtree` require absolute paths.
|
||||
|
||||
## Chunked upload
|
||||
|
||||
`/upload path offset:b62` writes base62-decoded bytes at `offset`. `/upload path` with no offset replies with the current size. `/commit src dst` verifies CRC32 from the source filename before replacing the destination.
|
||||
|
||||
From the firmware repo root:
|
||||
|
||||
```bash
|
||||
python src/modules/LoBBS/bin/lobbs_chunkify.py local.txt /flash/path/dest.txt
|
||||
```
|
||||
|
||||
The script prints `/mkdir`, `/upload`, and `/commit` lines. Temp files use `<name>.<crc8>.tmp`.
|
||||
Reference in new issue
Block a user