From 75a10c195f17468b3a53bc3807e4bd63443c3da8 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 21:05:22 +0200 Subject: [PATCH] feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) — closes #1 --- AGENTS.md | 4 +- CHANGELOG.md | 8 ++ ORCHESTRATOR.md | 10 +- README.md | 18 ++- package-lock.json | 256 +++++++++++++++++++++++++++++++++- package.json | 4 +- src/main.ts | 24 +++- src/mesh/mesh.controller.ts | 25 +++- src/mesh/mesh.service.spec.ts | 118 ++++++++++++++++ src/mesh/mesh.service.ts | 40 +++++- tsconfig.json | 1 + 11 files changed, 489 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/mesh/mesh.service.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 064279c..6189d85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,12 +12,12 @@ ## Verification 1. **Local checks** — `npm run build` (nest build) and `npm test` (vitest; no e2e suite yet). Both must pass before a PR is opened. -2. **Docker** — `docker compose build`, then after `docker compose up -d`: `curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/status` returns JSON (`{ connected, myNodeNum, host, port }`). `"connected":false` is normal while the bridge/device is down — the service retries every 5 s. +2. **Docker** — `docker compose build`, then after `docker compose up -d`: `curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/api/status` returns JSON (`{ connected, myNodeNum, host, port }`). `"connected":false` is normal while the bridge/device is down — the service retries every 5 s. ## API 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). -2. **Endpoints** — `GET /status` (`{ connected, myNodeNum, host, port }`) and `POST /send` (body `{ "text": "...", "to"?: }`, returns `{ id }`). No machine-readable spec yet; keep this list in sync with the controller. +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }`, returns `{ id }`), and `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9cb8ccb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 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). diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md index 6290635..462fdcb 100644 --- a/ORCHESTRATOR.md +++ b/ORCHESTRATOR.md @@ -8,7 +8,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they - Repo: Gitea at https://git.kkhost.pl → org **OpenCode**, repo **MeshBot** (full name `OpenCode/MeshBot`). Every Gitea API/MCP call uses owner=`OpenCode`, repo=`MeshBot`. - Local workdir + deploy dir: `/home/krzyw/projects/sandbox/mesh-bot` (git checkout; `docker compose up -d --build` deploys from here). -- Service URL: http://bot.dev.kkhost.pl (Traefik → docker-compose stack on this host, container port 3000 via external network dev_net; no published ports — TLS is terminated upstream of the local dev_traefik and *.dev.kkhost.pl DNS is network-wide). `GET /status` reports bridge connectivity. +- Service URL: http://bot.dev.kkhost.pl (Traefik → docker-compose stack on this host, container port 3000 via external network dev_net; no published ports — TLS is terminated upstream of the local dev_traefik and *.dev.kkhost.pl DNS is network-wide). `GET /api/status` reports bridge connectivity. - Gitea API access for git.kkhost.pl: prefer the gitea MCP tools — they are already configured with a token. For raw REST/curl, read the current token from the local .env file at repo root (variable `GITEA_TOKEN`; create it if missing — .env is gitignored and must never be committed): `source .env && curl -s -H "Authorization: token $GITEA_TOKEN" https://git.kkhost.pl/api/v1/user`. - Current user: kamilkrzywda (id 1). @@ -23,7 +23,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they 3. Local verification ONLY (there is no CI): `npm run build`, `npm test` (the whole vitest unit suite must pass — the test count grows over time, so compare against the previous green run, never a hardcoded number), and for container-affecting changes `docker compose build`. 4. Push → open PR to main → merge manually once local checks are green (no CI gate, no auto-merge configured); verify via `GET /pulls/{n}` that state=closed and merged_at is set. 5. Tag the version on main: `git tag v origin/main && git push origin v` (after `git fetch origin main`). -6. Deploy in a PTY (long task): `cd /home/krzyw/projects/sandbox/mesh-bot && git checkout main && git reset --hard origin/main && docker compose up -d --build`; then verify `curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/status` returns JSON with the expected host/port (`"connected":false` is normal while the bridge/device is down — the service retries every 5 s). +6. Deploy in a PTY (long task): `cd /home/krzyw/projects/sandbox/mesh-bot && git checkout main && git reset --hard origin/main && docker compose up -d --build`; then verify `curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/api/status` returns JSON with the expected host/port (`"connected":false` is normal while the bridge/device is down — the service retries every 5 s). 7. Close each issue with a comment: what changed, version, PR number, merge commit SHA, deployed URL. 8. Repeat for the next open issue: `GET /issues?state=open&type=issues` — note Gitea lists pull requests in this endpoint too; filter out entries with a `pull_request` key. @@ -35,7 +35,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they ## 5. Docker & deploy facts - Multi-stage `node:22-alpine` Dockerfile: build stage runs `npm ci` + `nest build`, final stage copies pruned node_modules + dist; entrypoint `node dist/main.js`. -- docker-compose.yml has Traefik labels ONLY — no published ports, no websecure/certs. Router rule Host(`bot.dev.kkhost.pl`) on the `web` entrypoint → container port 3000, attached to external network dev_net. From the host you verify with a Host header: `curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/status`. +- docker-compose.yml has Traefik labels ONLY — no published ports, no websecure/certs. Router rule Host(`bot.dev.kkhost.pl`) on the `web` entrypoint → container port 3000, attached to external network dev_net. From the host you verify with a Host header: `curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/api/status`. - The build runs `npm ci` → package-lock.json must stay in sync with package.json (see AGENTS.md → Other for the host npm quirk). ## 6. Mesh protocol facts (learned the hard way) @@ -49,8 +49,8 @@ Credentials are deliberately NOT stored in this file — see §1 for where they ## 7. App architecture quick map - `src/mesh/codec.ts` — framing (`extractFrames`, `frame`) + protobuf encode/decode (`decodeFromRadio`, `buildTextToRadio`); unit tests in codec.spec.ts. -- `src/mesh/mesh.service.ts` — TCP connection to the bridge (MESH_HOST/MESH_PORT env, defaults 192.168.1.4:4003), buffer + frame extraction on data, FromRadio dispatch (`myInfo` → myNodeNum, `configCompleteId` → ready log, `packet` → text-message logging), fixed 5 s reconnect. -- `src/mesh/mesh.controller.ts` — `GET /status`, `POST /send`. +- `src/mesh/mesh.service.ts` — TCP connection to the bridge (MESH_HOST/MESH_PORT env, defaults 192.168.1.4:4003), buffer + frame extraction on data, FromRadio dispatch (`myInfo` → myNodeNum, `channel` → upsert into channelMap keyed by index — reset on every reconnect, `configCompleteId` → ready log, `packet` → text-message logging), fixed-interval reconnect (MESH_RECONNECT_MS, default 5 s). +- `src/mesh/mesh.controller.ts` — `GET /status`, `POST /send`, `GET /channels` (all under the `/api` global prefix set in main.ts; Scalar docs at `/`, OpenAPI JSON at `/docs-json`). ## 8. Finding current state (this file keeps no snapshots) diff --git a/README.md b/README.md index 461986c..3b9b1d5 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,20 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`. ## Endpoints -- `GET /status` — `{ connected, myNodeNum, host, port }` -- `POST /send` — body `{ "text": "...", "to"?: }`, returns `{ id }` +All API endpoints live under the `/api` prefix: + +- `GET /api/status` — `{ connected, myNodeNum, host, port }` +- `POST /api/send` — body `{ "text": "...", "to"?: }`, returns `{ id }` +- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes) + +Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at +`/docs-json`. ```sh -curl http://localhost:3000/status -curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d '{"text":"hi"}' -curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}' +curl http://localhost:3000/api/status +curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}' +curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}' +curl http://localhost:3000/api/channels ``` `to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`. @@ -36,6 +43,7 @@ curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d ' - `MESH_HOST` (default `192.168.1.4`) — bridge host - `MESH_PORT` (default `4003`) — bridge port +- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms - `PORT` (default `3000`) — HTTP listen port Note: the serial bridge accepts only ONE client at a time — if another app is diff --git a/package-lock.json b/package-lock.json index 28af490..6e48030 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@nestjs/common": "^12.0.1", "@nestjs/core": "^12.0.1", "@nestjs/platform-express": "^12.0.1", + "@nestjs/swagger": "^12.0.2", + "@scalar/nestjs-api-reference": "^1.2.21", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -609,6 +611,12 @@ "@bufbuild/protobuf": "^2.2.3" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.17.0.tgz", + "integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "12.0.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz", @@ -746,6 +754,29 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-12.0.0.tgz", + "integrity": "sha512-aX7lxZcqXldtnSr0bgT2ZPQvpTpIwBMW1GMX29o4s3q02vmq6JoTYuPW0GwYXa8BiFMRX7WbGZVDA703pNqOMQ==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/mau": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/@nestjs/mau/-/mau-0.2.8.tgz", @@ -965,6 +996,47 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@nestjs/swagger": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-12.0.2.tgz", + "integrity": "sha512-/IDj6o2k1wqZybhIkMaJp58YNrAos/4TwTf3zBn/j1flKpMb5Tyfbj9sP6rpLucQ7PzHHu7n4ucmeHWEisfn6A==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.17.0", + "@nestjs/mapped-types": "12.0.0", + "@standard-schema/spec": "1.1.0", + "es-toolkit": "1.52.0", + "js-yaml": "5.4.2", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.33.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^12.0.0", + "@nestjs/core": "^12.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "typescript": "^5.5.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-12.1.0.tgz", @@ -1611,6 +1683,118 @@ "dev": true, "license": "MIT" }, + "node_modules/@scalar/client-side-rendering": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.4.3.tgz", + "integrity": "sha512-a7Hs+YpaT3CEtJcx6pJS/1XMhV/cCGmheYrwuCd51LvrnJLluVqq7ID6F9xWqPnc7Y0E3vs6XZcz+h3exjUFdg==", + "license": "MIT", + "dependencies": { + "@scalar/schemas": "0.11.0", + "@scalar/types": "0.21.0", + "@scalar/validation": "0.6.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.13.0.tgz", + "integrity": "sha512-1vB6AZXCQZPb+MApSrso6ss+rbLuiI/vGe+PmiXm8Cnt6B+10JeacDPoPr51EgSwltSfH2hZhJ7hHjW2pSg+bQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/nestjs-api-reference": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@scalar/nestjs-api-reference/-/nestjs-api-reference-1.2.21.tgz", + "integrity": "sha512-uaUpwblhplCzQRt8MYnMUF7FQFwCHGPcA5hmJxbvKezRSmpX4FATS5tXCYmY4P8yiMPTOwlQHB6hz8QSFQkk6g==", + "license": "MIT", + "dependencies": { + "@scalar/client-side-rendering": "0.4.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.11.0.tgz", + "integrity": "sha512-rtvZSsL7IFcHLB1j0VJIgW2MJ702NPOvlhRoRWU4gDK4rGDRmy58BzXtF7gbKOm8vA8aX24NIMr7itwc1Pp6AQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.13.0", + "@scalar/validation": "0.6.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.21.0.tgz", + "integrity": "sha512-kl/GSKZ2DynaaP1LxnyXxopgoIYrtUI95cOeG3KYkdGJ6pEn3MJ6PDmWfRQNj3gH/ZRx2UuNIf3jVvtMNrlrKA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.13.0", + "nanoid": "^5.1.6", + "type-fest": "^5.8.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/types/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@scalar/validation": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.4.tgz", + "integrity": "sha512-s93HFX0+N6bLwGjZPKFGVHq/gWiZy1YQkR2+dSflbUdYp+4j2Pg+WDA0gwbFrsOlgtkEjwQbjXtbmBiFTtPYUg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -2084,6 +2268,12 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -2740,6 +2930,18 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -3522,6 +3724,28 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5116,6 +5340,27 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.33.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.0.tgz", + "integrity": "sha512-wpdK+m6BU5yj6pmUdMskZVTSWYG4DLglAx3sIhylloY37i8O37IrH+YEpqdXNfpaTGxILRBFzUqLF2jKqbfI7A==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -5276,7 +5521,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5671,6 +5916,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index af1a817..8425d08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.0.1", + "version": "0.1.0", "description": "", "author": "", "private": true, @@ -26,6 +26,8 @@ "@nestjs/common": "^12.0.1", "@nestjs/core": "^12.0.1", "@nestjs/platform-express": "^12.0.1", + "@nestjs/swagger": "^12.0.2", + "@scalar/nestjs-api-reference": "^1.2.21", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/src/main.ts b/src/main.ts index 1dd98d8..f5cde61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,31 @@ import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import type { RequestHandler } from 'express'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { apiReference } from '@scalar/nestjs-api-reference'; +import * as pkg from '../package.json'; import { AppModule } from './app.module.js'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); + app.setGlobalPrefix('api'); + + // Scalar API docs at / (OpenAPI JSON at /docs-json), outside the api prefix. + const docConfig = new DocumentBuilder() + .setTitle('MeshBot API') + .setDescription( + 'Meshtastic bridge bot: status, send text messages, list device channels.', + ) + .setVersion(pkg.version) + .build(); + const document = SwaggerModule.createDocument(app, docConfig); + const http = app.getHttpAdapter().getInstance(); + http.get('/docs-json', (_req, res) => { + res.json(document); + }); + http.get('/', apiReference({ url: '/docs-json' }) as RequestHandler); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/src/mesh/mesh.controller.ts b/src/mesh/mesh.controller.ts index 2cca12d..a274915 100644 --- a/src/mesh/mesh.controller.ts +++ b/src/mesh/mesh.controller.ts @@ -5,20 +5,35 @@ import { Get, Post, } from '@nestjs/common'; +import { ApiOperation, ApiProperty } from '@nestjs/swagger'; import { BROADCAST_ADDR } from './codec.js'; import { MeshService } from './mesh.service.js'; +export class SendTextDto { + @ApiProperty({ description: 'Message text', example: 'hello' }) + text!: string; + @ApiProperty({ + required: false, + description: + 'Decimal node number, "!b", or "broadcast" (defaults to broadcast)', + example: '!b', + }) + to?: string | number; +} + @Controller() export class MeshController { constructor(private readonly mesh: MeshService) {} @Get('status') + @ApiOperation({ summary: 'Bridge connection status' }) status() { return this.mesh.status(); } @Post('send') - async send(@Body() body: { to?: string | number; text: string }) { + @ApiOperation({ summary: 'Send a text message over the mesh' }) + async send(@Body() body: SendTextDto) { if (typeof body?.text !== 'string' || body.text.length === 0) { throw new BadRequestException('text must be a non-empty string'); } @@ -34,4 +49,12 @@ export class MeshController { } return { id: await this.mesh.sendText(to, body.text) }; } + + @Get('channels') + @ApiOperation({ + summary: "The device's Meshtastic channels (keys redacted)", + }) + channels() { + return this.mesh.channels(); + } } diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts new file mode 100644 index 0000000..a0213c9 --- /dev/null +++ b/src/mesh/mesh.service.spec.ts @@ -0,0 +1,118 @@ +import { create, toBinary } from '@bufbuild/protobuf'; +import * as P from '@meshtastic/protobufs'; +import * as net from 'node:net'; +import type { AddressInfo } from 'node:net'; +import { describe, expect, it } from 'vitest'; +import { frame } from './codec.js'; +import { MeshService } from './mesh.service.js'; + +function startBridge(): Promise<{ + port: number; + sockets: net.Socket[]; + close: () => void; +}> { + const sockets: net.Socket[] = []; + const server = net.createServer((socket) => sockets.push(socket)); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ + port: (server.address() as AddressInfo).port, + sockets, + close: () => server.close(), + }); + }); + }); +} + +async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting'); + await new Promise((r) => setTimeout(r, 10)); + } +} + +function channelFrame( + index: number, + settings?: { name?: string; uplinkEnabled?: boolean }, +): Uint8Array { + const ch = create(P.Channel.ChannelSchema, { + index, + ...(settings + ? { + // psk must never leak into channels() output + settings: { + name: settings.name ?? '', + psk: new Uint8Array(16).fill(7), + uplinkEnabled: settings.uplinkEnabled ?? false, + }, + } + : {}), + }); + const fr = create(P.Mesh.FromRadioSchema, { + payloadVariant: { case: 'channel', value: ch }, + }); + return frame(toBinary(P.Mesh.FromRadioSchema, fr)); +} + +describe('MeshService channel pipeline', () => { + it('collects channels from FromRadio frames and redacts keys', async () => { + const bridge = await startBridge(); + process.env.MESH_HOST = '127.0.0.1'; + process.env.MESH_PORT = String(bridge.port); + const svc = new MeshService(); + try { + expect(svc.channels()).toEqual([]); + + svc.onModuleInit(); + await waitFor( + () => bridge.sockets.length === 1 && svc.status().connected, + ); + + bridge.sockets[0].write( + channelFrame(2, { name: 'LongFast', uplinkEnabled: true }), + ); + bridge.sockets[0].write(channelFrame(0)); // channel with no settings at all + + await waitFor(() => svc.channels().length === 2); + expect(svc.channels()).toEqual([ + { index: 0, name: '', uplinkEnabled: false, downlinkEnabled: false }, + { + index: 2, + name: 'LongFast', + uplinkEnabled: true, + downlinkEnabled: false, + }, + ]); + } finally { + svc.onModuleDestroy(); + bridge.close(); + } + }); + + it('resets channels on reconnect (no stale data)', async () => { + const bridge = await startBridge(); + process.env.MESH_HOST = '127.0.0.1'; + process.env.MESH_PORT = String(bridge.port); + process.env.MESH_RECONNECT_MS = '50'; + const svc = new MeshService(); + try { + svc.onModuleInit(); + await waitFor( + () => bridge.sockets.length === 1 && svc.status().connected, + ); + + bridge.sockets[0].write(channelFrame(1, { name: 'Stale' })); + await waitFor(() => svc.channels().length === 1); + + // drop the connection; bot reconnects and must start with an empty table + bridge.sockets[0].destroy(); + await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); + await waitFor(() => svc.channels().length === 0); + } finally { + delete process.env.MESH_RECONNECT_MS; + svc.onModuleDestroy(); + bridge.close(); + } + }); +}); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index eccdf52..613e5ae 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -18,11 +18,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(MeshService.name); private readonly host = process.env.MESH_HOST ?? '192.168.1.4'; private readonly port = parseInt(process.env.MESH_PORT ?? '4003', 10); + // ponytail: fixed-interval retry, no backoff — fine for single-bridge use + private readonly reconnectMs = parseInt( + process.env.MESH_RECONNECT_MS ?? '5000', + 10, + ); private socket: net.Socket | null = null; private buffer: Uint8Array = new Uint8Array(0); private connected = false; private myNodeNum: number | null = null; + // channels streamed by the device during initial sync (before config_complete_id) + private channelMap = new Map< + number, + import('@meshtastic/protobufs').Channel.Channel + >(); private reconnectTimer: NodeJS.Timeout | null = null; private shuttingDown = false; @@ -45,6 +55,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { }; } + // psk/keys are deliberately not included — never expose them over HTTP + channels(): { + index: number; + name: string; + uplinkEnabled: boolean; + downlinkEnabled: boolean; + }[] { + return [...this.channelMap.values()] + .sort((a, b) => a.index - b.index) + .map(({ index, settings }) => ({ + index, + name: settings?.name ?? '', + uplinkEnabled: settings?.uplinkEnabled ?? false, + downlinkEnabled: settings?.downlinkEnabled ?? false, + })); + } + async sendText(to: number, text: string): Promise { if (!this.connected || !this.socket) throw new ServiceUnavailableException('bridge not connected'); @@ -59,6 +86,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { this.socket = socket; socket.on('connect', () => { this.connected = true; + // fresh sync on every (re)connect — drop stale channels from the old session + this.channelMap = new Map(); this.logger.log(`bridge connected ${this.host}:${this.port}`); }); socket.on('data', (chunk) => { @@ -90,8 +119,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { private scheduleReconnect(): void { if (this.shuttingDown) return; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - // ponytail: fixed 5s retry, no backoff — fine for single-bridge use - this.reconnectTimer = setTimeout(() => this.connect(), 5000); + this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs); } private handleFromRadio( @@ -103,8 +131,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { this.myNodeNum = v.value.myNodeNum; this.logger.log(`my node num: ${this.myNodeNum}`); break; + case 'channel': + // device streams one Channel per table slot during initial sync + this.channelMap.set(v.value.index, v.value); + break; case 'configCompleteId': - this.logger.log('bridge ready (config complete)'); + this.logger.log( + `bridge ready (config complete), ${this.channelMap.size} channels`, + ); break; case 'packet': { const pkt = v.value; diff --git a/tsconfig.json b/tsconfig.json index 8109c41..f7697b9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "module": "nodenext", "moduleResolution": "nodenext", "resolvePackageJsonExports": true, + "resolveJsonModule": true, "esModuleInterop": true, "isolatedModules": true, "declaration": true,