feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) (#2)

feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) — closes #1
This commit was merged in pull request #2.
This commit is contained in:
2026-09-23 21:05:22 +02:00
parent 5a33e4590a
commit 75a10c195f
11 changed files with 489 additions and 19 deletions
+2 -2
View File
@@ -12,12 +12,12 @@
## Verification ## 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. 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 ## 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). 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"?: <node number | "!b" | "broadcast"> }`, 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"?: <node number | "!b" | "broadcast"> }`, 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 ## Other
+8
View File
@@ -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).
+5 -5
View File
@@ -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`. - 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). - 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`. - 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). - 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`. 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. 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<version> origin/main && git push origin v<version>` (after `git fetch origin main`). 5. Tag the version on main: `git tag v<version> origin/main && git push origin v<version>` (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. 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. 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 ## 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`. - 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). - 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) ## 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 ## 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/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.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`. - `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) ## 8. Finding current state (this file keeps no snapshots)
+13 -5
View File
@@ -21,13 +21,20 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
## Endpoints ## Endpoints
- `GET /status` — `{ connected, myNodeNum, host, port }` All API endpoints live under the `/api` prefix:
- `POST /send` — body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, 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 ```sh
curl http://localhost:3000/status curl http://localhost:3000/api/status
curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d '{"text":"hi"}' curl -X POST http://localhost:3000/api/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 -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`. `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_HOST` (default `192.168.1.4`) — bridge host
- `MESH_PORT` (default `4003`) — bridge port - `MESH_PORT` (default `4003`) — bridge port
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
- `PORT` (default `3000`) — HTTP listen port - `PORT` (default `3000`) — HTTP listen port
Note: the serial bridge accepts only ONE client at a time — if another app is Note: the serial bridge accepts only ONE client at a time — if another app is
+255 -1
View File
@@ -14,6 +14,8 @@
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^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", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -609,6 +611,12 @@
"@bufbuild/protobuf": "^2.2.3" "@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": { "node_modules/@nestjs/cli": {
"version": "12.0.5", "version": "12.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz", "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": { "node_modules/@nestjs/mau": {
"version": "0.2.8", "version": "0.2.8",
"resolved": "https://registry.npmjs.org/@nestjs/mau/-/mau-0.2.8.tgz", "resolved": "https://registry.npmjs.org/@nestjs/mau/-/mau-0.2.8.tgz",
@@ -965,6 +996,47 @@
"yarn": ">= 1.13.0" "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": { "node_modules/@nestjs/testing": {
"version": "12.1.0", "version": "12.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-12.1.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-12.1.0.tgz",
@@ -1611,6 +1683,118 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@sindresorhus/is": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -2084,6 +2268,12 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT" "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": { "node_modules/array-timsort": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
@@ -2740,6 +2930,18 @@
"node": ">= 0.4" "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": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -3522,6 +3724,28 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -5116,6 +5340,27 @@
"node": ">=8" "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": { "node_modules/through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -5276,7 +5521,7 @@
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -5671,6 +5916,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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"
}
} }
} }
} }
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.0.1", "version": "0.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -26,6 +26,8 @@
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^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", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
+23 -1
View File
@@ -1,9 +1,31 @@
import { NestFactory } from '@nestjs/core'; 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'; import { AppModule } from './app.module.js';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks(); 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); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();
+24 -1
View File
@@ -5,20 +5,35 @@ import {
Get, Get,
Post, Post,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiProperty } from '@nestjs/swagger';
import { BROADCAST_ADDR } from './codec.js'; import { BROADCAST_ADDR } from './codec.js';
import { MeshService } from './mesh.service.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() @Controller()
export class MeshController { export class MeshController {
constructor(private readonly mesh: MeshService) {} constructor(private readonly mesh: MeshService) {}
@Get('status') @Get('status')
@ApiOperation({ summary: 'Bridge connection status' })
status() { status() {
return this.mesh.status(); return this.mesh.status();
} }
@Post('send') @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) { if (typeof body?.text !== 'string' || body.text.length === 0) {
throw new BadRequestException('text must be a non-empty string'); 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) }; 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();
}
} }
+118
View File
@@ -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<void> {
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();
}
});
});
+37 -3
View File
@@ -18,11 +18,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MeshService.name); private readonly logger = new Logger(MeshService.name);
private readonly host = process.env.MESH_HOST ?? '192.168.1.4'; private readonly host = process.env.MESH_HOST ?? '192.168.1.4';
private readonly port = parseInt(process.env.MESH_PORT ?? '4003', 10); 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 socket: net.Socket | null = null;
private buffer: Uint8Array = new Uint8Array(0); private buffer: Uint8Array = new Uint8Array(0);
private connected = false; private connected = false;
private myNodeNum: number | null = null; 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 reconnectTimer: NodeJS.Timeout | null = null;
private shuttingDown = false; 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<number> { async sendText(to: number, text: string): Promise<number> {
if (!this.connected || !this.socket) if (!this.connected || !this.socket)
throw new ServiceUnavailableException('bridge not connected'); throw new ServiceUnavailableException('bridge not connected');
@@ -59,6 +86,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.socket = socket; this.socket = socket;
socket.on('connect', () => { socket.on('connect', () => {
this.connected = true; 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}`); this.logger.log(`bridge connected ${this.host}:${this.port}`);
}); });
socket.on('data', (chunk) => { socket.on('data', (chunk) => {
@@ -90,8 +119,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.shuttingDown) return; if (this.shuttingDown) return;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
// ponytail: fixed 5s retry, no backoff — fine for single-bridge use this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
} }
private handleFromRadio( private handleFromRadio(
@@ -103,8 +131,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.myNodeNum = v.value.myNodeNum; this.myNodeNum = v.value.myNodeNum;
this.logger.log(`my node num: ${this.myNodeNum}`); this.logger.log(`my node num: ${this.myNodeNum}`);
break; break;
case 'channel':
// device streams one Channel per table slot during initial sync
this.channelMap.set(v.value.index, v.value);
break;
case 'configCompleteId': case 'configCompleteId':
this.logger.log('bridge ready (config complete)'); this.logger.log(
`bridge ready (config complete), ${this.channelMap.size} channels`,
);
break; break;
case 'packet': { case 'packet': {
const pkt = v.value; const pkt = v.value;
+1
View File
@@ -3,6 +3,7 @@
"module": "nodenext", "module": "nodenext",
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"resolvePackageJsonExports": true, "resolvePackageJsonExports": true,
"resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": true, "isolatedModules": true,
"declaration": true, "declaration": true,