Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3062f40899 |
@@ -17,7 +17,7 @@
|
||||
## 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** — 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.
|
||||
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. All `POST /api/send` traffic targets channel index 2 (`testowy`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `2`).
|
||||
|
||||
## Other
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## v0.1.3 — 2026-09-23 19:58
|
||||
|
||||
- Sends are now pinned to the test channel `testowy` (channel index 2) instead of the hardcoded primary channel (`channel: 0`). The target channel is configurable via `MESH_SEND_CHANNEL` (default `2`) and parsed once at service construction; `buildTextToRadio` takes the channel index as a parameter. Unit tests now decode the outbound ToRadio frame on the fake bridge and assert `packet.channel === 2` on the default send path.
|
||||
|
||||
## v0.1.2 — 2026-09-23 19:51
|
||||
|
||||
- Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`), mirroring flight's runner conventions: on push/PR to `main` (plus manual dispatch) one job runs `npm ci`, `nest build`, the vitest unit suite, and a production Docker image build — with retry loops for the runner's flaky uplink. No app code changed.
|
||||
|
||||
@@ -39,11 +39,15 @@ curl http://localhost:3000/api/channels
|
||||
|
||||
`to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`.
|
||||
|
||||
All sends always target channel index 2 (`testowy`) — never the primary
|
||||
channel. Override with `MESH_SEND_CHANNEL`.
|
||||
|
||||
## Env vars
|
||||
|
||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||
- `MESH_PORT` (default `4403`) — bridge port
|
||||
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
||||
- `MESH_SEND_CHANNEL` (default `2`) — channel index all sends target (`testowy`; never the primary channel)
|
||||
- `PORT` (default `3000`) — HTTP listen port
|
||||
|
||||
Note: the serial bridge accepts only ONE client at a time — if another app is
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
+2
-1
@@ -64,6 +64,7 @@ export async function decodeFromRadio(
|
||||
export async function buildTextToRadio(
|
||||
to: number,
|
||||
text: string,
|
||||
channelIndex: number,
|
||||
): Promise<{ bytes: Uint8Array; id: number }> {
|
||||
const P = await loadProto();
|
||||
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
|
||||
@@ -72,7 +73,7 @@ export async function buildTextToRadio(
|
||||
to,
|
||||
id,
|
||||
wantAck: true,
|
||||
channel: 0,
|
||||
channel: channelIndex,
|
||||
payloadVariant: {
|
||||
case: 'decoded',
|
||||
value: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { create, toBinary } from '@bufbuild/protobuf';
|
||||
import { create, fromBinary, 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 { extractFrames, frame } from './codec.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
function startBridge(): Promise<{
|
||||
@@ -116,3 +116,55 @@ describe('MeshService channel pipeline', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService send', () => {
|
||||
it('sends on the test channel (index 2) by default, never primary', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
delete process.env.MESH_SEND_CHANNEL; // default path: "testowy" (index 2)
|
||||
const svc = new MeshService();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
const out: Uint8Array[] = [];
|
||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||
const id = await svc.sendText(42, 'hello testowy');
|
||||
expect(id).toBeGreaterThan(0);
|
||||
|
||||
const readOutbound = (): P.Mesh.MeshPacket | undefined => {
|
||||
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
||||
let off = 0;
|
||||
for (const c of out) {
|
||||
merged.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
const { packets } = extractFrames(merged);
|
||||
if (!packets.length) return undefined;
|
||||
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
|
||||
return tr.payloadVariant?.case === 'packet'
|
||||
? tr.payloadVariant.value
|
||||
: undefined;
|
||||
};
|
||||
|
||||
await waitFor(() => readOutbound() !== undefined);
|
||||
const pkt = readOutbound()!;
|
||||
expect(pkt.channel).toBe(2); // "testowy" — never the primary channel (0)
|
||||
expect(pkt.to).toBe(42);
|
||||
const decoded =
|
||||
pkt.payloadVariant?.case === 'decoded'
|
||||
? pkt.payloadVariant.value
|
||||
: undefined;
|
||||
expect(
|
||||
new TextDecoder().decode(decoded?.payload ?? new Uint8Array()),
|
||||
).toBe('hello testowy');
|
||||
} finally {
|
||||
delete process.env.MESH_SEND_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,11 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
process.env.MESH_RECONNECT_MS ?? '5000',
|
||||
10,
|
||||
);
|
||||
// sends are pinned to the test channel ("testowy", index 2) on purpose — never the primary channel
|
||||
private readonly sendChannel = parseInt(
|
||||
process.env.MESH_SEND_CHANNEL ?? '2',
|
||||
10,
|
||||
);
|
||||
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer: Uint8Array = new Uint8Array(0);
|
||||
@@ -75,7 +80,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
async sendText(to: number, text: string): Promise<number> {
|
||||
if (!this.connected || !this.socket)
|
||||
throw new ServiceUnavailableException('bridge not connected');
|
||||
const { bytes, id } = await buildTextToRadio(to, text);
|
||||
const { bytes, id } = await buildTextToRadio(to, text, this.sendChannel);
|
||||
this.socket.write(bytes);
|
||||
return id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user