Files
MeshBot/CHANGELOG.md
T
2026-09-30 05:47:10 +00:00

65 KiB
Raw Blame History

Changelog

v0.21.10 — 2026-09-30 05:46

  • Container now runs with TZ=Europe/Warsaw (new line in the bot service's environment: block, hardcoded like MESH_HOST/MESH_PORT): scheduled jobs and log timestamps use local Polish time instead of UTC — the weather cron (WEATHER_CRON=0 6,12,18 * * *) previously fired at 06/12/18 UTC = 08/14/20 CEST. DST-aware via the IANA zone (CEST +2 / CET +1). Deployment-config only — no application code changed; takes effect on the next docker compose up -d --build.

v0.21.9 — 2026-09-29 20:56

  • New #loser command (owner request): replies with which user/node has the worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide loss stats, so it groups our own tx history by destination and counts delivered vs failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and the broadcast addr is never a real destination even if a NACK lands on it). Loser = most failures, tie-break worse failure ratio. Node resolved to longName/shortName, falling back to the @!xxxxxxxx mention when unknown. Three reply shapes: worst delivery line (worst delivery: <name> (@!hex) - N failed / M delivered), a "no failed sends yet" line, and a "not enough data yet — only directed sends count (broadcasts have no acks)" line. Registered in the CommandService constructor next to #ping; listed in #help.
  • Tests: 175 → 180 — new #loser specs cover all three reply cases, broadcast/pending exclusion, the unknown-node @!hex fallback, and the failure-ratio tie-break; the #help spec now also asserts #loser is listed.

v0.21.8 — 2026-09-27 07:13

  • Fixed npm test exiting 1 with "Errors 2" even though all tests pass (every CI run red since v0.21.2): the two watchdog specs that force a reconnect destroy the service's socket while data is still pending in its receive buffer (the overflow spec floods 300 KB and destroys on the first ~64 KB chunk; the garbage spec destroys right after its last write), so the kernel sends RST instead of FIN and the fake bridge's accepted server-side sockets emit read ECONNRESET. Those test-owned sockets had no 'error' handler, so Node threw it as an uncaught exception that vitest counts as a run-level failure. startBridge() now attaches a no-op 'error' handler to every accepted socket (single registration point, covers all four spec files using the fake bridge). Test-only change — fake-bridge.ts is imported by specs only; no runtime diff, no deploy needed.

v0.21.7 — 2026-09-27 06:50

  • Fixed double registration of scheduled jobs: ScheduleModule.forRoot() was called from both WeatherModule and BatteryAlertModule, so Nest created two ScheduleExplorers, each scanning all providers — every @Cron/@Interval job (hourly weather broadcast, per-minute battery check) fired twice. Removed the call from battery.module.ts; the single registration in weather.module.ts remains.
  • Tests: 174 → 175 — new regression spec (src/schedule-singleton.spec.ts) asserts ScheduleModule.forRoot( appears exactly once across all src/**/*.module.ts.

v0.21.6 — 2026-09-26 10:37

  • New #help command (owner request): replies with a short list of all available commands as ONE multi-line message (single packet, not one spaced packet per line) — #ping, the #rss help/add/remove/get/sources/tags family. Registered in the CommandService constructor next to #ping.
  • Tests: 173 → 174 — #help dispatches through respond() exactly once with text containing both #ping and #rss. (Also hardened the v0.21.5 multi-send-spacing spec, which flaked under full-suite load: it now uses fake timers to assert deterministically that line 1 goes out before any time elapses and lines 2-3 land exactly one delay apart.)

v0.21.5 — 2026-09-26 10:26

  • Multi-line command replies are now spaced apart: CommandService.onRx waits MESH_MULTI_SEND_DELAY_MS (new env, default 5000 = 5 s, read at construction) between consecutive lines of an array reply — no delay before the first line. Production incident: #rss get lublin sent its items back-to-back within the same second and 3 of 4 were dropped in RF (the owner saw only the last one); each packet now gets time to land.
  • Tests: 172 → 173 — a 3-line reply with an overridden short delay asserts all lines are sent, the first goes out immediately (no pre-delay), and each subsequent line lands ≥ the configured gap after the previous one; existing specs pin MESH_MULTI_SEND_DELAY_MS=0 in beforeEach so they stay fast.

v0.21.4 — 2026-09-26 09:51

  • LLM_TIMEOUT_MS default raised from 60000 (60 s) to 180000 (3 min): production incident — #rss get lublin at 9:41 AM failed with "The operation was aborted due to timeout" because the local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens) was slow/busy on that call; the same endpoint answers a trivial prompt in ~1 s when idle, so this is latency variance, not a dead endpoint. Still env-overridable (LLM_TIMEOUT_MS), only the fallback changed.

v0.21.3 — 2026-09-26 09:31

  • Idle timeout default lowered from 10 min to 1 min (MESH_IDLE_TIMEOUT_MS fallback 600000 → 60000) — the v0.21.2 frame-based watchdog works in production (it fired today and the reconnect delivered queued packets), but the bridge/radio side at 192.168.1.4 keeps killing inbound RX within ~20 min of a fresh sync, so a shorter detection window is wanted.
  • New lazy periodic autoreconnect: MESH_RECONNECT_INTERVAL_MS (new env, default 300000 = 5 min, read at construction) — an interval that unconditionally logs ("periodic reconnect — MESH_RECONNECT_INTERVAL_MS elapsed") and destroys the socket so the existing close→scheduleReconnect loop re-establishes with a fresh sync on schedule. When not connected at tick time, destroy is a no-op and the retry loop owns recovery (no special-casing). Started in onModuleInit, cleared in onModuleDestroy.
  • Tests: 171 → 172 — periodic reconnect observed end-to-end with an overridden short interval (fake bridge disconnect + re-establish); existing watchdog tests pass unchanged, comments updated to the new 60 s default.

v0.21.2 — 2026-09-26 08:47

  • Idle watchdog now tracks DECODED FRAMES, not raw bytes (fixes a second production zombie that slipped past v0.21.1): text+telemetry RX was dead for >1 h while TX worked and the watchdog never fired — /proc/net/snmp showed InSegs delta = 2 segments per 30 s, i.e. the bridge peer keeps dribbling small NON-frame bytes (~every 15 s) that kept resetting lastPacketAt on every socket chunk even though zero valid Meshtastic frames decoded. The 'data' handler no longer updates lastPacketAt per chunk; it is updated only when extractFrames yields ≥1 valid packet (the reset in the 'connect' handler is unchanged), so a stream that decodes nothing trips the watchdog after MESH_IDLE_TIMEOUT_MS even while raw bytes keep arriving.
  • Buffer-overflow backstop: if the undecoded frame buffer exceeds MESH_MAX_FRAME_BUFFER (new env, default 256000 = 250 KB) the service logs ERROR ("frame buffer overflow — stream desync, forcing reconnect") and destroys the socket → existing close→scheduleReconnect self-heals a desynced stream. The undecoded buffer is also reset on every (re)connect so leftover garbage from a dead session can't re-trigger the backstop or corrupt the first frames of a healthy new stream.
  • Tests: 169 → 171 — raw non-frame bytes do NOT keep the watchdog alive (garbage-only stream forces disconnect + reconnect, exactly today's incident shape), and an undecoded chunk over MESH_MAX_FRAME_BUFFER triggers destroy + a fresh session that decodes normally. The existing valid-frames-reset-the-clock test now exercises frame-based tracking unchanged.

v0.21.1 — 2026-09-26 06:47

  • Bridge zombie-connection fix (production incident: outbound sends kept working ~10 h while inbound packets stopped entirely and /api/status stayed connected:true). New idle watchdog: the service tracks lastPacketAt, updated on EVERY chunk received from the bridge in the lowest-level socket 'data' path (any frame kind, decodable or not) and reset when a connection successfully starts. A 30 s interval checks: if connected AND no inbound traffic for MESH_IDLE_TIMEOUT_MS (new env, default 600000 = 10 min), it logs WARN ("no bridge traffic for X ms — forcing reconnect") and destroys the socket — the EXISTING connect/retry loop (MESH_RECONNECT_MS) then re-establishes with a fresh sync. No changes to connect-error retry/backoff behavior; this only adds detection for established-but-silent connections.
  • TCP keepalive on the bridge socket: socket.setKeepAlive(true, 30_000) — cheap insurance against dead peers at the TCP level, complementing the application-level watchdog.
  • Tests: 165 → 169 — silence past an overridden timeout forces disconnect + reconnect (fake bridge), inbound traffic keeps resetting the idle clock so no false trigger, default timeout does not fire in a test window, and setKeepAlive(true, 30_000) is asserted on the socket.

v0.21.0 — 2026-09-25 19:59

  • #rss get <name|#tag> now replies with 3–5 SEPARATE mesh messages instead of one small summary (owner request: the single-message summary was too small). Each item is its own packet, so no fragmentation — and each rides the trigger's channel threaded as a quote of the command packet.
  • New summarizer prompt asks for exactly 3-5 lines, one item per line, format <short header>: <one or two short sentences> with a ≤20-char header (e.g. Culture: Museum opens new wing), no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom — see v0.20.1).
  • The LLM reply is parsed into items: split on newlines, trim, drop empty lines and lines without a : header separator, cap at 5. Fewer than 3 valid lines → send what we got (no failure, no retry — the model is non-deterministic); zero valid lines → no message sent.
  • Per-item backstop replaces the old whole-reply cut: any single line >185 chars is hard-cut at a word boundary near 180 (fitOneLine), so every item fits one packet even if the model ignores the prompt. fitOnePacket() (whole-reply ≤190) is gone — it no longer applies to joined text.
  • CommandService now sends array replies line by line: each element goes through the same routing as a single reply (respond(id, line) threaded to the trigger packet id; legacy sendText fallback per line when there is no packet id). A failing send stops the remaining lines (warn log), like any failed command.
  • LLM failure path unchanged: single summary failed reply via respond(). #rss get #tag gets the same multi-message treatment automatically (shared summary path — covered by tests). All other subcommands unchanged.
  • Tests: 158 → 165 — prompt shape + maxTokens asserted on the new call, 5-item cap, invalid-line filtering, fewer-than-3 fallback, per-line over-length cut through handle(), direct parseSummaryItems/fitOneLine specs, and CommandService array-reply routing (threaded respond ×N to one packet id; legacy sendText fallback).

v0.20.1 — 2026-09-25 19:20

  • #rss get summaries now fit ONE mesh packet (≤190 chars). Mesh fragments longer replies into fire-and-forget packets that recipients frequently never receive, and the old prompt ("a few short one-line highlights") produced ~326-334 char replies.
  • The summary prompt now asks for "at most 3 short bullet lines, each under 45 characters … bullets only - no intro, no other text" (verified against the live LLM: exactly 3 compliant bullets, ~110 chars). A fitOnePacket() backstop additionally cuts any over-length reply at complete-bullet boundaries so delivery is one packet even if the model ignores the prompt.
  • The summary chat() call now uses an explicit maxTokens = 2500 (was 200). Empirically verified on this server: qwen3.8-27b emits reasoning_content and those tokens COUNT against max_tokens (cap 150 → completion_tokens 150, of which 105 were reasoning, finish_reason 'length'; a cap of 500 was fully consumed by reasoning with zero visible content). Thinking length for this task is unbounded and uncontrollable server-side (/no_think, chat_template_kwargs.enable_thinking and thinking_budget are all ignored) — observed 32..1776 tokens across runs. 2500 covers every observed run plus ~80 content tokens, and keeps the worst case (~42 s at ~60 tok/s) under the 60 s LLM timeout. The old cap of 200 guaranteed empty replies whenever reasoning exceeded it.
  • Tests: 154 → 158 — prompt shape + maxTokens asserted on the summary call, over-length reply cut through handle(), and direct fitOnePacket specs (pass-through at/under 190, bullet-boundary cut, hard-cut fallback for non-bulleted paragraphs).

v0.20.0 — 2026-09-25 18:40

  • New #rss command family on the command channel (ch2): #rss help, #rss add <url> <name> (validates URL + fetches with a 15 s timeout, requires ≥1 item with a non-empty title, rejects duplicate names case-insensitively; tags are generated by the LLM from up to 8 latest titles — on LLM failure the source is still added with empty tags and the reply says so), #rss remove <name>, #rss tags (all distinct tags), #rss sources, #rss get <name> and #rss get #<tag> (LLM headline summary, maxTokens 200; tag form collects items across ALL sources carrying the tag — one dead feed does not sink the summary). Bare #rss / unknown subcommand → help. All replies ride the existing respond() path like #ping.
  • Storage: new rss_sources(name TEXT PRIMARY KEY, url, tags, created_at) table in the EXISTING MESH_DB_PATH file (default ./data/mesh-bot.db) via a second node:sqlite connection opened by RssModule's factory — no new DB file. Names are matched case-insensitively everywhere (COLLATE NOCASE).
  • Feed parsing: new dependency fast-xml-parser (the only one added for this feature) — RSS 2.0 (<item><title>/<link>) and Atom (<entry><title>, link as href attribute); CDATA titles supported; malformed input or no usable items → "not a valid feed".
  • LLM: RssService injects LlmService (plain DI, one-way — RssModule imports LlmModule) and uses chat() directly with explicit maxTokens (50 for tags, 200 for summaries) to keep mesh replies short. Tag output is sanitized (lowercase, no #, whitespace → '-', deduped).
  • Dispatch: CommandService gains prefix registration (registerPrefix(name, fn)) alongside exact matches — one handler owns the whole #rss family while #ping stays an untouched exact match; exact names win over prefixes, and a name merely starting with the prefix (e.g. #rssfoo) does not dispatch. Wired in CommandModule's factory (inject: [MeshService, RssService]) — still no constructor-level cycle, no forwardRef.
  • Tests: 128 → 153 specs — store CRUD against a real temp SQLite file (incl. second-connection persistence), feed parser fixtures (RSS 2.0 / Atom / malformed / empty titles), handler behavior with mocked LLM + fetch (help, add success/LLM-fail/invalid-url/duplicate/non-2xx/unparseable, remove found/missing, tags/sources empty+populated, get by name/by tag across sources/dead-feed resilience/unknown name+tag), and #rss prefix dispatch through respond().

v0.19.0 — 2026-09-25 18:03

  • New LLM client module (src/llm): LlmService speaks the OpenAI-compatible chat-completions API of the local server at LLM_API_BASE (default http://192.168.1.70:1111/v1, no auth) using native fetch only — no new dependencies. Plain feature module (LlmModule), wired into app.module.ts; no Mesh/Schedule dependency.
  • Methods: listModels() (GET /models → data[].id strings), chat(messages, opts?) (POST /chat/completions with { model, messages, ...opts } → choices[0].message.content; non-2xx throws an Error carrying the HTTP status and a short body snippet; malformed responses without content throw too), and summarize(text) — thin wrapper over chat() with system prompt "Summarize the following text concisely." (the stated primary use).
  • Env: LLM_API_BASE (default http://192.168.1.70:1111/v1), LLM_MODEL (default qwen3.8-27b), LLM_TIMEOUT_MS (default 60000 — the local 27B model can be slow; applied via AbortSignal.timeout).
  • Tests: +4 specs — listModels parses ids, chat passes model/messages/opts through and returns content, chat throws on non-2xx with status in the message, summarize sends system prompt + user text.

v0.18.1 — 2026-09-25 17:30

  • Command replies now ride MeshService.respond(id, reply) instead of direct sendText routing: when a handler returns a non-empty string and the trigger has a packet id, the reply is auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination) on the original message's channel with replyId = id, so the recipient app renders it as a threaded quote of the triggering packet. COMMAND_CHANNEL is now only the trigger/listen gate — the reply channel comes from the record.
  • Defensive fallback: when the trigger has no packet id (never happens in production — MeshService always passes pkt.id), the legacy sendText routing on COMMAND_CHANNEL (directed DM or broadcast) still sends the reply out. Failure handling unchanged: a failing handler or respond only logs a warn, never crashes.
  • Tests: 121 → 124 specs — unit cases now mock mesh.respond (id present → respond(id, reply) with no sendText; id absent → legacy DM/broadcast fallback on the command channel), plus a failing-respond swallow case; e2e over the fake bridge asserts wire-level threading for both shapes: #ping broadcast on ch2 → #pong! broadcast on ch2 with replyId = ping packet id, and #ping DM → #pong! DM to the sender on ch2 with replyId set. All gate tests (case/whitespace/channel/env override) kept.
  • The wire-decode test helper outboundPackets() moved from mesh.service.spec.ts into fake-bridge.ts so both specs share it.
  • Docs: AGENTS.md "Other" bullet and MESHTASTIC.md describe the respond() auto-routing + threading; README env list unchanged (COMMAND_CHANNEL still exists, now meaning trigger channel only).

v0.18.0 — 2026-09-25 17:08

  • New channel-index-2 auto-responder (src/commands): an event-driven CommandService reacts to decoded rx text on COMMAND_CHANNEL (env, default 2) and dispatches exact #command matches (case-sensitive, surrounding whitespace trimmed) through a single-registration-point registry — handlers registered in the constructor via register(name, fn) receive { text, from?, id? } and return a reply string or void (= no reply). First command: #ping → replies #pong!.
  • Reply routing v1: directed DM back to the sender when present, else broadcast — always on COMMAND_CHANNEL, via the existing MeshService.sendText(to, text, undefined, channel) path. No threading/replyId in v1. A failing handler or send only logs a warn; it never crashes the app.
  • Wiring: MeshService gains an optional third constructor param (commands?) plus a public attachCommands() — the rx handler calls this.commands?.onRx(channel, text, from, id) right after notifyRx(), fire-and-forget like the notifier. DI note: a constructor-level MeshService ↔ CommandService cycle leaves one side null in NestJS 12's injector (verified empirically), so CommandModule provides CommandService via a factory that injects MeshService and attaches itself as a side effect — no forwardRef, no module cycle.
  • Tests: 106 → 121 specs — directed/broadcast replies on ch2, non-command-channel and non-command text ignored, case-sensitive exact match, whitespace trimming, COMMAND_CHANNEL override, void handler = no reply, extensibility (second registered command), failing handler/send swallowed; end-to-end over the fake bridge (#ping on ch2 answered with a directed #pong! tx record, ch3 not answered); DI spec asserts both sides of the wiring resolve to real instances.
  • Docs: AGENTS.md "Other" bullet + MESHTASTIC.md "Channels & sending" describe the auto-responder; README env list gains COMMAND_CHANNEL.

v0.17.0 — 2026-09-25 16:18

  • The mesh→agent notify gate is now a comma-separated multi-channel list: MESH_NOTIFY_CHANNEL (default 3) parses to channel indices, each entry trimmed, empty/invalid entries ignored ("3,0" → [3, 0]).
  • ch0 DM-only filtering: ch0 is the shared default channel where other users talk — only directed messages (both sender and recipient present) notify there; ch0 broadcasts and undirected traffic are ignored. Synthetic injections (POST /api/messages, injectMessage) ride ch0 by design and stay exempt from the rule, so they still notify when 0 is in the list.
  • notifyRx() gains optional params: to? (the packet's destination, now passed through from the rx handler) and an injected flag; the field is renamed notifyChannel → notifyChannels. Self-echo filter unchanged.
  • Tests: 102 → 106 specs — multi-list match/non-match, ch0 broadcast ignored even with 0 in the list, ch0 directed DM notified, ch0 injection notified via a multi-list, invalid env entries ignored.
  • Docs: AGENTS.md + MESHTASTIC.md "Notifications" describe the list semantics and the ch0 DM-only rule; README env bullet/forwarded/notifier lines and the controller's GET /api/forwarded description kept in sync. Local .env (gitignored) now MESH_NOTIFY_CHANNEL=3,0.

v0.16.0 — 2026-09-25 15:49

  • New GET /api/forwarded: the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — the id is the packet id (real rx) or generated record id (injected), from omitted when there is no sender. Backed by an in-memory ring buffer in MeshService, recorded in notifyRx() at hand-off: after the channel gate (MESH_NOTIFY_CHANNEL) and self-echo filter pass, right before the OpencodeService.notify() call; an unwired notifier records nothing (a notifier disabled internally still counts — MeshService cannot see its enabled flag). In-memory only, reset on restart. Purpose: a replying agent gets exactly the notified subset with packet ids instead of scanning all of /api/messages.
  • Tests: 96 → 102 specs — ring records on a gated forward (shape + newest-first via forwarded()), no record on channel mismatch / self-echo / unwired notifier, injected messages count when they pass the gate (from omitted when absent), cap at 100 with oldest evicted.
  • Docs: README endpoint bullet + curl example; AGENTS.md endpoints list kept in sync with the controller.

v0.15.0 — 2026-09-25 15:40

  • New POST /api/respond endpoint and MCP respond(messageId, content) tool: reply to an earlier message by packet id with automatic routing — the caller never thinks about where it goes. rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their own destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carries replyId = messageId, so the recipient app renders it as a threaded quote of that packet. Returns { id }; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down; MCP tool errors carry the same messages.
  • Captured rx history records now persist their destination (to: pkt.to) — no schema change needed: the to_node column already existed for tx rows and flush/seed already round-tripped it, so captured packets simply fill it in too. This is what lets a reply tell a DM from a broadcast original.
  • The opencode notifier wrapper now includes the packet id: msg = New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it. — the id is the rx packet's id, or the generated record id for injected messages; the from node part is still omitted when absent.
  • Tests: 88 → 96 specs. New: captured-rx row persists to_node; six respond service specs (rx DM → directed send back to sender on the original channel with wire-level replyId, broadcast original → broadcast reply, tx record → its destination/channel, SQLite fallback when the record is outside the in-memory buffer, 404 memory+DB miss, 400 validation matrix); MCP respond wiring spec (routing + unknown-id/out-of-range/empty-content tool errors) and the tool list grows to ten.
  • Docs: README gains the endpoint bullet, a curl example, the MCP tool entry and the updated notifier wrapper format; AGENTS.md endpoints/MCP/notifier lines kept in sync with the controller.

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).