feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) #2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
- 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+255
-1
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user