Skip to content

Commit

Permalink
Add support for Meshtastic (Bircom) (#303)
Browse files Browse the repository at this point in the history
  • Loading branch information
vicb authored Sep 2, 2024
1 parent 1cedc54 commit 936bf15
Show file tree
Hide file tree
Showing 24 changed files with 387 additions and 8 deletions.
14 changes: 13 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,17 @@
"statusBarItem.remoteForeground": "#e7e7e7"
},
"peacock.color": "#557c9b",
"git.ignoreLimitWarning": true
"git.ignoreLimitWarning": true,
"cSpell.words": [
"aprs",
"bircom",
"Flymaster",
"flyme",
"imei",
"inreach",
"meshbir",
"Meshtastic",
"xcontest",
"Zoleo"
]
}
9 changes: 9 additions & 0 deletions apps/fetcher/src/app/state/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const FLYMASTER = '003';
const OGN = `123456`;
const ZOLEO = `012345678912345`;
const XCONTEST = `a123456789012345678901234567`;
const MESHBIR = `12345678-1234-1234-1234-123456789012`;

describe('sync', () => {
let nowFn: any;
Expand Down Expand Up @@ -63,6 +64,7 @@ describe('sync', () => {
ogn: OGN,
zoleo: ZOLEO,
xcontest: XCONTEST,
meshbir: MESHBIR,
};

for (const [name, account] of Object.entries(trackerAccounts)) {
Expand Down Expand Up @@ -210,6 +212,7 @@ describe('sync', () => {
ogn: createTrackerEntity(OGN),
zoleo: createTrackerEntity(ZOLEO, { type: 'zoleo' }),
xcontest: createTrackerEntity(XCONTEST),
meshbir: createTrackerEntity(MESHBIR),
});
syncLiveTrack(state, lt);

Expand All @@ -221,6 +224,7 @@ describe('sync', () => {
expect(state.pilots[1978].ogn.enabled).toEqual(true);
expect(state.pilots[1978].zoleo.enabled).toEqual(true);
expect(state.pilots[1978].xcontest.enabled).toEqual(true);
expect(state.pilots[1978].meshbir.enabled).toEqual(true);

expect(state.pilots[1978].inreach.account).toEqual(INREACH);
expect(state.pilots[1978].spot.account).toEqual(SPOT);
Expand All @@ -230,6 +234,7 @@ describe('sync', () => {
expect(state.pilots[1978].ogn.account).toEqual(OGN);
expect(state.pilots[1978].zoleo.account).toEqual(ZOLEO);
expect(state.pilots[1978].xcontest.account).toEqual(XCONTEST);
expect(state.pilots[1978].meshbir.account).toEqual(MESHBIR);

lt = createLiveTrackEntity('1978', {
inreach: createTrackerEntity('invalid'),
Expand All @@ -240,6 +245,7 @@ describe('sync', () => {
ogn: createTrackerEntity('invalid'),
zoleo: createTrackerEntity('invalid', { type: 'zoleo' }),
xcontest: createTrackerEntity('invalid'),
meshbir: createTrackerEntity('invalid'),
});
syncLiveTrack(state, lt);

Expand All @@ -251,6 +257,7 @@ describe('sync', () => {
expect(state.pilots[1978].ogn.enabled).toEqual(false);
expect(state.pilots[1978].zoleo.enabled).toEqual(false);
expect(state.pilots[1978].xcontest.enabled).toEqual(false);
expect(state.pilots[1978].meshbir.enabled).toEqual(false);

expect(state.pilots[1978].inreach.account).toEqual('');
expect(state.pilots[1978].spot.account).toEqual('');
Expand All @@ -260,6 +267,7 @@ describe('sync', () => {
expect(state.pilots[1978].ogn.account).toEqual('');
expect(state.pilots[1978].zoleo.account).toEqual('');
expect(state.pilots[1978].xcontest.account).toEqual('');
expect(state.pilots[1978].meshbir.account).toEqual('');
});
});
});
Expand All @@ -282,6 +290,7 @@ function createLiveTrackEntity(id: string, liveTrack: Partial<LiveTrackEntity> =
ogn: createTrackerEntity(OGN),
zoleo: createTrackerEntity(ZOLEO, { type: 'zoleo' }),
xcontest: createTrackerEntity(XCONTEST),
meshbir: createTrackerEntity(MESHBIR),
};

return { ...entity, ...liveTrack };
Expand Down
1 change: 1 addition & 0 deletions apps/fetcher/src/app/state/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ function createPilotFromEntity(liveTrack: LiveTrackEntity): protos.Pilot {
...createAccountEnabledTracker('ogn', liveTrack),
zoleo,
...createAccountEnabledTracker('xcontest', liveTrack),
...createAccountEnabledTracker('meshbir', liveTrack),
};
}

Expand Down
66 changes: 66 additions & 0 deletions apps/fetcher/src/app/trackers/meshbir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { parse } from './meshbir';

describe('parse', () => {
describe('position', () => {
it('should translate message to points', () => {
expect(
parse([
{
altitude: 1778.12,
ground_speed: 30,
latitude: 32.1927,
longitude: 76.4506,
time: 123460,
type: 'position',
user_id: '12345678-1234-1234-1234-123456789012',
},
{
altitude: 1778.12,
ground_speed: 30,
latitude: 32.1927,
longitude: 76.4506,
time: 123450,
type: 'position',
user_id: '12345678-1234-1234-1234-123456789012',
},
]),
).toMatchInlineSnapshot(`
Map {
"12345678-1234-1234-1234-123456789012" => [
{
"alt": 1778.12,
"lat": 32.1927,
"lon": 76.4506,
"name": "meshbir",
"speed": 30,
"timeMs": 123460,
},
{
"alt": 1778.12,
"lat": 32.1927,
"lon": 76.4506,
"name": "meshbir",
"speed": 30,
"timeMs": 123450,
},
],
}
`);
});
});

describe('test', () => {
it('should silently ignore messages', () => {
expect(
parse([
{
type: 'message',
user_id: '12345678-1234-1234-1234-123456789012',
time: 123456,
message: 'hello Meshtastic',
},
]),
).toEqual(new Map());
});
});
});
107 changes: 107 additions & 0 deletions apps/fetcher/src/app/trackers/meshbir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// https://bircom.in/
// https://github.com/vicb/flyXC/issues/301

import type { protos, TrackerNames } from '@flyxc/common';
import { Keys, removeBeforeFromLiveTrack, validateMeshBirAccount } from '@flyxc/common';
import type { MeshBirMessage } from '@flyxc/common-node';
import type { ChainableCommander, Redis } from 'ioredis';

import type { LivePoint } from './live-track';
import { makeLiveTrack } from './live-track';
import type { TrackerUpdates } from './tracker';
import { TrackerFetcher } from './tracker';

const KEEP_HISTORY_MIN = 20;

export class MeshBirFetcher extends TrackerFetcher {
constructor(state: protos.FetcherState, pipeline: ChainableCommander, protected redis: Redis) {
super(state, pipeline);
}

protected getTrackerName(): TrackerNames {
return 'meshbir';
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async fetch(devices: number[], updates: TrackerUpdates, timeoutSec: number): Promise<void> {
const messages = (await flushMessageQueue(this.redis)).filter((m) => m != null);

if (messages.length == 0) {
return;
}

// Maps meshbir ids to datastore ids.
const meshIdToDsId = new Map<string, number>();
for (const dsId of devices) {
const tracker = this.getTracker(dsId);
if (tracker == null) {
updates.trackerErrors.set(dsId, `Not found ${tracker.account}`);
continue;
}
if (validateMeshBirAccount(tracker.account) === false) {
updates.trackerErrors.set(dsId, `Invalid account ${tracker.account}`);
continue;
}
meshIdToDsId.set(tracker.account, dsId);
}

const pointsByMeshId = parse(messages);

for (const [meshId, points] of pointsByMeshId.entries()) {
const dsId = meshIdToDsId.get(meshId);
if (dsId != null) {
const liveTrack = removeBeforeFromLiveTrack(
makeLiveTrack(points),
Math.round(Date.now() / 1000) - KEEP_HISTORY_MIN * 60,
);
if (liveTrack.timeSec.length > 0) {
updates.trackerDeltas.set(dsId, liveTrack);
}
}
}
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldFetch(tracker: protos.Tracker) {
return true;
}
}

export function parse(messages: MeshBirMessage[]): Map<string, LivePoint[]> {
const pointsByMeshId = new Map<string, LivePoint[]>();
for (const msg of messages) {
if (msg.type == 'position') {
const point: LivePoint = {
lat: msg.latitude,
lon: msg.longitude,
alt: msg.altitude,
speed: msg.ground_speed,
timeMs: msg.time,
name: 'meshbir',
};
const meshId = validateMeshBirAccount(msg.user_id);
if (meshId !== false) {
const points = pointsByMeshId.get(meshId) ?? [];
points.push(point);
pointsByMeshId.set(meshId, points);
}
}
}
return pointsByMeshId;
}

// Returns and empty the message queue.
async function flushMessageQueue(redis: Redis): Promise<MeshBirMessage[]> {
try {
const [[_, messages]] = await redis
.multi()
.lrange(Keys.meshBirMsgQueue, 0, -1)
.ltrim(Keys.meshBirMsgQueue, 1, 0)
.exec();

// Return older messages first
return (messages as string[]).map((json) => JSON.parse(json)).reverse();
} catch (e) {
console.error('Error reading meshbir queue', e);
}
}
2 changes: 2 additions & 0 deletions apps/fetcher/src/app/trackers/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { addElevationLogs } from '../redis';
import { FlymasterFetcher } from './flymaster';
import { FlymeFetcher } from './flyme';
import { InreachFetcher } from './inreach';
import { MeshBirFetcher } from './meshbir';
import { OgnFetcher } from './ogn';
import { OGN_HOST, OGN_PORT, OgnClient } from './ogn-client';
import { SkylinesFetcher } from './skylines';
Expand Down Expand Up @@ -60,6 +61,7 @@ export async function resfreshTrackers(
new OgnFetcher(ognClient, state, pipeline),
new ZoleoFetcher(state, pipeline, redis, datastore),
new XcontestFetcher(state, pipeline),
new MeshBirFetcher(state, pipeline, redis),
];

const fetchResults = await Promise.allSettled(fetchers.map((f) => f.refresh(LIVE_FETCH_TIMEOUT_SEC)));
Expand Down
2 changes: 1 addition & 1 deletion apps/fetcher/src/app/trackers/zoleo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ async function addNewDevices(datastore: Datastore, messages: ZoleoMessage[]): Pr
return addedDevices;
}

// Reand and empty the message queue.
// Returns and empty the message queue.
async function flushMessageQueue(redis: Redis): Promise<(ZoleoMessage | null)[]> {
try {
const [[_, messages]] = await redis
Expand Down
14 changes: 13 additions & 1 deletion apps/fxc-front/src/app/pages/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,18 @@ export class SettingsPage extends LitElement {
>
</device-card>
</ion-col>
</ion-row> `,
<ion-col size=12 size-lg=6>
<device-card
.tracker=${'meshbir'}
.binder=${this.binder}
label="UUID"
.hint=${html`<ion-text class="ion-padding-horizontal ion-padding-top block">
Enter your Meshtastic UUID. It should look like "12345678-ab45-b23c-8549-1f3456c89e12".
</ion-text>`}
>
</device-card>
</ion-col>
</ion-row>`,
)}
</ion-grid>
</ion-content>
Expand Down Expand Up @@ -489,6 +500,7 @@ export class SettingsPage extends LitElement {
<a href="https://www.glidernet.org/" target="_blank">OGN (Open Glider Network)</a>
</li>
<li><a href="https://live.xcontest.org/" target="_blank">XCTrack (XContest live)</a></li>
<li><a href="https://bircom.in/" target="_blank">Meshtastic (Bircom)</a></li>
</ul>
<p>
<a href="mailto:[email protected]?subject=flyXC%20registration%20error" target="_blank"> Contact us by email </a>
Expand Down
49 changes: 49 additions & 0 deletions apps/fxc-server/src/app/routes/meshbir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { parseMessage } from './meshbir';

describe('parseMessage', () => {
it('should parse a position', () => {
expect(
parseMessage({
type: 'position',
user_id: '12345678-1234-1234-1234-123456789012',
time: 123456,
latitude: 32.1927,
longitude: 76.4506,
altitude: 1778.12,
ground_speed: 30,
}),
).toMatchInlineSnapshot(`
{
"altitude": 1778.12,
"ground_speed": 30,
"latitude": 32.1927,
"longitude": 76.4506,
"time": 123456,
"type": "position",
"user_id": "12345678-1234-1234-1234-123456789012",
}
`);
});

it('should parse a test', () => {
expect(
parseMessage({
type: 'message',
user_id: '12345678-1234-1234-1234-123456789012',
time: 123456,
message: 'hello Meshtastic',
}),
).toMatchInlineSnapshot(`
{
"message": "hello Meshtastic",
"time": 123456,
"type": "message",
"user_id": "12345678-1234-1234-1234-123456789012",
}
`);
});

it('should throw on invalid message', () => {
expect(() => parseMessage({ type: 'unkown' })).toThrow();
});
});
Loading

0 comments on commit 936bf15

Please sign in to comment.