feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) #2
@@ -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"?: <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
|
||||
|
||||
|
||||
@@ -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
@@ -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<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.
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`
|
||||
All API endpoints live under the `/api` prefix:
|
||||
|
||||
- `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
|
||||
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
|
||||
|
||||
Generated
+255
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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"
|
||||
},
|
||||
|
||||
+23
-1
@@ -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<NestExpressApplication>(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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
|
||||
Reference in New Issue
Block a user