Compare commits

18 Commits

Author SHA1 Message Date
2f76091ae6 fix nodeinfo and telemetry
All checks were successful
Build Docker containers / Build (push) Successful in 42s
2025-04-18 13:06:11 +00:00
0f4035ec3a add debug-incoming-packets option
All checks were successful
Build Docker containers / Build (push) Successful in 42s
2025-04-17 14:22:37 +00:00
18df989fc6 fix markercluster issues
All checks were successful
Build Docker containers / Build (push) Successful in 40s
2025-04-16 01:49:20 -04:00
e694ffa66a remove extra )
All checks were successful
Build Docker containers / Build (push) Successful in 40s
2025-04-16 00:54:04 -04:00
da99bfdeef add arrowheads plugin 2025-04-16 00:53:54 -04:00
b2a9488efd add back text-message-embed
Some checks failed
Build Docker containers / Build (push) Failing after 10s
2025-04-16 00:50:27 -04:00
6b7149905a fix background colors in traceroute window
Some checks failed
Build Docker containers / Build (push) Failing after 10s
2025-04-16 00:43:50 -04:00
8497053aed fix missing nodes, fix issue with traceroutes
All checks were successful
Build Docker containers / Build (push) Successful in 44s
2025-04-16 00:37:24 -04:00
bfdd6cba22 fix nodes all showing offline
All checks were successful
Build Docker containers / Build (push) Successful in 42s
2025-04-16 00:30:03 -04:00
6554d270bc fix call to HardwareInfo
All checks were successful
Build Docker containers / Build (push) Successful in 39s
2025-04-16 00:28:05 -04:00
bcaa1f2c20 fix typo in neighbor-info
All checks were successful
Build Docker containers / Build (push) Successful in 49s
2025-04-16 00:23:09 -04:00
0a7e456173 disable cache when building
All checks were successful
Build Docker containers / Build (push) Successful in 42s
2025-04-16 00:17:40 -04:00
d50fe75759 use ES modules
All checks were successful
Build Docker containers / Build (push) Successful in 8s
2025-04-16 00:15:50 -04:00
d963520486 use EM modules
All checks were successful
Build Docker containers / Build (push) Successful in 21s
2025-04-16 00:13:12 -04:00
e025140ab4 switch to ES modules
All checks were successful
Build Docker containers / Build (push) Successful in 47s
2025-04-16 00:08:48 -04:00
e8095fce81 update docker-compose
All checks were successful
Build Docker containers / Build (push) Successful in 17s
2025-04-15 23:48:54 -04:00
af5690e524 rewrite parser for mqtt
All checks were successful
Build Docker containers / Build (push) Successful in 30s
2025-04-15 21:26:45 -04:00
33726de0cb start work on lora ingest app
All checks were successful
Build Docker containers / Build (push) Successful in 16s
2025-04-15 20:16:04 -04:00
21 changed files with 1691 additions and 647 deletions

View File

@ -13,11 +13,11 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Build web app image - name: Build web app image
run: docker build -f webapp/Dockerfile -t git.arinity.org/ctmesh/map:latest . run: docker build --no-cache -f webapp/Dockerfile -t git.arinity.org/ctmesh/map:latest .
- name: Build mqtt listener image - name: Build mqtt listener image
run: docker build -f mqtt/Dockerfile -t git.arinity.org/ctmesh/map-mqtt:latest . run: docker build --no-cache -f mqtt/Dockerfile -t git.arinity.org/ctmesh/map-mqtt:latest .
- name: Build cli image - name: Build cli image
run: docker build -f cli/Dockerfile -t git.arinity.org/ctmesh/map-cli:latest . run: docker build --no-cache -f cli/Dockerfile -t git.arinity.org/ctmesh/map-cli:latest .
- name: Push images - name: Push images
run: | run: |
docker push git.arinity.org/ctmesh/map:latest docker push git.arinity.org/ctmesh/map:latest

View File

@ -6,7 +6,7 @@ services:
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
command: "--mqtt-topic=msh/US/#" command: "--mqtt-broker-url= --mqtt-topic=msh/US/# --collect-service-envelopes --collect-neighbor-info --collect-waypoints"
environment: environment:
DATABASE_URL: "mysql://root:password@database:3306/meshtastic-map?connection_limit=100" DATABASE_URL: "mysql://root:password@database:3306/meshtastic-map?connection_limit=100"

1
lora/.npmrc Normal file
View File

@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

60
lora/LoraStream.js Normal file
View File

@ -0,0 +1,60 @@
import { Transform } from 'stream';
export default class LoraStream extends Transform {
constructor(options) {
super(options);
this.byteBuffer = new Uint8Array([]);
this.textDecoder = new TextDecoder();
}
_transform(chunk, encoding, callback) {
this.byteBuffer = new Uint8Array([
...this.byteBuffer,
...chunk,
]);
let processingExhausted = false;
while (this.byteBuffer.length !== 0 && !processingExhausted) {
const framingIndex = this.byteBuffer.findIndex((byte) => byte === 0x94);
const framingByte2 = this.byteBuffer[framingIndex + 1];
if (framingByte2 === 0xc3) {
if (this.byteBuffer.subarray(0, framingIndex).length) {
this.byteBuffer = this.byteBuffer.subarray(framingIndex);
}
const msb = this.byteBuffer[2];
const lsb = this.byteBuffer[3];
if (msb !== undefined && lsb !== undefined && this.byteBuffer.length >= 4 + (msb << 8) + lsb) {
const packet = this.byteBuffer.subarray(4, 4 + (msb << 8) + lsb);
const malformedDetectorIndex = packet.findIndex(
(byte) => byte === 0x94,
);
if (malformedDetectorIndex !== -1 && packet[malformedDetectorIndex + 1] === 0xc3) {
// malformed
this.byteBuffer = this.byteBuffer.subarray(malformedDetectorIndex);
} else {
this.byteBuffer = this.byteBuffer.subarray(3 + (msb << 8) + lsb + 1);
this.push(packet);
}
} else {
/** Only partioal message in buffer, wait for the rest */
processingExhausted = true;
}
} else {
/** Message not complete, only 1 byte in buffer */
processingExhausted = true;
}
}
callback();
}
_flush(callback) {
try {
if (this._buffer) {
this.push(this._buffer.trim());
}
callback();
} catch (err) {
callback(err);
}
}
}

62
lora/MeshtasticStream.js Normal file
View File

@ -0,0 +1,62 @@
import { Transform } from 'stream';
import { Mesh, Channel, Config, ModuleConfig } from '@meshtastic/protobufs';
import { fromBinary, create } from '@bufbuild/protobuf';
export default class MeshtasticStream extends Transform {
constructor(options) {
super({ readableObjectMode: true, ...options });
}
_transform(chunk, encoding, callback) {
const dataPacket = fromBinary(Mesh.FromRadioSchema, chunk);
let schema = null;
switch(dataPacket.payloadVariant.case) {
case 'packet':
schema = Mesh.MeshPacketSchema;
break;
case 'nodeInfo':
schema = Mesh.NodeInfoSchema;
break;
case 'myInfo':
schema = Mesh.MyNodeInfoSchema;
break;
case 'deviceuiConfig':
// can't find, come back to this
break;
case 'config':
schema = Config.ConfigSchema
break;
case 'moduleConfig':
schema = ModuleConfig.ModuleConfigSchema
break;
case 'fileInfo':
schema = Mesh.FileInfoSchema
break;
case 'channel':
schema = Channel.ChannelSchema
break;
case 'metadata':
schema = Mesh.DeviceMetadataSchema
break;
case 'logRecord':
schema = Mesh.LogRecordSchema
break;
case 'configCompleteId':
// done sending init data
break;
}
if (schema !== null) {
this.push(create(schema, dataPacket.payloadVariant.value));
}
callback();
}
_flush(callback) {
try {
if (this._buffer) {
this.push(this._buffer.trim());
}
callback();
} catch (err) {
callback(err);
}
}
}

107
lora/index.js Normal file
View File

@ -0,0 +1,107 @@
import { Mesh, Mqtt, Portnums, Telemetry, Config, Channel } from '@meshtastic/protobufs';
import { fromBinary, toBinary, create } from '@bufbuild/protobuf';
import { LoraStream } from './LoraStream';
import { MeshtasticStream } from './MeshtasticStream';
import net from 'net';
const client = new net.Socket();
const IP = '192.168.10.117';
const PORT = 4403;
function sendHello() {
const data = create(Mesh.ToRadioSchema, {
payloadVariant: {
case: 'wantConfigId',
value: 1,
}
});
sendToDevice(toBinary(Mesh.ToRadioSchema, data));
}
function sendToDevice(data) {
const bufferLength = data.length;
const header = new Uint8Array([
0x94,
0xC3,
(bufferLength >> 8) & 0xFF,
bufferLength & 0xFF,
]);
data = new Uint8Array([...header, ...data]);
client.write(data);
}
client.connect(PORT, IP, function() {
console.log('Connected');
sendHello();
});
const meshtasticStream = new MeshtasticStream();
client.pipe(new LoraStream()).pipe(meshtasticStream);
meshtasticStream.on('data', (data) => {
parseMeshtastic(data['$typeName'], data);
})
function parseMeshtastic(typeName, data) {
switch(typeName) {
case Mesh.MeshPacketSchema.typeName:
onMeshPacket(data);
break;
case Mesh.NodeInfoSchema.typeName:
console.log('node info');
break;
}
}
function onMeshPacket(envelope) {
const payloadVariant = envelope.payloadVariant.case;
if (payloadVariant === 'encrypted') {
// attempt decryption
}
const dataPacket = envelope.payloadVariant.value;
const portNum = dataPacket.portnum;
if (!portNum) {
return;
}
let schema = null;
switch (portNum) {
case Portnums.PortNum.POSITION_APP:
schema = Mesh.PositionSchema;
break;
case Portnums.PortNum.TELEMETRY_APP:
schema = Telemetry.TelemetrySchema;
break;
case Portnums.PortNum.TEXT_MESSAGE_APP:
// no schema?
break;
case Portnums.PortNum.WAYPOINT_APP:
schema = Mesh.WaypointSchema;
break;
case Portnums.PortNum.TRACEROUTE_APP:
schema = Mesh.RouteDiscoverySchema;
break;
case Portnums.PortNum.NODEINFO_APP:
schema = Mesh.NodeInfoSchema;
break;
case Portnums.PortNum.NEIGHBORINFO_APP:
schema = Mesh.NeighborInfoSchema;
break;
case Portnums.PortNum.MAP_REPORT_APP:
schema = Mqtt.MapReportSchema;
break;
}
let decodedData = dataPacket.payload;
if (schema !== null) {
try {
decodedData = fromBinary(schema, decodedData);
console.log(decodedData);
} catch(e) {
// ignore errors, likely incomplete data
return;
}
}
}

341
lora/package-lock.json generated Normal file
View File

@ -0,0 +1,341 @@
{
"name": "lora",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lora",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.6.2",
"@prisma/client": "^5.11.0",
"command-line-args": "^5.2.1",
"command-line-usage": "^7.0.1"
},
"devDependencies": {
"prisma": "^5.10.2"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.5.tgz",
"integrity": "sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@meshtastic/protobufs": {
"name": "@jsr/meshtastic__protobufs",
"version": "2.6.2",
"resolved": "https://npm.jsr.io/~/11/@jsr/meshtastic__protobufs/2.6.2.tgz",
"integrity": "sha512-bIENtFnUEru28GrAeSdiBS9skp0hN/3HZunMbF/IjvUrXOlx2fptKVj3b+pzjOWnLBZxllrByV/W+XDmrxqJ6g==",
"dependencies": {
"@bufbuild/protobuf": "^2.2.3"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/array-back": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk-template": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
"integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/chalk-template?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/command-line-args": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz",
"integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==",
"license": "MIT",
"dependencies": {
"array-back": "^3.1.0",
"find-replace": "^3.0.0",
"lodash.camelcase": "^4.3.0",
"typical": "^4.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/command-line-usage": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz",
"integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==",
"license": "MIT",
"dependencies": {
"array-back": "^6.2.2",
"chalk-template": "^0.4.0",
"table-layout": "^4.1.0",
"typical": "^7.1.1"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/command-line-usage/node_modules/array-back": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz",
"integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/command-line-usage/node_modules/typical": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz",
"integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/find-replace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
"license": "MIT",
"dependencies": {
"array-back": "^3.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/table-layout": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz",
"integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==",
"license": "MIT",
"dependencies": {
"array-back": "^6.2.2",
"wordwrapjs": "^5.1.0"
},
"engines": {
"node": ">=12.17"
}
},
"node_modules/table-layout/node_modules/array-back": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz",
"integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/typical": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wordwrapjs": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz",
"integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
}
}
}

19
lora/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "lora",
"version": "1.0.0",
"main": "index.js",
"author": "",
"license": "ISC",
"description": "",
"type": "module",
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.6.2",
"@prisma/client": "^5.11.0",
"command-line-args": "^5.2.1",
"command-line-usage": "^7.0.1"
},
"devDependencies": {
"prisma": "^5.10.2"
}
}

View File

@ -1,14 +1,13 @@
const crypto = require("crypto"); import crypto from 'crypto';
const mqtt = require("mqtt"); import mqtt from "mqtt";
const commandLineArgs = require("command-line-args"); import commandLineArgs from 'command-line-args';
const commandLineUsage = require("command-line-usage"); import commandLineUsage from 'command-line-usage';
const PositionUtil = require("./utils/position_util"); import { fromBinary } from '@bufbuild/protobuf';
import { Mesh, Mqtt, Portnums, Telemetry } from '@meshtastic/protobufs';
const { Mesh, Mqtt, Portnums, Telemetry } = require("@meshtastic/protobufs"); import PositionUtil from './utils/position_util.js';
const { fromBinary } = require("@bufbuild/protobuf");
// create prisma db client // create prisma db client
const { PrismaClient } = require("@prisma/client"); import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// meshtastic bitfield flags // meshtastic bitfield flags
@ -87,9 +86,9 @@ const optionsList = [
description: "This option will save all received waypoints to the database.", description: "This option will save all received waypoints to the database.",
}, },
{ {
name: "collect-neighbour-info", name: "collect-neighbor-info",
type: Boolean, type: Boolean,
description: "This option will save all received neighbour infos to the database.", description: "This option will save all received neighbor infos to the database.",
}, },
{ {
name: "collect-map-reports", name: "collect-map-reports",
@ -108,6 +107,11 @@ const optionsList = [
type: Boolean, type: Boolean,
description: "This option will drop all packets that have 'OK to MQTT' set to false.", description: "This option will drop all packets that have 'OK to MQTT' set to false.",
}, },
{
name: "debug-incoming-packets",
type: Boolean,
description: "This option will print out all known packets as they arrive.",
},
{ {
name: "drop-portnums-without-bitfield", name: "drop-portnums-without-bitfield",
type: Number, type: Number,
@ -203,7 +207,7 @@ if(options.help){
}, },
]); ]);
console.log(usage); console.log(usage);
return; process.exit(1);
} }
// get options and fallback to default values // get options and fallback to default values
@ -214,16 +218,19 @@ const mqttClientId = options["mqtt-client-id"] ?? "mqttx_1bc723c7";
const mqttTopics = options["mqtt-topic"] ?? ["msh/US/#"]; const mqttTopics = options["mqtt-topic"] ?? ["msh/US/#"];
const allowedPortnums = options["allowed-portnums"] ?? null; const allowedPortnums = options["allowed-portnums"] ?? null;
const logUnknownPortnums = options["log-unknown-portnums"] ?? false; const logUnknownPortnums = options["log-unknown-portnums"] ?? false;
const collectServiceEnvelopes = options["collect-service-envelopes"] ?? false; const collectorEnabled = {
const collectPositions = options["collect-positions"] ?? false; serviceEnvelopes: options["collect-service-envelopes"] ?? false,
const collectTextMessages = options["collect-text-messages"] ?? false; positions: options["collect-positions"] ?? false,
const ignoreDirectMessages = options["ignore-direct-messages"] ?? false; textMessages: options["collect-text-messages"] ?? false,
const collectWaypoints = options["collect-waypoints"] ?? false; waypoints: options["collect-waypoints"] ?? false,
const collectNeighbourInfo = options["collect-neighbour-info"] ?? false; mapReports: options["collect-map-reports"] ?? false,
const collectMapReports = options["collect-map-reports"] ?? false; directMessages: !(options["ignore-direct-messages"] ?? false),
neighborInfo: options["collect-neighbor-info"] ?? false,
}
const decryptionKeys = options["decryption-keys"] ?? [ const decryptionKeys = options["decryption-keys"] ?? [
"1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key "1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key
]; ];
const logKnownPacketTypes = options["debug-incoming-packets"] ?? false;
const dropPacketsNotOkToMqtt = options["drop-packets-not-ok-to-mqtt"] ?? false; const dropPacketsNotOkToMqtt = options["drop-packets-not-ok-to-mqtt"] ?? false;
const dropPortnumsWithoutBitfield = options["drop-portnums-without-bitfield"] ?? null; const dropPortnumsWithoutBitfield = options["drop-portnums-without-bitfield"] ?? null;
const oldFirmwarePositionPrecision = options["old-firmware-position-precision"] ?? null; const oldFirmwarePositionPrecision = options["old-firmware-position-precision"] ?? null;
@ -241,6 +248,7 @@ const purgeTextMessagesAfterSeconds = options["purge-text-messages-after-seconds
const purgeTraceroutesAfterSeconds = options["purge-traceroutes-after-seconds"] ?? null; const purgeTraceroutesAfterSeconds = options["purge-traceroutes-after-seconds"] ?? null;
const purgeWaypointsAfterSeconds = options["purge-waypoints-after-seconds"] ?? null; const purgeWaypointsAfterSeconds = options["purge-waypoints-after-seconds"] ?? null;
// create mqtt client // create mqtt client
const client = mqtt.connect(mqttBrokerUrl, { const client = mqtt.connect(mqttBrokerUrl, {
username: mqttUsername, username: mqttUsername,
@ -266,6 +274,15 @@ if(purgeIntervalSeconds){
}, purgeIntervalSeconds * 1000); }, purgeIntervalSeconds * 1000);
} }
function printStatus() {
console.log(`MQTT server: ${mqttBrokerUrl}`)
console.log(`MQTT topic(s): ${mqttTopics.join(', ')}`)
for (const key of Object.keys(collectorEnabled)) {
console.log(`${key} collection: ${collectorEnabled[key] ? 'enabled' : 'disabled'}`)
}
}
printStatus();
/** /**
* Purges all nodes from the database that haven't been heard from within the configured timeframe. * Purges all nodes from the database that haven't been heard from within the configured timeframe.
*/ */
@ -666,14 +683,14 @@ function convertHexIdToNumericId(hexId) {
} }
// subscribe to everything when connected // subscribe to everything when connected
client.on("connect", () => { client.on('connect', () => {
for(const mqttTopic of mqttTopics){ for(const mqttTopic of mqttTopics){
client.subscribe(mqttTopic); client.subscribe(mqttTopic);
} }
}); });
// handle message received // handle message received
client.on("message", async (topic, message) => { client.on('message', async (topic, message) => {
try { try {
// decode service envelope // decode service envelope
let envelope = null; let envelope = null;
@ -687,7 +704,7 @@ client.on("message", async (topic, message) => {
let dataPacket = envelope.packet.payloadVariant.value; let dataPacket = envelope.packet.payloadVariant.value;
// attempt to decrypt encrypted packets // attempt to decrypt encrypted packets
if (envelope.packet.payloadVariant.case === 'encrypted') { if (envelope.packet.payloadVariant.case === 'encrypted') {
dataPacket = decrypt(envelope.packet); envelope.packet.payloadVariant.value = dataPacket = decrypt(envelope.packet);
} }
if (dataPacket !== null) { if (dataPacket !== null) {
@ -712,7 +729,7 @@ client.on("message", async (topic, message) => {
} }
// create service envelope in db // create service envelope in db
if(collectServiceEnvelopes){ if (collectorEnabled.serviceEnvelopes) {
try { try {
await prisma.serviceEnvelope.create({ await prisma.serviceEnvelope.create({
data: { data: {
@ -750,7 +767,7 @@ client.on("message", async (topic, message) => {
return; return;
} }
const payload = dataPacket.payload; let payload = dataPacket.payload;
// get portnum from decoded packet // get portnum from decoded packet
const portnum = dataPacket.portnum; const portnum = dataPacket.portnum;
// get bitfield from decoded packet // get bitfield from decoded packet
@ -758,32 +775,100 @@ client.on("message", async (topic, message) => {
// this value will be null for packets from v2.4.x and below, and will be an integer in v2.5.x and above // this value will be null for packets from v2.4.x and below, and will be an integer in v2.5.x and above
const bitfield = dataPacket.bitfield; const bitfield = dataPacket.bitfield;
const logKnownPacketTypes = false;
// if allowed portnums are configured, ignore portnums that are not in the list // if allowed portnums are configured, ignore portnums that are not in the list
if(allowedPortnums != null && !allowedPortnums.includes(portnum)){ if(allowedPortnums != null && !allowedPortnums.includes(portnum)){
return; return;
} }
if(portnum === Portnums.PortNum.TEXT_MESSAGE_APP) { let callback = null;
let schema = null;
if(!collectTextMessages){ switch(portnum) {
case Portnums.PortNum.TEXT_MESSAGE_APP:
callback = onTextMessage;
break;
case Portnums.PortNum.POSITION_APP:
callback = onPosition;
schema = Mesh.PositionSchema;
break;
case Portnums.PortNum.NODEINFO_APP:
callback = onNodeInfo;
schema = Mesh.UserSchema;
break;
case Portnums.PortNum.WAYPOINT_APP:
callback = onWaypoint;
schema = Mesh.WaypointSchema;
break;
case Portnums.PortNum.NEIGHBORINFO_APP:
callback = onNeighborInfo;
schema = Mesh.NeighborInfoSchema;
break;
case Portnums.PortNum.TELEMETRY_APP:
callback = onTelemetry;
schema = Telemetry.TelemetrySchema;
break;
case Portnums.PortNum.TRACEROUTE_APP:
callback = onRouteDiscovery;
schema = Mesh.RouteDiscoverySchema;
break;
case Portnums.PortNum.MAP_REPORT_APP:
callback = onMapReport;
schema = Mqtt.MapReportSchema;
break;
default:
// handle unknown port nums here
if (logUnknownPortnums) {
const ignoredPortnums = [
Portnums.PortNum.UNKNOWN_APP,
Portnums.PortNum.TEXT_MESSAGE_COMPRESSED_APP,
Portnums.PortNum.ROUTING_APP,
Portnums.PortNum.PAXCOUNTER_APP,
Portnums.PortNum.STORE_FORWARD_APP,
Portnums.PortNum.RANGE_TEST_APP,
Portnums.PortNum.ATAK_PLUGIN,
Portnums.PortNum.ATAK_FORWARDER,
];
// ignore packets we don't want to see for now
if (portnum === undefined || ignoredPortnums.includes(portnum) || portnum > 511) {
return; return;
} }
console.log(portnum, envelope);
// check if we want to ignore direct messages }
if(ignoreDirectMessages && envelope.packet.to !== 0xFFFFFFFF){
return;
} }
if (callback !== null) {
if (schema !== null) {
try {
payload = fromBinary(schema, payload);
} catch(e) {
return;
}
}
await callback(envelope, payload);
}
} catch(e) {
// ignore errors
}
});
async function onTextMessage(envelope, payload) {
if(logKnownPacketTypes) { if(logKnownPacketTypes) {
console.log("TEXT_MESSAGE_APP", { console.log('TEXT_MESSAGE_APP', {
to: envelope.packet.to.toString(16), to: envelope.packet.to.toString(16),
from: envelope.packet.from.toString(16), from: envelope.packet.from.toString(16),
text: payload.toString(), text: payload.toString(),
}); });
} }
if (!collectorEnabled.textMessages) {
return;
}
// check if we want to ignore direct messages
if(!collectorEnabled.directMessages && envelope.packet.to !== 0xFFFFFFFF){
return;
}
try { try {
await prisma.textMessage.create({ await prisma.textMessage.create({
data: { data: {
@ -803,32 +888,30 @@ client.on("message", async (topic, message) => {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
else if(portnum === Portnums.PortNum.POSITION_APP) { async function onPosition(envelope, payload) {
const position = fromBinary(Mesh.PositionSchema, payload);
if(logKnownPacketTypes){ if(logKnownPacketTypes){
console.log("POSITION_APP", { console.log('POSITION_APP', {
from: envelope.packet.from.toString(16), from: envelope.packet.from.toString(16),
position: position, position: payload,
}); });
} }
// process position // process position
if(position.latitudeI != null && position.longitudeI){ if(payload.latitudeI != null && payload.longitudeI){
const bitfield = envelope.packet.payloadVariant.value.bitfield;
// if bitfield is not available, we are on firmware v2.4 or below // if bitfield is not available, we are on firmware v2.4 or below
// if configured, position packets should have their precision reduced // if configured, position packets should have their precision reduced
if (bitfield == null && oldFirmwarePositionPrecision != null) { if (bitfield == null && oldFirmwarePositionPrecision != null) {
// adjust precision of latitude and longitude // adjust precision of latitude and longitude
position.latitudeI = PositionUtil.setPositionPrecision(position.latitudeI, oldFirmwarePositionPrecision); payload.latitudeI = PositionUtil.setPositionPrecision(payload.latitudeI, oldFirmwarePositionPrecision);
position.longitudeI = PositionUtil.setPositionPrecision(position.longitudeI, oldFirmwarePositionPrecision); payload.longitudeI = PositionUtil.setPositionPrecision(payload.longitudeI, oldFirmwarePositionPrecision);
// update position precision on packet to show that it is no longer full precision // update position precision on packet to show that it is no longer full precision
position.precisionBits = oldFirmwarePositionPrecision; payload.precisionBits = oldFirmwarePositionPrecision;
} }
@ -840,10 +923,10 @@ client.on("message", async (topic, message) => {
}, },
data: { data: {
position_updated_at: new Date(), position_updated_at: new Date(),
latitude: position.latitudeI, latitude: payload.latitudeI,
longitude: position.longitudeI, longitude: payload.longitudeI,
altitude: position.altitude !== 0 ? position.altitude : null, altitude: payload.altitude !== 0 ? payload.altitude : null,
position_precision: position.precisionBits, position_precision: payload.precisionBits,
}, },
}); });
} catch (e) { } catch (e) {
@ -853,7 +936,7 @@ client.on("message", async (topic, message) => {
} }
// don't collect position history if not enabled, but we still want to update the node above // don't collect position history if not enabled, but we still want to update the node above
if(!collectPositions){ if (!collectorEnabled.positions) {
return; return;
} }
@ -881,9 +964,9 @@ client.on("message", async (topic, message) => {
packet_id: envelope.packet.id, packet_id: envelope.packet.id,
channel_id: envelope.channelId, channel_id: envelope.channelId,
gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null, gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null,
latitude: position.latitudeI, latitude: payload.latitudeI,
longitude: position.longitudeI, longitude: payload.longitudeI,
altitude: position.altitude, altitude: payload.altitude,
}, },
}); });
} }
@ -891,17 +974,13 @@ client.on("message", async (topic, message) => {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
else if(portnum === Portnums.PortNum.NODEINFO_APP) { async function onNodeInfo(envelope, payload) {
const user = fromBinary(Mesh.UserSchema, payload);
if(logKnownPacketTypes) { if(logKnownPacketTypes) {
console.log("NODEINFO_APP", { console.log('NODEINFO_APP', {
from: envelope.packet.from.toString(16), from: envelope.packet.from.toString(16),
user: user, user: payload,
}); });
} }
@ -913,55 +992,51 @@ client.on("message", async (topic, message) => {
}, },
create: { create: {
node_id: envelope.packet.from, node_id: envelope.packet.from,
long_name: user.longName, long_name: payload.longName,
short_name: user.shortName, short_name: payload.shortName,
hardware_model: user.hwModel, hardware_model: payload.hwModel,
is_licensed: user.isLicensed === true, is_licensed: payload.isLicensed === true,
role: user.role, role: payload.role,
}, },
update: { update: {
long_name: user.longName, long_name: payload.longName,
short_name: user.shortName, short_name: payload.shortName,
hardware_model: user.hwModel, hardware_model: payload.hwModel,
is_licensed: user.isLicensed === true, is_licensed: payload.isLicensed === true,
role: user.role, role: payload.role,
}, },
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
else if(portnum === Portnums.PortNum.WAYPOINT_APP) { async function onWaypoint(envelope, payload) {
if(!collectWaypoints){
return;
}
const waypoint = fromBinary(Mesh.WaypointSchema, payload);
if(logKnownPacketTypes) { if(logKnownPacketTypes) {
console.log("WAYPOINT_APP", { console.log('WAYPOINT_APP', {
to: envelope.packet.to.toString(16), to: envelope.packet.to.toString(16),
from: envelope.packet.from.toString(16), from: envelope.packet.from.toString(16),
waypoint: waypoint, waypoint: payload,
}); });
} }
if (!collectorEnabled.waypoints) {
return;
}
try { try {
await prisma.waypoint.create({ await prisma.waypoint.create({
data: { data: {
to: envelope.packet.to, to: envelope.packet.to,
from: envelope.packet.from, from: envelope.packet.from,
waypoint_id: waypoint.id, waypoint_id: payload.id,
latitude: waypoint.latitudeI, latitude: payload.latitudeI,
longitude: waypoint.longitudeI, longitude: payload.longitudeI,
expire: waypoint.expire, expire: payload.expire,
locked_to: waypoint.lockedTo, locked_to: payload.lockedTo,
name: waypoint.name, name: payload.name,
description: waypoint.description, description: payload.description,
icon: waypoint.icon, icon: payload.icon,
channel: envelope.packet.channel, channel: envelope.packet.channel,
packet_id: envelope.packet.id, packet_id: envelope.packet.id,
channel_id: envelope.channelId, channel_id: envelope.channelId,
@ -971,17 +1046,103 @@ client.on("message", async (topic, message) => {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
else if(portnum === Portnums.PortNum.NEIGHBORINFO_APP) { async function onMapReport(envelope, payload) {
const neighbourInfo = fromBinary(Mesh.NeighborInfoSchema, payload);
if(logKnownPacketTypes) { if(logKnownPacketTypes) {
console.log("NEIGHBORINFO_APP", { console.log('MAP_REPORT_APP', {
from: envelope.packet.from.toString(16), from: envelope.packet.from.toString(16),
neighbour_info: neighbourInfo, map_report: payload,
});
}
// create or update node in db
try {
// data to set on node
const data = {
long_name: payload.longName,
short_name: payload.shortName,
hardware_model: payload.hwModel,
role: payload.role,
latitude: payload.latitudeI,
longitude: payload.longitudeI,
altitude: payload.altitude !== 0 ? payload.altitude : null,
firmware_version: payload.firmwareVersion,
region: payload.region,
modem_preset: payload.modemPreset,
has_default_channel: payload.hasDefaultChannel,
position_precision: payload.positionPrecision,
num_online_local_nodes: payload.numOnlineLocalNodes,
position_updated_at: new Date(),
};
await prisma.node.upsert({
where: {
node_id: envelope.packet.from,
},
create: {
node_id: envelope.packet.from,
...data,
},
update: data,
});
} catch (e) {
console.error(e);
}
// don't collect map report history if not enabled, but we still want to update the node above
if (!collectorEnabled.mapReports) {
return;
}
try {
// find an existing map with duplicate information created in the last 60 seconds
const existingDuplicateMapReport = await prisma.mapReport.findFirst({
where: {
node_id: envelope.packet.from,
long_name: payload.longName,
short_name: payload.shortName,
created_at: {
gte: new Date(Date.now() - 60000), // created in the last 60 seconds
},
}
});
// create map report if no duplicates found
if(!existingDuplicateMapReport){
await prisma.mapReport.create({
data: {
node_id: envelope.packet.from,
long_name: payload.longName,
short_name: payload.shortName,
role: payload.role,
hardware_model: payload.hwModel,
firmware_version: payload.firmwareVersion,
region: payload.region,
modem_preset: payload.modemPreset,
has_default_channel: payload.hasDefaultChannel,
latitude: payload.latitudeI,
longitude: payload.longitudeI,
altitude: payload.altitude,
position_precision: payload.positionPrecision,
num_online_local_nodes: payload.numOnlineLocalNodes,
},
});
}
} catch (e) {
console.error(e);
}
}
async function onNeighborInfo(envelope, payload) {
if(logKnownPacketTypes) {
console.log('NEIGHBORINFO_APP', {
from: envelope.packet.from.toString(16),
neighbor_info: payload,
}); });
} }
@ -993,8 +1154,8 @@ client.on("message", async (topic, message) => {
}, },
data: { data: {
neighbours_updated_at: new Date(), neighbours_updated_at: new Date(),
neighbour_broadcast_interval_secs: neighbourInfo.nodeBroadcastIntervalSecs, neighbour_broadcast_interval_secs: payload.nodeBroadcastIntervalSecs,
neighbours: neighbourInfo.neighbors.map((neighbour) => { neighbours: payload.neighbors.map((neighbour) => {
return { return {
node_id: neighbour.nodeId, node_id: neighbour.nodeId,
snr: neighbour.snr, snr: neighbour.snr,
@ -1007,7 +1168,7 @@ client.on("message", async (topic, message) => {
} }
// don't store all neighbour infos, but we want to update the existing node above // don't store all neighbour infos, but we want to update the existing node above
if(!collectNeighbourInfo){ if (!collectorEnabled.neighborInfo) {
return; return;
} }
@ -1016,8 +1177,8 @@ client.on("message", async (topic, message) => {
await prisma.neighbourInfo.create({ await prisma.neighbourInfo.create({
data: { data: {
node_id: envelope.packet.from, node_id: envelope.packet.from,
node_broadcast_interval_secs: neighbourInfo.nodeBroadcastIntervalSecs, node_broadcast_interval_secs: payload.nodeBroadcastIntervalSecs,
neighbours: neighbourInfo.neighbors.map((neighbour) => { neighbours: payload.neighbors.map((neighbour) => {
return { return {
node_id: neighbour.nodeId, node_id: neighbour.nodeId,
snr: neighbour.snr, snr: neighbour.snr,
@ -1028,31 +1189,62 @@ client.on("message", async (topic, message) => {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
else if(portnum === Portnums.PortNum.TELEMETRY_APP) { async function onRouteDiscovery(envelope, payload) {
const telemetry = fromBinary(Telemetry.TelemetrySchema, payload);
if(logKnownPacketTypes) { if(logKnownPacketTypes) {
console.log("TELEMETRY_APP", { console.log('TRACEROUTE_APP', {
to: envelope.packet.to.toString(16),
from: envelope.packet.from.toString(16), from: envelope.packet.from.toString(16),
telemetry: telemetry, want_response: envelope.packet.payloadVariant.value.wantResponse,
route_discovery: payload,
}); });
} }
try {
await prisma.traceRoute.create({
data: {
to: envelope.packet.to,
from: envelope.packet.from,
want_response: envelope.packet.payloadVariant.value.wantResponse,
route: payload.route,
snr_towards: payload.snrTowards,
route_back: payload.routeBack,
snr_back: payload.snrBack,
channel: envelope.packet.channel,
packet_id: envelope.packet.id,
channel_id: envelope.channelId,
gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null,
},
});
} catch (e) {
console.error(e);
}
}
async function onTelemetry(envelope, payload) {
// we need to do some work on the packet to log it properly
const telemetryType = payload.variant.case
if(logKnownPacketTypes) {
console.log('TELEMETRY_APP', {
from: envelope.packet.from.toString(16),
type: telemetryType,
telemetry: payload.variant.value,
});
}
payload = payload.variant.value
// data to update // data to update
const data = {}; const data = {};
// handle device metrics // handle device metrics
if(telemetry.deviceMetrics){ if (telemetryType === 'deviceMetrics'){
data.battery_level = telemetry.deviceMetrics.batteryLevel !== 0 ? telemetry.deviceMetrics.batteryLevel : null; data.battery_level = payload.batteryLevel !== 0 ? payload.batteryLevel : null;
data.voltage = telemetry.deviceMetrics.voltage !== 0 ? telemetry.deviceMetrics.voltage : null; data.voltage = payload.voltage !== 0 ? payload.voltage : null;
data.channel_utilization = telemetry.deviceMetrics.channelUtilization !== 0 ? telemetry.deviceMetrics.channelUtilization : null; data.channel_utilization = payload.channelUtilization !== 0 ? payload.channelUtilization : null;
data.air_util_tx = telemetry.deviceMetrics.airUtilTx !== 0 ? telemetry.deviceMetrics.airUtilTx : null; data.air_util_tx = payload.airUtilTx !== 0 ? payload.airUtilTx : null;
data.uptime_seconds = telemetry.deviceMetrics.uptimeSeconds !== 0 ? telemetry.deviceMetrics.uptimeSeconds : null; data.uptime_seconds = payload.uptimeSeconds !== 0 ? payload.uptimeSeconds : null;
// create device metric // create device metric
try { try {
@ -1091,20 +1283,20 @@ client.on("message", async (topic, message) => {
} }
// handle environment metrics // handle environment metrics
if(telemetry.environmentMetrics){ if(telemetryType === 'environmentMetrics'){
// get metric values // get metric values
const temperature = telemetry.environmentMetrics.temperature !== 0 ? telemetry.environmentMetrics.temperature : null; const temperature = payload.temperature !== 0 ? payload.temperature : null;
const relativeHumidity = telemetry.environmentMetrics.relativeHumidity !== 0 ? telemetry.environmentMetrics.relativeHumidity : null; const relativeHumidity = payload.relativeHumidity !== 0 ? payload.relativeHumidity : null;
const barometricPressure = telemetry.environmentMetrics.barometricPressure !== 0 ? telemetry.environmentMetrics.barometricPressure : null; const barometricPressure = payload.barometricPressure !== 0 ? payload.barometricPressure : null;
const gasResistance = telemetry.environmentMetrics.gasResistance !== 0 ? telemetry.environmentMetrics.gasResistance : null; const gasResistance = payload.gasResistance !== 0 ? payload.gasResistance : null;
const voltage = telemetry.environmentMetrics.voltage !== 0 ? telemetry.environmentMetrics.voltage : null; const voltage = payload.voltage !== 0 ? payload.voltage : null;
const current = telemetry.environmentMetrics.current !== 0 ? telemetry.environmentMetrics.current : null; const current = payload.current !== 0 ? payload.current : null;
const iaq = telemetry.environmentMetrics.iaq !== 0 ? telemetry.environmentMetrics.iaq : null; const iaq = payload.iaq !== 0 ? payload.iaq : null;
const windDirection = telemetry.environmentMetrics.windDirection; const windDirection = payload.windDirection;
const windSpeed = telemetry.environmentMetrics.windSpeed; const windSpeed = payload.windSpeed;
const windGust = telemetry.environmentMetrics.windGust; const windGust = payload.windGust;
const windLull = telemetry.environmentMetrics.windLull; const windLull = payload.windLull;
// set metrics to update on node table // set metrics to update on node table
data.temperature = temperature; data.temperature = temperature;
@ -1153,15 +1345,15 @@ client.on("message", async (topic, message) => {
} }
// handle power metrics // handle power metrics
if(telemetry.powerMetrics){ if(telemetryType === 'powerMetrics'){
// get metric values // get metric values
const ch1Voltage = telemetry.powerMetrics.ch1Voltage !== 0 ? telemetry.powerMetrics.ch1Voltage : null; const ch1Voltage = payload.ch1Voltage !== 0 ? payload.ch1Voltage : null;
const ch1Current = telemetry.powerMetrics.ch1Current !== 0 ? telemetry.powerMetrics.ch1Current : null; const ch1Current = payload.ch1Current !== 0 ? payload.ch1Current : null;
const ch2Voltage = telemetry.powerMetrics.ch2Voltage !== 0 ? telemetry.powerMetrics.ch2Voltage : null; const ch2Voltage = payload.ch2Voltage !== 0 ? payload.ch2Voltage : null;
const ch2Current = telemetry.powerMetrics.ch2Current !== 0 ? telemetry.powerMetrics.ch2Current : null; const ch2Current = payload.ch2Current !== 0 ? payload.ch2Current : null;
const ch3Voltage = telemetry.powerMetrics.ch3Voltage !== 0 ? telemetry.powerMetrics.ch3Voltage : null; const ch3Voltage = payload.ch3Voltage !== 0 ? payload.ch3Voltage : null;
const ch3Current = telemetry.powerMetrics.ch3Current !== 0 ? telemetry.powerMetrics.ch3Current : null; const ch3Current = payload.ch3Current !== 0 ? payload.ch3Current : null;
// create power metric // create power metric
try { try {
@ -1212,160 +1404,4 @@ client.on("message", async (topic, message) => {
console.error(e); console.error(e);
} }
} }
} }
else if(portnum === Portnums.PortNum.TRACEROUTE_APP) {
const routeDiscovery = fromBinary(Mesh.RouteDiscoverySchema, payload);
if(logKnownPacketTypes) {
console.log("TRACEROUTE_APP", {
to: envelope.packet.to.toString(16),
from: envelope.packet.from.toString(16),
want_response: payload.wantResponse,
route_discovery: routeDiscovery,
});
}
try {
await prisma.traceRoute.create({
data: {
to: envelope.packet.to,
from: envelope.packet.from,
want_response: envelope.packet.decoded.wantResponse,
route: routeDiscovery.route,
snr_towards: routeDiscovery.snrTowards,
route_back: routeDiscovery.routeBack,
snr_back: routeDiscovery.snrBack,
channel: envelope.packet.channel,
packet_id: envelope.packet.id,
channel_id: envelope.channelId,
gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null,
},
});
} catch (e) {
console.error(e);
}
}
else if(portnum === Portnums.PortNum.MAP_REPORT_APP) {
const mapReport = fromBinary(Mqtt.MapReportSchema, payload);
if(logKnownPacketTypes) {
console.log("MAP_REPORT_APP", {
from: envelope.packet.from.toString(16),
map_report: mapReport,
});
}
// create or update node in db
try {
// data to set on node
const data = {
long_name: mapReport.longName,
short_name: mapReport.shortName,
hardware_model: mapReport.hwModel,
role: mapReport.role,
latitude: mapReport.latitudeI,
longitude: mapReport.longitudeI,
altitude: mapReport.altitude !== 0 ? mapReport.altitude : null,
firmware_version: mapReport.firmwareVersion,
region: mapReport.region,
modem_preset: mapReport.modemPreset,
has_default_channel: mapReport.hasDefaultChannel,
position_precision: mapReport.positionPrecision,
num_online_local_nodes: mapReport.numOnlineLocalNodes,
position_updated_at: new Date(),
};
await prisma.node.upsert({
where: {
node_id: envelope.packet.from,
},
create: {
node_id: envelope.packet.from,
...data,
},
update: data,
});
} catch (e) {
console.error(e);
}
// don't collect map report history if not enabled, but we still want to update the node above
if(!collectMapReports){
return;
}
try {
// find an existing map with duplicate information created in the last 60 seconds
const existingDuplicateMapReport = await prisma.mapReport.findFirst({
where: {
node_id: envelope.packet.from,
long_name: mapReport.longName,
short_name: mapReport.shortName,
created_at: {
gte: new Date(Date.now() - 60000), // created in the last 60 seconds
},
}
});
// create map report if no duplicates found
if(!existingDuplicateMapReport){
await prisma.mapReport.create({
data: {
node_id: envelope.packet.from,
long_name: mapReport.longName,
short_name: mapReport.shortName,
role: mapReport.role,
hardware_model: mapReport.hwModel,
firmware_version: mapReport.firmwareVersion,
region: mapReport.region,
modem_preset: mapReport.modemPreset,
has_default_channel: mapReport.hasDefaultChannel,
latitude: mapReport.latitudeI,
longitude: mapReport.longitudeI,
altitude: mapReport.altitude,
position_precision: mapReport.positionPrecision,
num_online_local_nodes: mapReport.numOnlineLocalNodes,
},
});
}
} catch (e) {
console.error(e);
}
}
else {
if (logUnknownPortnums) {
const ignoredPortnums = [
Portnums.PortNum.UNKNOWN_APP,
Portnums.PortNum.TEXT_MESSAGE_COMPRESSED_APP,
Portnums.PortNum.ROUTING_APP,
Portnums.PortNum.PAXCOUNTER_APP,
Portnums.PortNum.STORE_FORWARD_APP,
Portnums.PortNum.RANGE_TEST_APP,
Portnums.PortNum.ATAK_PLUGIN,
Portnums.PortNum.ATAK_FORWARDER,
];
// ignore packets we don't want to see for now
if (portnum === undefined || ignoredPortnums.includes(portnum) || portnum > 511) {
return;
}
console.log(portnum, envelope);
}
}
} catch(e) {
// ignore errors
}
});

View File

@ -5,6 +5,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"type": "module",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.2.5", "@bufbuild/protobuf": "^2.2.5",
"@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.6.2", "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.6.2",

View File

@ -1,4 +1,4 @@
class NodeIdUtil { export default class NodeIdUtil {
/** /**
* Converts the provided hex id to a numeric id, for example: !FFFFFFFF to 4294967295 * Converts the provided hex id to a numeric id, for example: !FFFFFFFF to 4294967295
@ -19,5 +19,3 @@ class NodeIdUtil {
} }
} }
module.exports = NodeIdUtil;

View File

@ -1,4 +1,4 @@
class PositionUtil { export default class PositionUtil {
/** /**
* Obfuscates the provided latitude or longitude down to the provided precision in bits. * Obfuscates the provided latitude or longitude down to the provided precision in bits.
@ -62,5 +62,3 @@ class PositionUtil {
} }
} }
module.exports = PositionUtil;

View File

@ -15,6 +15,8 @@
"chartjs-adapter-moment": "^1.0.1", "chartjs-adapter-moment": "^1.0.1",
"install": "^0.13.0", "install": "^0.13.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-arrowheads": "^1.4.0",
"leaflet-geometryutil": "^0.10.3",
"leaflet-groupedlayercontrol": "^0.6.1", "leaflet-groupedlayercontrol": "^0.6.1",
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
"moment": "^2.30.1", "moment": "^2.30.1",
@ -2700,6 +2702,25 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/leaflet-arrowheads": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/leaflet-arrowheads/-/leaflet-arrowheads-1.4.0.tgz",
"integrity": "sha512-aIjsmoWe1VJXaGOpKpS6E8EzN2vpx3GGCNP/FxQteLVzAg5xMID7elf9hj/1CWLJo8FuGRjSvKkUQDj7mocrYA==",
"license": "MIT",
"dependencies": {
"leaflet": "^1.7.1",
"leaflet-geometryutil": "^0.10.0"
}
},
"node_modules/leaflet-geometryutil": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/leaflet-geometryutil/-/leaflet-geometryutil-0.10.3.tgz",
"integrity": "sha512-Qeas+KsnenE0Km/ydt8km3AqFe7kJhVwuLdbCYM2xe2epsxv5UFEaVJiagvP9fnxS8QvBNbm7DJlDA0tkKo9VA==",
"license": "BSD-3-Clause",
"dependencies": {
"leaflet": "^1.6.0"
}
},
"node_modules/leaflet-groupedlayercontrol": { "node_modules/leaflet-groupedlayercontrol": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/leaflet-groupedlayercontrol/-/leaflet-groupedlayercontrol-0.6.1.tgz", "resolved": "https://registry.npmjs.org/leaflet-groupedlayercontrol/-/leaflet-groupedlayercontrol-0.6.1.tgz",

View File

@ -16,6 +16,8 @@
"chartjs-adapter-moment": "^1.0.1", "chartjs-adapter-moment": "^1.0.1",
"install": "^0.13.0", "install": "^0.13.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-arrowheads": "^1.4.0",
"leaflet-geometryutil": "^0.10.3",
"leaflet-groupedlayercontrol": "^0.6.1", "leaflet-groupedlayercontrol": "^0.6.1",
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
"moment": "^2.30.1", "moment": "^2.30.1",

View File

@ -0,0 +1,386 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Meshtastic Messages</title>
<!-- tailwind css -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- moment -->
<script src="https://app.unpkg.com/moment@2.29.1/files/dist/moment.js"></script>
<!-- vuejs -->
<script src="https://app.unpkg.com/vue@3.4.26/files/dist/vue.global.js"></script>
<!-- axios -->
<script src="https://app.unpkg.com/axios@1.6.8/files/dist/axios.min.js"></script>
<style>
/* used to prevent ui flicker before vuejs loads */
[v-cloak] {
display: none;
}
</style>
</head>
<body class="h-full">
<div id="app" v-cloak>
<div class="h-full flex flex-col overflow-hidden">
<!-- empty state -->
<div v-if="messages.length === 0" class="flex h-full">
<div class="flex flex-col mx-auto my-auto p-4 text-gray-500 text-center">
<div class="mb-2 mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-10">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
</div>
<div class="font-semibold">No Messages</div>
<div>There's no messages yet...</div>
</div>
</div>
<!-- note: must use flex-col-reverse to prevent ui scrolling when adding older messages to ui -->
<div v-show="messages.length > 0" id="messages" class="h-full flex flex-col-reverse p-3 overflow-y-auto">
<!-- messages -->
<div :key="message.id" v-for="message of reversedMessages" class="max-w-xl items-start my-1.5">
<div class="flex">
<div class="mr-2 mt-2">
<a target="_blank" :href="`/?node_id=${message.from}`">
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(message.from)}]`, `text-[${getNodeTextColour(message.from)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ getNodeShortName(message.from) }}</div>
</div>
</a>
</div>
<div class="flex flex-col">
<!-- sender -->
<div class="text-xs text-gray-500">
<a target="_blank" :href="`/?node_id=${message.from}`" class="hover:text-blue-500">
<span>{{ getNodeLongName(message.from) }}</span>
</a>
<span v-if="message.to.toString() !== '4294967295'">
<span></span>
<a target="_blank" :href="`/?node_id=${message.to}`" class="hover:text-blue-500">{{ getNodeName(message.to) }}</a>
</span>
</div>
<!-- message -->
<div @click="message.is_details_expanded = !message.is_details_expanded" class="flex">
<div class="border border-gray-300 rounded-xl shadow overflow-hidden bg-[#efefef] divide-y">
<div class="w-full space-y-0.5 px-2.5 py-1" v-html="escapeMessageText(message.text)" style="white-space:pre-wrap;word-break:break-word;"></div>
<div v-if="message.is_details_expanded" class="text-xs text-gray-500 px-2 py-1">
<span :title="message.created_at">{{ formatMessageTimestamp(message.created_at) }}</span>
<span> • Gated by <a target="_blank" :href="`/?node_id=${message.gateway_id}`" class="hover:text-blue-500">{{ getNodeName(message.gateway_id) }}</a></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- load previous -->
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex space-x-2 mx-auto bg-gray-200 px-3 py-1 hover:bg-gray-300 rounded-full shadow">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span>Load Previous</span>
</button>
</div>
</div>
</div>
<script>
Vue.createApp({
data() {
return {
to: null,
from: null,
channelId: null,
gatewayId: null,
isLoadingPrevious: false,
isLoadingMore: false,
shouldAutoScroll: true,
loadPreviousObserver: null,
hasMorePrevious: true,
messages: [],
nodesById: {},
moment: window.moment,
};
},
mounted: function() {
// parse url params
const queryParams = new URLSearchParams(window.location.search);
this.to = queryParams.get('to');
this.from = queryParams.get('from');
this.channelId = queryParams.get('channel_id');
this.gatewayId = queryParams.get('gateway_id');
this.directMessageNodeIds = queryParams.get('direct_message_node_ids');
this.count = queryParams.get('count');
// listen for scrolling of messages list
document.getElementById("messages").addEventListener("scroll", (event) => {
// check if messages is scrolled to bottom
const element = event.target;
const isAtBottom = element.scrollTop === (element.scrollHeight - element.offsetHeight);
// we want to auto scroll if user is at bottom of messages list
this.shouldAutoScroll = isAtBottom;
});
// setup intersection observer
this.loadPreviousObserver = new IntersectionObserver((entries) => {
const loadMoreElement = entries[0];
if(loadMoreElement && loadMoreElement.isIntersecting){
this.loadPrevious();
}
});
this.initialLoad();
},
methods: {
async initialLoad() {
// load 1 page of previous messages
await this.loadPrevious();
// scroll to bottom
this.scrollToBottom();
// setup auto loading previous
this.loadPreviousObserver.observe(document.querySelector("#load-previous"));
// load more every few seconds
setInterval(async () => {
await this.loadMore();
}, 2500);
},
async loadPrevious() {
// do nothing if already loading
if(this.isLoadingPrevious){
return;
}
this.isLoadingPrevious = true;
try {
const response = await window.axios.get('/api/v1/text-messages', {
params: {
to: this.to,
from: this.from,
channel_id: this.channelId,
gateway_id: this.gatewayId,
direct_message_node_ids: this.directMessageNodeIds,
count: this.count,
order: "desc",
last_id: this.oldestMessageId,
},
});
// add messages to start of existing messages
const messages = response.data.text_messages;
for(const message of messages){
this.messages.unshift(message);
}
// no more previous to load if previous list is empty
if(messages.length === 0){
this.hasMorePrevious = false;
}
// fetch node info
for(const message of messages){
await this.fetchNodeInfo(message.to);
await this.fetchNodeInfo(message.from);
await this.fetchNodeInfo(message.gateway_id);
}
} catch(e) {
// do nothing
} finally {
this.isLoadingPrevious = false;
}
},
async loadMore() {
// do nothing if already loading
if(this.isLoadingMore){
return;
}
this.isLoadingMore = true;
try {
const response = await window.axios.get('/api/v1/text-messages', {
params: {
to: this.to,
from: this.from,
channel_id: this.channelId,
gateway_id: this.gatewayId,
direct_message_node_ids: this.directMessageNodeIds,
count: this.count,
order: "asc",
last_id: this.latestMessageId,
},
});
// add messages to end of existing messages
const messages = response.data.text_messages;
for(const message of messages){
this.messages.push(message);
}
// scroll to bottom
if(this.shouldAutoScroll){
this.scrollToBottom();
}
// fetch node info
for(const message of messages){
await this.fetchNodeInfo(message.to);
await this.fetchNodeInfo(message.from);
await this.fetchNodeInfo(message.gateway_id);
}
} catch(e) {
// do nothing
} finally {
this.isLoadingMore = false;
}
},
async fetchNodeInfo(nodeId) {
// do nothing if already fetched
if(nodeId in this.nodesById){
return;
}
// do nothing if broadcast address
if(nodeId.toString() === "4294967295"){
return;
}
try {
const response = await window.axios.get(`/api/v1/nodes/${nodeId}`);
const node = response.data.node;
if(node){
this.nodesById[node.node_id] = node;
}
} catch(e) {
// do nothing
}
},
scrollToBottom: function() {
this.$nextTick(() => {
var container = this.$el.querySelector("#messages");
container.scrollTop = container.scrollHeight;
});
},
getNodeHexId(nodeId) {
return "!" + parseInt(nodeId).toString(16);
},
getNodeName(nodeId) {
// find node by id
const node = this.nodesById[nodeId];
if(!node){
return this.getNodeHexId(nodeId);
}
return `[${node.short_name}] ${node.long_name}`;
},
getNodeShortName(nodeId) {
return this.nodesById[nodeId]?.short_name?.substring(0, 4) ?? "?";
},
getNodeLongName(nodeId) {
return this.nodesById[nodeId]?.long_name ?? this.getNodeHexId(nodeId);
},
getNodeColour(nodeId) {
// convert node id to a hex colour
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
},
getNodeTextColour(nodeId) {
// extract rgb components
const r = (nodeId & 0xFF0000) >> 16;
const g = (nodeId & 0x00FF00) >> 8;
const b = nodeId & 0x0000FF;
// calculate brightness
const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255;
// determine text color based on brightness
return brightness > 0.5 ? "#000000" : "#FFFFFF";
},
escapeMessageText(text) {
return text.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('\n', '<br/>');
},
formatMessageTimestamp(createdAt) {
return moment(new Date(createdAt)).local().format("DD/MMM/YYYY hh:mm:ss A");
},
},
computed: {
reversedMessages() {
// ensure a copy of the array is returned in reverse order
return this.messages.map((message) => message).reverse();
},
oldestMessageId() {
if(this.messages.length > 0){
return this.messages[0].id;
}
return null;
},
latestMessageId() {
if(this.messages.length > 0){
return this.messages[this.messages.length - 1].id;
}
return null;
}
},
}).mount('#app');
</script>
</body>
</html>

View File

@ -1,5 +1,6 @@
<script setup> <script setup>
const emit = defineEmits(['showTraceRoute']); const emit = defineEmits(['showTraceRoute']);
import moment from 'moment';
import { state } from '../../store.js'; import { state } from '../../store.js';
import { findNodeById } from '../../utils.js'; import { findNodeById } from '../../utils.js';
</script> </script>

View File

@ -64,7 +64,7 @@ import { getNodeColor, getNodeTextColor, findNodeById, findNodeMarkerById } from
</div> </div>
<div class="my-auto relative flex flex-none items-center justify-center"> <div class="my-auto relative flex flex-none items-center justify-center">
<div> <div>
<div class="flex rounded-full h-12 w-12 text-white shadow-sm" :class="[ `bg-[${getNodeColor(state.selectedTraceRoute.to)}]`, `text-[${getNodeTextColor(state.selectedTraceRoute.to)}]` ]"> <div class="flex rounded-full h-12 w-12 text-white shadow-sm" :style="{backgroundColor: getNodeColor(state.selectedTraceRoute.to)}" :class="[ `text-[${getNodeTextColor(state.selectedTraceRoute.to)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.selectedTraceRoute.to)?.short_name ?? "?" }}</div> <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.selectedTraceRoute.to)?.short_name ?? "?" }}</div>
</div> </div>
</div> </div>
@ -83,7 +83,7 @@ import { getNodeColor, getNodeTextColor, findNodeById, findNodeMarkerById } from
</div> </div>
<div class="my-auto relative flex flex-none items-center justify-center"> <div class="my-auto relative flex flex-none items-center justify-center">
<div> <div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColor(route)}]`, `text-[${getNodeTextColor(route)}]` ]"> <div class="flex rounded-full h-12 w-12 text-white shadow" :style="{backgroundColor: getNodeColor(route)}" :class="[ `text-[${getNodeTextColor(route)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(route)?.short_name ?? "?" }}</div> <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(route)?.short_name ?? "?" }}</div>
</div> </div>
</div> </div>
@ -102,7 +102,7 @@ import { getNodeColor, getNodeTextColor, findNodeById, findNodeMarkerById } from
</div> </div>
<div class="my-auto relative flex flex-none items-center justify-center"> <div class="my-auto relative flex flex-none items-center justify-center">
<div> <div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColor(state.selectedTraceRoute.from)}]`, `text-[${getNodeTextColor(state.selectedTraceRoute.from)}]` ]"> <div class="flex rounded-full h-12 w-12 text-white shadow" :style="{backgroundColor: getNodeColor(state.selectedTraceRoute.from)}" :class="[ `text-[${getNodeTextColor(state.selectedTraceRoute.from)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.selectedTraceRoute.from)?.short_name ?? "?" }}</div> <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.selectedTraceRoute.from)?.short_name ?? "?" }}</div>
</div> </div>
</div> </div>
@ -121,7 +121,7 @@ import { getNodeColor, getNodeTextColor, findNodeById, findNodeMarkerById } from
</div> </div>
<div class="my-auto relative flex flex-none items-center justify-center"> <div class="my-auto relative flex flex-none items-center justify-center">
<div> <div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColor(state.selectedTraceRoute.gateway_id)}]`, `text-[${getNodeTextColor(state.selectedTraceRoute.gateway_id)}]` ]"> <div class="flex rounded-full h-12 w-12 text-white shadow" :style="{backgroundColor: getNodeColor(state.selectedTraceRoute.gateway_id)}" :class="[ `text-[${getNodeTextColor(state.selectedTraceRoute.gateway_id)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.selectedTraceRoute.gateway_id)?.short_name ?? "?" }}</div> <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.selectedTraceRoute.gateway_id)?.short_name ?? "?" }}</div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import moment from 'moment'; import moment from 'moment';
import L from 'leaflet/dist/leaflet.js'; import 'leaflet/dist/leaflet.js';
const L = window.L;
import 'leaflet.markercluster/dist/leaflet.markercluster.js'; import 'leaflet.markercluster/dist/leaflet.markercluster.js';
import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js'; import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js';
import { import {

View File

@ -11,8 +11,11 @@ import Announcement from '../components/Announcement.vue';
import axios from 'axios'; import axios from 'axios';
import moment from 'moment'; import moment from 'moment';
import L from 'leaflet/dist/leaflet.js'; import 'leaflet/dist/leaflet';
import 'leaflet.markercluster/dist/leaflet.markercluster.js'; const L = window.L;
import 'leaflet-geometryutil';
import 'leaflet-arrowheads';
import 'leaflet.markercluster';
import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js'; import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js';
import { onMounted, useTemplateRef, ref, watch, markRaw } from 'vue'; import { onMounted, useTemplateRef, ref, watch, markRaw } from 'vue';
import { state } from '../store.js'; import { state } from '../store.js';
@ -298,6 +301,10 @@ function onNodesUpdated(nodes) {
continue; continue;
} }
} }
// add node to cache
state.nodes.push(node);
// skip nodes without position // skip nodes without position
if (!node.latitude || !node.longitude) { if (!node.latitude || !node.longitude) {
continue; continue;
@ -322,7 +329,7 @@ function onNodesUpdated(nodes) {
let icon = icons.mqttDisconnected; let icon = icons.mqttDisconnected;
// use offline icon for nodes older than configured node offline age // use offline icon for nodes older than configured node offline age
if (nodesOfflineAge) { if (nodesOfflineAge.value) {
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at)); const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if (lastUpdatedAgeInMillis > nodesOfflineAge.value * 1000) { if (lastUpdatedAgeInMillis > nodesOfflineAge.value * 1000) {
icon = icons.offline; icon = icons.offline;
@ -365,8 +372,7 @@ function onNodesUpdated(nodes) {
}); });
} }
// Push node and marker to cache // Push node marker to cache
state.nodes.push(node);
state.nodeMarkers[node.node_id] = marker; state.nodeMarkers[node.node_id] = marker;
// show node info tooltip when clicking node marker // show node info tooltip when clicking node marker

View File

@ -1,14 +1,15 @@
const path = require('path'); import path from "path";
const express = require('express'); import { fileURLToPath } from 'url';
const compression = require('compression'); import express from "express";
const commandLineArgs = require("command-line-args"); import compression from "compression";
const commandLineUsage = require("command-line-usage"); import commandLineArgs from "command-line-args";
import commandLineUsage from "command-line-usage";
// protobuf imports // protobuf imports
const { Mesh, Config } = require("@meshtastic/protobufs"); import { Mesh, Config } from "@meshtastic/protobufs";
// create prisma db client // create prisma db client
const { PrismaClient } = require("@prisma/client"); import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// return big ints as string when using JSON.stringify // return big ints as string when using JSON.stringify
@ -46,7 +47,7 @@ if(options.help){
}, },
]); ]);
console.log(usage); console.log(usage);
return; process.exit(1);
} }
// get options and fallback to default values // get options and fallback to default values
@ -76,6 +77,8 @@ const app = express();
app.use(compression()); app.use(compression());
// serve files inside the public folder from / // serve files inside the public folder from /
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use('/', express.static(path.join(__dirname, 'public'))); app.use('/', express.static(path.join(__dirname, 'public')));
app.get('/', async (req, res) => { app.get('/', async (req, res) => {
@ -665,7 +668,7 @@ app.get('/api/v1/stats/hardware-models', async (req, res) => {
return { return {
count: result._count.hardware_model, count: result._count.hardware_model,
hardware_model: result.hardware_model, hardware_model: result.hardware_model,
hardware_model_name: HardwareModel.valuesById[result.hardware_model] ?? "UNKNOWN", hardware_model_name: HardwareModel[result.hardware_model] ?? "UNKNOWN",
}; };
}); });

View File

@ -5,6 +5,7 @@
"main": "index.js", "main": "index.js",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "module",
"dependencies": { "dependencies": {
"@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.6.2", "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.6.2",
"@prisma/client": "^5.11.0", "@prisma/client": "^5.11.0",