Files
MeshBot/CHANGELOG.md
T
2026-09-25 14:14:37 +00:00

201 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
## v0.14.0 — 2026-09-25 14:11
- The standalone Python notify forwarder (`ops/forwarder`, compose service `forwarder`) is gone: its logic now lives in-process as a new `src/opencode` module — an `@Injectable()` `OpencodeService` registered in `MeshModule` and injected into `MeshService`. When an RX text lands on channel `MESH_NOTIFY_CHANNEL` (default 3), `MeshService.notifyRx()` calls `void this.opencode.notify(text, from)` instead of the old webhook POST; the channel gate and self-echo filter stay in `MeshService`, everything else (workspace/session resolution, message POST, retries) moved into the service.
- Ported forwarder behavior 1:1 with new env names: `OPENCODE_API` (default `http://172.24.0.1:9898` — opencode binds 0.0.0.0 on the host and is reachable from the bot container over dev_net; the old forwarder used 127.0.0.1 only because it ran with `network_mode: host`), `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), `OPENCODE_SESSION` (empty = discover the most recently updated session per request; non-empty = pin that session and skip discovery), `OPENCODE_USER` / `OPENCODE_PASS` (HTTP Basic auth on every call). Missing/empty credentials disable the notifier instead of failing: one warn at construction, `notify()` is a no-op — the app keeps running.
- Workspace/session resolution per request (no caching — messages are rare): `GET {api}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH`, fallback name `mesh-bot`, status `ready` preferred when several match; then, unless pinned, `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry when none carry it). The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node <n>]: "<text>" — please react to it.` (em-dash, not a hyphen; the `from node` part omitted when there is no sender).
- Timeout semantics preserved: every call has a 15 s timeout (`AbortSignal.timeout(15000)`); a read timeout on the final message POST means DELIVERED — opencode accepts immediately but holds the response until the agent turn finishes — so it is treated as success and never retried. Workspace/session GET failures are ordinary retryable failures: backoff [2 s, 10 s, 30 s] (the constant moved here from `mesh.service.ts`), up to 4 attempts total, final failure logs `opencode notify dropped after N attempts`.
- `MeshService` loses `notifyUrl`, `postNotify()` and `NOTIFY_RETRY_DELAYS_MS`; the constructor gains an optional second parameter (`db?, opencode?`) so all existing direct constructions in specs keep working — without a notifier wired, `notifyRx()` is a no-op.
- `docker-compose.yml`: forwarder service block deleted; bot environment drops `MESH_NOTIFY_URL`, keeps `MESH_NOTIFY_CHANNEL`, and passes the five `OPENCODE_*` vars via `${VAR:-default}` interpolation from `.env`. Local `.env` (gitignored) renamed `FORWARDER_USER/PASS/SESSION` → `OPENCODE_USER/PASS/SESSION` (values unchanged, session pin kept), added `OPENCODE_API=http://172.24.0.1:9898`, dropped the `MESH_NOTIFY_URL=` line.
- Tests: new `src/opencode/opencode.service.spec.ts` (12 specs — exact wrapper text with/without `from` incl. em-dash and Basic auth header, workspace path match / name fallback / ready preference, session pin vs discovery-by-max-time.updated vs first-entry fallback, POST timeout = delivered success with no retry, GET failure retried then succeeds, give up after 4 attempts with the dropped log, disabled-when-no-creds no-op + construction warn). `mesh.service.spec.ts` notify block rewritten around a mocked `OpencodeService`: channel gate (default 3), off-channel skip, injected channel-0 case incl. `from` passthrough as undefined, self-echo skip for rx and injected; the ten webhook-specific tests (URL/body/retry/HTTP-error/give-up) are gone — retry semantics now live in the opencode spec.
- Docs: README "Notify forwarder" section replaced by "Opencode notifier", env-var list updated (`MESH_NOTIFY_URL` bullet removed, `OPENCODE_*` documented); AGENTS.md webhook paragraph rewritten for the built-in notifier; MESHTASTIC.md notification rule updated. All FORWARDER_* mentions removed from tracked files (CHANGELOG history keeps them as-is).
## v0.13.1 — 2026-09-25 13:02
- Battery-low alerts now ride channel "testowy" (index 2) instead of the pinned send channel ("test", index 3): `BatteryAlertService` reads new env `BATTERY_ALERT_CHANNEL` at construction (default `2`, same pattern as its other env config) and passes it through the existing `MeshService.sendText(to, text, replyId?, channel?)` path — which gained an optional trailing `channel` argument defaulting to the pinned `MESH_SEND_CHANNEL`, so every other sender (API, MCP, weather) is untouched. Threshold, cooldown, merged one-message-per-cycle format and @mentions are unchanged.
- Tests: battery specs now assert the send carries channel index 2 by default plus a `BATTERY_ALERT_CHANNEL` override case; the end-to-end case asserts the alert frame rides channel 2 while the pinned send channel stays at its default 3. All pre-existing specs stay green.
## v0.13.0 — 2026-09-25 12:34
- New non-AI `src/battery` module: `BatteryAlertModule` + `BatteryAlertService`, wired into `app.module.ts` exactly like the weather module (ScheduleModule.forRoot() + MeshModule). Every `BATTERY_CHECK_INTERVAL_MS` ms (default 60000, read at construction) it scans the same node state that backs `GET /api/nodes` and collects every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20); when any qualify it sends AT MOST ONE broadcast per cycle containing all of them — never one message per node, and nothing at all when none qualify. The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice.
- Alert format: `⚠️ battery low: @!<8-hex-lowercase-node-num> (<longName or shortName>) <X>%` with additional nodes appended as `, @!<hex> (<name>) <Y>%` (the single-node case is the same template with one entry) — Meshtastic mention syntax (`@!` + zero-padded 8-char lowercase hex, e.g. `@!09a2b3c4`), entries kept tight so common cases stay near the ~190-char single-packet limit; the name part falls back longName → shortName and is omitted entirely when a node reported no name. Broadcasts ride the pinned send channel via the existing `MeshService.sendText(BROADCAST_ADDR, text)` path (MESH_SEND_CHANNEL, default index 3) — no new transport.
- Anti-spam: per-node cooldown of `BATTERY_ALERT_COOLDOWN_MS` ms (default 21600000 = 6 h) in an in-memory `Map<nodeNum, lastAlertEpochMs>` — only nodes outside their window are included in a batch and the alert timestamp is recorded for every node in it after sending, so a node is alerted at most once per window; the map resets on process restart by design (a node may get one duplicate alert after a deploy).
- Live battery updates: `batteryLevel` was previously only populated from NodeInfo frames during the initial sync. Live NODEINFO_APP packets (port 4, serialized meshtastic.NodeInfo in Data.payload) now update `deviceMetrics` in the node database too — new `decodeNodeInfo()` + `PORT_NODEINFO_APP` in codec.ts, one branch in the existing packet-handling path — so alerts and `/api/nodes` reflect current values between syncs. Unknown nodes get a stub entry when they report metrics live; malformed payloads are skipped like every other port.
- Tests: new `src/battery/battery.service.spec.ts` (mention hex padding/lowercase, batched message construction incl. multi-node append, name fallbacks and the 190-char ceiling, threshold boundary with default + custom env, no-battery skip, all qualifying nodes merged into exactly one broadcast, send-failure never throws, cooldown suppression within window + re-alert after expiry via fake timers, in-cooldown node excluded from a batch while an out-of-window node still triggers the single message) plus a DI spec mirroring weather's; an end-to-end case drives a real MeshService over the fake bridge and asserts the alert frame rides channel index 3 with the exact text. `mesh.service.spec.ts` gains a NODEINFO_APP live-update case (stale sync battery refreshed, other fields preserved, unknown-node stub). All pre-existing specs stay green.
## v0.12.8 — 2026-09-25 12:04
- Docs (MESHTASTIC.md response protocol): new standing rule per owner request ("remember to always replay on reply on channel when question is send here") — questions received via mesh are **answered on the channel**: every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session. All existing bullets (always-respond-first, channel-only/no DMs, owner authority 2594370888, language, NO_RESPONSE quirk, self-echo note) unchanged. No code changes — all 64 tests untouched and green.
## v0.12.7 — 2026-09-25 11:40
- `POST /api/send` and the MCP `send_message` tool now accept an optional `replyId` (integer in 1..0xffffffff) referencing an earlier message's packet id from `GET /api/messages` / `get_messages`. When set, the recipient app renders the sent text as a threaded reply/quote of that packet. The value rides Meshtastic's protocol-level reply mechanism: `Data.reply_id` (field 7) on the TEXT_MESSAGE_APP payload.
- `buildTextToRadio(to, text, channelIndex, replyId?)` in `src/mesh/codec.ts` gained the optional param and sets **camelCase** `replyId` on the Data value (`replyId ?? 0`). The JS protobufs package silently drops snake_case `reply_id`, so camelCase is mandatory; a byte-level spec asserts wire tag `0x3d` (field 7, fixed32) appears iff `replyId` was given.
- `MeshService.sendText(to, text, replyId?)` passes the value through to the codec. REST validation rejects non-integers and out-of-range values with a 400; MCP validates via zod (`z.number().int().min(1).max(0xffffffff)`). Omitting it leaves the field unset (proto3 default 0, not serialized) — existing callers are unaffected.
- New specs: codec byte-level `0x3d` presence/absence check and a service outbound-capture case asserting `Data.replyId === <id>` when given and `0` when omitted. All 62 pre-existing tests stay green (64 total).
- Docs: README endpoint + MCP tool lines, AGENTS.md API section, and the controller's `@ApiOperation` description now document the optional `replyId`.
## v0.12.6 — 2026-09-25 10:55
- Docs (MESHTASTIC.md response protocol): reverted the v0.12.5 reply rule per owner preference ("we will talk on that channel") — replies to messages from a specific node now go back **on the same channel as a broadcast** (no `to`), no direct messages; broadcasts are used for both replies and general announcements such as the hourly weather report. The always-respond-first bullet (**ALWAYS respond ... BEFORE starting any work it triggers**) and every other rule are unchanged. No code changes — `POST /api/send` and the MCP send tool already accept an omitted `to` (broadcast).
## v0.12.5 — 2026-09-25 10:45
- Docs (MESHTASTIC.md response protocol): replies to messages from a specific node now go back **as a direct message to that sender's node number** (`to` = the sender's node num), so they surface in the direct conversation thread ("flagged as reply") instead of as new channel posts. Broadcasts (no `to`) are reserved for general announcements such as the hourly weather report. No code changes — `POST /api/send` and the MCP send tool already accept a `to` target; all other rules (owner authority 2594370888, language, confirm-first-then-work, channel pinning, self-echo note, NO_RESPONSE quirk) unchanged.
- MESHTASTIC.md confirm-first rule made explicit and mandatory: **ALWAYS respond to a received mesh message on the Meshtastic BEFORE starting any work it triggers** (acknowledge first, then work) — the previous "first respond/confirm, then start the actual work" wording was not explicit enough and got violated in practice.
## v0.12.4 — 2026-09-25 10:17
- Notify forwarder (`ops/forwarder`) now resolves the opencode workspace + session dynamically per request instead of hardcoding them as env defaults: `GET {FORWARDER_API}/api/workspaces` → match by `path == FORWARDER_WORKSPACE_PATH` (fallback `name == "mesh-bot"`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. Fixes lost user messages after an opencode host restart regenerated the workspace UUID (the old hardcoded base URL 404'd on every notify POST).
- New env config replaces `FORWARDER_BASE`: `FORWARDER_API` (default `http://127.0.0.1:9898`) and `FORWARDER_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`); `FORWARDER_SESSION` is now an optional pin — when set, session discovery is skipped; empty/unset discovers dynamically. `FORWARDER_USER` / `FORWARDER_PASS` stay required. Each failure mode gets a clear 502 + log line: API unreachable, workspace not found, no sessions in workspace. The message POST itself (endpoint under `{proxyPath}/session/{sid}`, body and auth), timeout=15 semantics, 202-on-timeout and the `_reply` BrokenPipe guard are unchanged.
- `docker-compose.yml`: forwarder environment block now passes `FORWARDER_API` / `FORWARDER_WORKSPACE_PATH` / `FORWARDER_SESSION` (empty default) — old `FORWARDER_BASE` line removed; bot service untouched.
- `MeshService.postNotify()`: after `await fetch(...)`, checks `res.ok` and throws `Error('HTTP <status>')` when not ok, so a forwarder 5xx during an opencode restart is retried with the same backoff as network failures (the `res &&` guard keeps specs that stub fetch to resolve `undefined` green). One new spec: fetch resolves status 502 twice then 200 → exactly 3 attempts, success path. All 61 pre-existing tests stay green (62 total).
- Docs: README "Notify forwarder" section rewritten for dynamic resolution + the new env vars; AGENTS.md webhook note gains a clause that the forwarder resolves the mesh-bot workspace/session from the opencode API per request (`FORWARDER_SESSION` pins a session when set).
## v0.12.3 — 2026-09-25 09:40
- Notify webhook POSTs are now retried instead of dropped on the first failure: `notifyRx`'s fire-and-forget POST runs through a retry loop with up to 4 attempts total (initial + 3 retries) and backoff delays `[2 s, 10 s, 30 s]` (~45 s window). Each failed attempt logs WARN with the attempt number and error; after the final failure it logs WARN `notify dropped after N attempts: ...` and stops. Success on any attempt ends the loop (unchanged success behavior/logging). Fire-and-forget semantics preserved — the RX handler never awaits the loop, which runs as an un-awaited async IIFE exactly like today's single POST. Self-echo filter and channel-match logic unchanged; backoff is hardcoded (no new env vars).
- New private `MeshService.postNotify()` retry helper + injectable `sleep()` so specs can skip the delays without fake timers. Two new specs: fetch fails twice then succeeds → exactly 3 attempts, no dropped log, success path taken; fetch always fails → exactly 4 attempts, final WARN `notify dropped after 4 attempts`, nothing thrown. All 59 pre-existing tests stay green (61 total).
- Docs: README `MESH_NOTIFY_URL` bullet and AGENTS.md webhook note now state failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
## v0.12.2 — 2026-09-25 08:44
- New compose service `forwarder` (`ops/forwarder`, `python:3.12-slim`, stdlib only, no pip installs): runs the notify forwarder that was previously a host-side `nohup` python script — receives the bot's outbound notify webhook POSTs (`{text, from?}` on `MESH_NOTIFY_URL`) and forwards each one as a message to an opencode API session (Basic auth).
- `network_mode: host` → listens on the host's `0.0.0.0:4097`, so `MESH_NOTIFY_URL=http://172.24.0.1:4097/notify` keeps working unchanged from the bot container, and it reaches opencode at `127.0.0.1:9898`. No `networks:` key (host mode), no Traefik labels; `restart: unless-stopped`.
- Config moves out of the script into compose environment from `.env` (gitignored): `FORWARDER_BASE` / `FORWARDER_SESSION` keep their current values as defaults, `FORWARDER_USER` / `FORWARDER_PASS` are required — the container exits on startup if either is missing. The real credentials are no longer hardcoded anywhere in the repo; nothing sensitive committed.
- No app code changes — existing 59 tests stay green.
## v0.12.1 — 2026-09-25 07:57
- Hotfix for the v0.12.0 bootstrap crash (`UnknownDependenciesException`): `MeshModule` now exports `MeshService`, and `WeatherService`'s `import type { MeshService }` became a value import — with `emitDecoratorMetadata` the type-only import is erased, so the constructor's DI token was `undefined` at runtime even with the export (the unit suite instantiates services directly and never booted the module graph, which is why 58 green tests still shipped a broken app).
- `docker-compose.yml`: `WEATHER_CRON` passthrough (`${WEATHER_CRON:-}`) mirroring the existing notify-var pattern — empty/unset falls back to the service default `'0 * * * *'`. No LAT/LON/CITY passthroughs (service-side defaults suffice).
- New DI smoke spec `src/weather/di.spec.ts`: compiles `WeatherModule` with Nest's `Test.createTestingModule().compile()` (no `app.init()`, so no TCP connection) and asserts `WeatherService` resolves its `MeshService` from `MeshModule` — fails if the export regresses.
## v0.12.0 — 2026-09-25 07:42
- New `src/weather` module: hourly cron-triggered weather report for Lublin, broadcast on the mesh (pinned send channel). `WeatherService.fetchWeather()` GETs Open-Meteo current conditions (`temperature_2m`, `apparent_temperature`, `relative_humidity_2m`, `wind_speed_10m`, `weather_code` — free API, no key) via global fetch; `formatReport()` renders one short line (e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`) with a WMO weather-code → description map (~19 common codes, unknown → `code <n>`). The `@Cron` handler fetches, formats and broadcasts via `MeshService.sendText(BROADCAST_ADDR, report)`; any error logs `[weather] report failed: ...` and is swallowed — the cron never crashes the app.
- Env config with defaults (read at construction): `WEATHER_CRON` (`'0 * * * *'`), `WEATHER_LAT` (51.2465), `WEATHER_LON` (22.5684), `WEATHER_CITY` ("Lublin").
- New dependency: `@nestjs/schedule` (ScheduleModule.forRoot() in WeatherModule, registered in AppModule). No new REST endpoints — scheduled job only; send path, persistence, redaction, MCP and webhook logic untouched.
- Tests: four new specs in `src/weather/weather.service.spec.ts` — exact formatted report from a sample payload; unknown WMO code → `code <n>` fallback; cron success path (stubbed fetch + MeshService mock) → `sendText(BROADCAST_ADDR, report)` called once with the Open-Meteo URL asserted; fetch failure → no send, no throw. All 54 pre-existing specs stay green (58 total).
- Docs: README "Automated weather reports" section, AGENTS.md bullet in "Other".
## v0.11.3 — 2026-09-25 07:28
- Added MESHTASTIC.md communication-rules file; referenced from AGENTS.md.
## v0.11.2 — 2026-09-25 06:41
- Self-echo filter in the notify webhook: `notifyRx` now skips the POST (with a single debug log line) when an explicit `from` equals our own node number (`myNodeNum`, set from FromRadio myInfo during initial sync) — our own sends must never trigger notifications of ourselves. Injections without `from` still notify; only an explicit `from` matching our node is filtered.
- Tests: two new specs in the existing `MeshService notify webhook` block — rx text on the notify channel from our own node → no fetch; injected message with `from` = our own node → no fetch (own node number set via a myInfo frame, like the real initial sync). All 52 pre-existing specs stay green (54 total).
- Docs: README env-var note + AGENTS.md webhook line ("messages from our own node are never notified").
## v0.11.1 — 2026-09-25 01:49
- Injected messages now fire the outbound notify webhook too: `injectMessage` calls the same extracted private helper (`notifyRx(channel, text, from?)`) as the bridge RX path (which lost its inline block), with channel 0 — so a synthetic rx record triggers the webhook only when `MESH_NOTIFY_CHANNEL` is set to `0`; no forced semantics change, default stays `3`.
- `docker-compose.yml`: passthrough for `MESH_NOTIFY_URL` / `MESH_NOTIFY_CHANNEL` via `${VAR:-default}` interpolation from `.env` (URL empty = disabled, channel default 3).
- Docs: README env-var note + AGENTS.md webhook line.
- Tests: two new specs in the existing `MeshService notify webhook` block — inject with `from` → exactly one POST `{text, from}`; without → body omits `from`. All 50 pre-existing specs stay green (52 total).
## v0.11.0 — 2026-09-25 01:30
- Outbound notification webhook (#17): when an inbound RX `TEXT_MESSAGE_APP` packet arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`, read at construction exactly like `MESH_SEND_CHANNEL`), the service fires a fire-and-forget HTTP POST to `MESH_NOTIFY_URL` with JSON body `{text, from}` (`from` omitted when absent) — no retries, no queue, no response handling; failures only log a warn. Unset or empty `MESH_NOTIFY_URL` disables the feature entirely (no fetch at all). Uses Node 22's global `fetch` — zero new dependencies.
- Tests: three new specs in a `MeshService notify webhook` block (global fetch stubbed via `vi.stubGlobal`): rx text on channel 3 → exactly one POST with the URL, `content-type: application/json`, and body `{text, from}`; rx text on another channel → captured in history but no call; `MESH_NOTIFY_URL` unset or empty → no call. All 47 pre-existing specs stay green (50 total).
- Docs: README env-var list (+2 vars), AGENTS.md API/env note.
## v0.10.0 — 2026-09-25 00:30
- Message history is now persisted to SQLite via the stdlib `node:sqlite` `DatabaseSync` (zero new dependencies). DB file path from env `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created); poller interval from env `MESH_POLL_MS` (default `1000` ms — "frequent pooling, configurable"). Schema: one table `messages(id INTEGER PRIMARY KEY, dir TEXT NOT NULL, to_node INTEGER, from_node INTEGER, text TEXT NOT NULL, channel INTEGER NOT NULL, at INTEGER NOT NULL, status TEXT, ack_error TEXT)`, keyed by packet id.
- Write path (the "pooling"): a `setInterval` poller started in `onModuleInit` re-upserts the ENTIRE current in-memory history buffer (≤100 rows) every tick with `INSERT OR REPLACE INTO messages ...`, then enforces retention with one statement keeping only the newest 1000 rows. Deliberately zero bookkeeping — trivial load at this scale.
- Read path: on startup (before the bridge connect) the service seeds the history buffer from the database (`SELECT ... ORDER BY at DESC LIMIT 100`), so message history SURVIVES PROCESS RESTARTS. On bridge reconnect the node/channel/device/telemetry capture state resets exactly as before, but the message history is no longer cleared — it's now an append-only log backed by SQLite (tx records that were still `pending` at disconnect stay pending).
- Testability: `MeshService` takes an optional `DatabaseSync` constructor argument (`null` = persistence disabled) so all 42 pre-existing specs construct without a database and touch no disk; new specs use `new DatabaseSync(':memory:')`. `flushHistory()` is public so tests can drive a poller tick without real timers.
- Production wiring: `MeshModule` now provides `MeshService` through a factory that opens the file DB from env (mkdir -p on the parent dir + idempotent table init). `docker-compose.yml` adds a named volume (`mesh_data:/app/data`, matching the Dockerfile WORKDIR `/app`) so the DB survives container recreation, and passes through `MESH_DB_PATH`/`MESH_POLL_MS`.
- Docs: README (new env vars + persistence note), AGENTS.md endpoint list, Swagger description of `GET /api/messages`, and the MCP `get_messages` tool description all updated from "in-memory only, reset on reconnect" to the SQLite-backed wording.
- Tests: five new specs in a `MeshService SQLite persistence` block — poller tick persists an injected row without any manual flush; an ACK status mutation reaches the DB after the next tick; startup seeding loads the newest 100 of 150 pre-populated rows into `messages()`; retention prunes a 1200-row table to the newest 1000; and history survives a bridge reconnect (unlike channels/nodes/telemetry).
## v0.9.0 — 2026-09-24 23:45
- New `POST /api/messages` endpoint (and matching MCP tool `inject_message`, now nine tools): synthetic inbox injection for external triggers. Body `{ "text": "...", "from"?: <node number> }` records an rx entry in the message history as if received on the mesh (channel 0), so external systems can push messages into the bot's inbox to trigger communication later. NOT transmitted over RF — nothing is written to the bridge or air; the record appears in `GET /api/messages` like any other rx message and is subject to the same last-100 cap. Returns `{ id }` with a random uint32 packet id (same generation as tx sends).
- New service method `MeshService.injectMessage(text, from?)`: pushes an rx record through the existing `pushHistory` cap pattern; `from` is optional and omitted from the record when absent (integer in 1..0xffffffff when provided — invalid values throw like `resolveDest`). Packet-id generation extracted from `buildTextToRadio` into a shared `randomPacketId()` helper so tx sends and injected records use one source of truth.
- Validation: empty/missing text → 400 `text must be a non-empty string`; invalid `from` (non-integer, <1 or >0xffffffff) → 400 `invalid node number '<n>'`. Same messages surface as MCP tool errors for `inject_message`.
- Swagger: `POST /api/messages` documented with the synthetic-injection caveat and an example. README + AGENTS.md endpoint/tool lists updated.
- Tests: new service specs (injected rx records newest-first via `messages()`, `from` omitted when absent, empty-text/invalid-from rejection, cap at 100 dropping the oldest, shared buffer with captured packets); MCP spec updated to nine tools incl. an `inject_message` call check asserting nothing reaches the bridge socket (no ToRadio 'packet' variant on the wire).
## v0.8.0 — 2026-09-24 21:40
- New `GET /api/telemetry` endpoint (and matching MCP tool `get_telemetry`, now eight tools): the telemetry stream captured from live `TELEMETRY_APP` packets (portnum 67) — device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them. Records are `{ from, at /* epoch ms */, kind, data }[]` newest first: `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` is that variant's payload. In-memory only, last 100 (same bounded pattern as message history), reset on every bridge reconnect.
- `MeshService.telemetry()` captures the new portnum in the existing packet handler: the `Data.payload` is decoded as a `meshtastic.Telemetry`, and packets without a variant payload are ignored. The variant payload goes through the same module-scope `jsonSafe` converter added in v0.7.1 — telemetry variants carry proto `uint64` fields (e.g. host-metrics byte counters) that decode to native bigint and would 500 Express' `res.json` otherwise, so they come back as decimal strings; unset optional fields are omitted from `data`.
- Verified against the installed `@meshtastic/protobufs` 2.8.0: `PortNum.TELEMETRY_APP = 67`; `Telemetry` is a `fixed32 time` plus oneof variant with exactly those eight cases; `DeviceMetrics` fields are batteryLevel/voltage/channelUtilization/airUtilTx/uptimeSeconds and `EnvironmentMetrics` the full sensor set (temperature, relativeHumidity, barometricPressure, gasResistance, voltage, current, iaq, distance, lux variants, wind/rainfall/soil/lightning, adc channels).
- Swagger: `GET /api/telemetry` documented with the kind values and an example carrying one record per kind. README + AGENTS.md endpoint/tool lists updated.
- Tests: fake-bridge gains a shared `telemetryPacketFrame` builder; new telemetry specs (device-metrics + environment variants → correct from/kind/data, cap at 100 dropping the oldest, reset on reconnect); MCP spec updated to eight tools incl. a `get_telemetry` call check.
## v0.7.1 — 2026-09-24 21:10
- Fix `GET /api/device` and MCP `device_info` crashing with `TypeError: Do not know how to serialize a BigInt` on live hardware (fw 2.7.15): protobuf-es decodes proto `uint64` fields as native JS `bigint`, which Express' `res.json`/`JSON.stringify` reject, and `MeshService.deviceInfo()` passed raw decoded proto objects through unconverted. Known affected fields: `Config.PowerConfig.powermonEnables` (in `localConfig.power`) and host-metrics byte counters (`freememBytes`/`diskfree*`).
- Fix at the single choke point: one small recursive converter (`jsonSafe`, bigint → decimal string, plain objects/arrays/primitives pass through) applied in `deviceInfo()` to everything that comes from raw decoded proto messages — DeviceMetadata plus every localConfig and moduleConfig section. No per-field patching; any uint64 field in those messages is covered generically. Values that were bigint now serialize as strings (e.g. `"powermonEnables": "123"`); no other endpoint/shape changes, redaction logic untouched.
- Tests: new device-info spec feeds a power config section frame with `powermonEnables` set to a real bigint through the fake bridge and asserts the value comes back as its string form (`"123"`) and that `JSON.stringify(deviceInfo())` succeeds — the old fixtures never set a uint64 field, which is why this slipped past the suite.
## v0.7.0 — 2026-09-24 20:21
- New `GET /api/device` endpoint (and matching MCP tool `device_info`, now seven tools): device info captured during the initial sync — this device's node number, firmware version and metadata (`meshtastic.DeviceMetadata` as-is; it carries no key material), plus per-section config and module-config messages keyed by section name. In-memory only, reset on every bridge reconnect like channels/nodes.
- `MeshService.deviceInfo()` captures the new FromRadio cases during sync: `metadata` (DeviceMetadata), `config` (one `meshtastic.Config` per section — oneof `payloadVariant` cases device/position/power/network/display/lora/bluetooth/security/sessionkey/deviceUi) and `moduleConfig` (one ModuleConfig per module). Verified against the installed `@meshtastic/protobufs` 2.8.0: FromRadio carries `meshtastic.Config`, not `LocalConfig` (that type lives in a separate localonly namespace and is not part of FromRadio).
- **Mandatory redaction (trust boundary):** key material never leaves the process — security section `publicKey`/`privateKey`/`adminKey` and network section `wifiPsk` are replaced with `"REDACTED"` in all output.
- Node summaries (`GET /api/nodes`, `GET /api/nodes/:id`, MCP `list_nodes`/`get_node`) gain optional fields from the NodeInfo: `snr` (dB), `hopsAway`, `viaMqtt` — present only when the device reported them. Note: `meshtastic.NodeInfo` in 2.8.0 has no firmware version field, so per-node firmwareVersion is not captured (device-level firmware version comes from DeviceMetadata via `/api/device`).
- Swagger: `GET /api/device` documented with the redaction note and an example; nodes endpoint example/description extended for the new optional fields. README + AGENTS.md endpoint lists updated.
- Tests: fake-bridge gains `metadataFrame`, `configSectionFrame`, `moduleConfigSectionFrame`; new device-info specs (metadata → firmwareVersion, config/module-config sections captured with security key material absent from the JSON output), NodeInfo snr/hopsAway/viaMqtt in summaries; MCP spec updated to seven tools incl. a `device_info` call check.
## v0.6.0 — 2026-09-24 18:28
- New MCP (Model Context Protocol) server endpoint at `POST /mcp` (outside the `/api` prefix, like Scalar at `/`), served in-process via the official `@modelcontextprotocol/sdk` Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`; a fresh `McpServer` + transport per request). Six tools map 1:1 to existing `MeshService` methods with no new logic: `mesh_status`, `send_message(text, to?)` (same target resolution as `POST /api/send` — omitted/""/"!b"/"broadcast" → broadcast, numbers pass through, names via the node DB; resolution errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), and `get_messages`. Tool results are JSON in a text content block.
- New dependency: `@modelcontextprotocol/sdk` (^1.30.1; zod comes with it). Note: the SDK's root entry is broken in all published 1.x tarballs (upstream issue #2273 — the exports map points to a dist index that was never shipped), so imports use the documented subpaths (`@modelcontextprotocol/sdk/server/mcp.js`, `.../server/streamableHttp.js`, `.../inMemory.js`, `.../client/index.js`).
- Test helpers shared by both specs extracted from `mesh.service.spec.ts` into `src/mesh/fake-bridge.ts` (fake bridge, waitFor, nodeInfoFrame, KAMIL fixtures); new `mcp.controller.spec.ts` drives the MCP server over an in-memory transport pair — all six tools listed with descriptions/schemas, and mesh_status / send_message (broadcast + name resolution + error messages) / get_node / list_channels / list_nodes / get_messages return correct shapes.
- README gains an MCP section; AGENTS.md endpoint list updated for `/mcp`.
## v0.5.0 — 2026-09-24 17:37
- Publishing channel moved from `testowy` (index 2) to the new `test` channel (index 3); `MESH_SEND_CHANNEL` default is now `3`. All docs/comments referencing the old pinning updated accordingly.
## v0.4.0 — 2026-09-24 14:39
- `POST /api/send` now accepts node names and explicit broadcast sentinels in `to`: omitted/"" or `"!b"`/`"broadcast"` (case-insensitive, trimmed) → broadcast address `0xffffffff`; numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way; anything else is looked up by name via the existing node DB lookup (exact long/short name, then unique substring). Unknown or ambiguous targets are a 400 with `no node matching '<to>'`. New service method `MeshService.resolveDest(to?)` does the resolution — `sendText()` keeps its resolved-number signature.
- Swagger: `SendTextDto.to` documented as `number | string` with the full resolution order and the DM-vs-broadcast caveat (DMs are unicast wantAck-tracked via `GET /api/messages`; broadcasts stay pending because firmware strips want_ack); `@ApiOperation` notes all sends still ride channel index 2 (`testowy`) regardless of DM or broadcast.
- README + AGENTS.md endpoint lists updated for the new `to` semantics.
- Unit tests: `resolveDest` coverage on the fake bridge — omitted/""/"!b"/"BROADCAST" → broadcast, number passthrough, numeric string, exact longName match → num (node DB seeded via framed FromRadio nodeInfo), unknown name → `no node matching`, and invalid numbers (0, negative, non-integer, > 0xffffffff) rejected.
## v0.3.0 — 2026-09-24 14:08
- Message history with delivery tracking, exposed as `GET /api/messages` (newest first): inbound TEXT_MESSAGE_APP packets (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` when the routing ACK/NACK arrives — ROUTING_APP packets carry a serialized `meshtastic.Routing` in `Data.payload` whose oneof variant is `errorReason` (`Routing_Error`: 0 NONE = delivered, anything else failed with a human-readable `ackError`, e.g. NO_ROUTE/GOT_NAK/TIMEOUT, fallback `reason <n>`), matched to our sends via `Data.requestId`. Broadcasts stay `pending` because firmware strips want_ack pre-TX (documented behavior, no timer). History is a bounded in-memory buffer of the last 100 records (newest kept) and resets on every bridge reconnect alongside channels/nodes.
- Codec: new `PORT_ROUTING_APP = 5` constant (verified against the installed `@meshtastic/protobufs` PortNum enum), `decodeRouting()` for ROUTING_APP payloads, and `describeRoutingError()` which maps a Routing.Error code to its name from the installed package's `Mesh.Routing_Error` enum.
- Swagger: `GET /api/messages` documented with the pending→delivered/failed lifecycle, the broadcast caveat, and an example matching the record shape; README + AGENTS.md endpoint lists updated.
## v0.2.0 — 2026-09-24 13:08
- Node database collected during the initial sync (`FromRadio.nodeInfo` frames, upserted by node number) and kept fresh by live `POSITION_APP` packets (the Position is serialized in `Data.payload`; unknown senders get a stub entry). The map resets on every bridge reconnect alongside channels.
- New endpoints: `GET /api/nodes` — lean summaries sorted by node number as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard (epoch secs | null), position? { lat, lon, alt? } }[]`; names/hwModel/battery appear only when the device reported them, `hwModel` is the enum's string name (UNSET omitted), and `position` only when both coordinates are known. `GET /api/nodes/:id` — lookup by exact node number (numbers/numeric strings), then case-insensitive exact long/short name, else unique case-insensitive substring; 404 with a clear message when nothing or more than one node matches.
- Codec: new `PORT_POSITION_APP = 3` constant (verified against the installed `@meshtastic/protobufs` PortNum enum), `decodePosition()` for POSITION_APP payloads, and `loadHwModelNames()` reverse lookup of the HardwareModel enum.
- Swagger docs made genuinely useful: `Mesh` tag on the controller (description via `DocumentBuilder.addTag` — v12's `ApiTagOptions` has no description field) plus `@ApiOperation` summary/description and `@ApiResponse` examples on all endpoints, existing and new.
- Unit tests build real framed FromRadio bytes for nodeInfo/POSITION_APP: sync collection + summary shape (lat/lon = value × 1e-7, battery), stub entry from an unknown POSITION_APP sender, lookup ladder (exact num / numeric string / exact name / unique substring) with ambiguous and missing → null, and reconnect reset.
## v0.1.4 — 2026-09-23 20:33
- Send a `ToRadio` with `want_config_id` (random non-zero uint32 nonce) on every bridge connect, so the firmware streams its initial sync data (`myInfo`, channels, `config_complete_id`) — previously nothing was written on connect, which is why `/api/channels` stayed empty and `myNodeNum` stayed null in production. New codec helper `buildWantConfigToRadio`; unit test captures the outbound frame on the fake bridge, asserts the `wantConfigId` oneof case with a non-zero value, then feeds back `myInfo` + `configCompleteId` frames and checks service state.
## v0.1.3 — 2026-09-23 19:58
- Sends are now pinned to the test channel `testowy` (channel index 2) instead of the hardcoded primary channel (`channel: 0`). The target channel is configurable via `MESH_SEND_CHANNEL` (default `2`) and parsed once at service construction; `buildTextToRadio` takes the channel index as a parameter. Unit tests now decode the outbound ToRadio frame on the fake bridge and assert `packet.channel === 2` on the default send path.
## v0.1.2 — 2026-09-23 19:51
- Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`), mirroring flight's runner conventions: on push/PR to `main` (plus manual dispatch) one job runs `npm ci`, `nest build`, the vitest unit suite, and a production Docker image build — with retry loops for the runner's flaky uplink. No app code changed.
## v0.1.1 — 2026-09-23 19:38
- Fixed the default bridge port to `4403`, the meshtastic-serial-bridge's actual TCP port (the old value pointed at a closed port). Updated the `MESH_PORT` default in code, the docker-compose environment, and docs.
## v0.1.0 — 2026-09-23 18:54
- New `GET /api/channels` endpoint listing the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }`; psk/keys are redacted and never exposed over HTTP. Channels are collected from `FromRadio.channel` frames streamed during initial sync (before `config_complete_id`) and reset on every bridge reconnect so stale data is dropped after a disconnect.
- All endpoints moved under the `/api` namespace via `app.setGlobalPrefix("api")`: `GET /status` → `GET /api/status`, `POST /send` → `POST /api/send`.
- Scalar API docs served at `/` (OpenAPI JSON at `/docs-json`) using `@nestjs/swagger` + `@scalar/nestjs-api-reference`; added light swagger decorators (`ApiProperty` on the send body, `ApiOperation` summaries) so the docs are useful.
- Reconnect interval is now configurable via `MESH_RECONNECT_MS` (default 5000 ms).