Skip to content

Commit b84a1e3

Browse files
Yerazeclaude
andauthored
chore(protobufs): refresh pin to develop@6ceceae and honor rx_rssi explicit presence (#4466)
* chore(protobufs): refresh pin to develop@6ceceae and honor rx_rssi presence Bumps the protobufs submodule from develop@ba16bfc to develop@6ceceae (61 upstream commits). Still a develop preview: the newest upstream tag is v2.7.26, no 2.8.x tag exists yet. Protocol tables brought in line with the new pin: - HardwareModel 141-143 (HELTEC_RC32 / RC52 / RCC6) - PortNum.LORA_OTA_APP = 79, plus the normalize and name tables in meshtasticProtobufService so a string-form portnum can't be dropped - UA_868 labelled deprecated in the region picker (upstream marked the enum value [deprecated = true]); still selectable so existing devices round-trip - DeviceMetadata.has_xeddsa surfaced through /api/admin/get-device-metadata and the Admin Commands capabilities list. The route uses an explicit allowlist, so the field would otherwise be silently dropped (#3923) MeshPacket.rx_rssi gained explicit presence upstream (optional int32 rx_rssi = 12, firmware PR #11271). Verified against the new pin: an absent field decodes to null, a genuine 0 dBm reading decodes to 0 and is carried on the wire. Every `rxRssi !== 0` guard was therefore reading a real 0 dBm reception as "no data". The consequential site is hasRfReceptionMarkers(): a 2.8 node spoofing our local node at point-blank range reports rx_rssi 0, no other RF marker fires, and the packet was classified isGenuineLocalTx — rendered as our own outgoing message. Now presence-based, with a regression test for that case. Six node/telemetry RSSI sites and the {RSSI} announcement token had the same pattern (cosmetic, not security). rx_snr is deliberately unchanged: it keeps proto3 implicit presence, so 0 there is still indistinguishable from absent. The -128 sentinel handling from #3590 stands. rx_time also became optional but needs no change — plausibleRxTime()'s 2020-01-01 floor already subsumes presence. No-op against 2.7 firmware, which never puts a 0 on the wire. Refs #3548, #3923, #3854 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB * address review: null-guard script env RSSI, surface hasXeddsa for local node Three items from the PR #4466 review, all verified against the code first. 1. scriptEnv.RSSI/SNR guarded only `undefined`. An absent rx_rssi decodes to null under 2.8's explicit presence, so String(null) handed user scripts the literal string "null" — truthy and non-empty — instead of omitting the var. 2. hasXeddsa was only surfaced for remote nodes. The original reasoning (that the local-node branch has no real DeviceMetadata to read) was wrong: the manager already captures hasWifi/hasEthernet/hasBluetooth from exactly that message. hasXeddsa is now captured onto localNodeInfo alongside them and returned by the local branch of /get-device-metadata, which is the common case for a user checking their own device. 3. Added a {RSSI}/{SNR} presence test. The existing token tests in meshtasticManager.autoack-templates.test.ts re-implement the logic inline and would pass regardless of what production does, so this drives the real replaceAcknowledgementTokens path instead. Verified non-vacuous: reverting the guard to `!== 0` fails "renders a present rxRssi of 0 as 0, not N/A". Full suite 13250 passed / 0 failed with PG+MySQL containers up. tsc clean, lint:ci passes. Refs #3548, #3923 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 08d3e03 commit b84a1e3

12 files changed

Lines changed: 187 additions & 21 deletions

src/components/AdminCommandsTab.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ const AdminCommandsTab: React.FC<AdminCommandsTabProps> = ({ nodes, currentNodeI
145145
positionFlags: number;
146146
hwModel: number;
147147
hasRemoteHardware: boolean;
148+
/** Firmware 2.8+ build capability: XEdDSA signature verification compiled in (#3923). */
149+
hasXeddsa?: boolean;
148150
} | null>(null);
149151

150152
// Reboot and Set Time command states
@@ -2608,7 +2610,8 @@ const AdminCommandsTab: React.FC<AdminCommandsTabProps> = ({ nodes, currentNodeI
26082610
deviceMetadata.hasBluetooth && 'Bluetooth',
26092611
deviceMetadata.hasEthernet && 'Ethernet',
26102612
deviceMetadata.canShutdown && 'Shutdown',
2611-
deviceMetadata.hasRemoteHardware && 'Remote HW'
2613+
deviceMetadata.hasRemoteHardware && 'Remote HW',
2614+
deviceMetadata.hasXeddsa && 'XEdDSA'
26122615
].filter(Boolean).join(', ') || t('common.none', 'None')}
26132616
</span>
26142617

src/components/configuration/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ export const REGION_OPTIONS: RegionOption[] = [
105105
{ value: 12, label: 'TH - Thailand' },
106106
{ value: 13, label: 'LORA_24 - WLAN Band' },
107107
{ value: 14, label: 'UA_433 - Ukraine 433MHz' },
108-
{ value: 15, label: 'UA_868 - Ukraine 868MHz' },
108+
// Deprecated upstream in the 2.8 protobufs (`UA_868 = 15 [deprecated = true]`).
109+
// Kept selectable so existing devices still round-trip, but labelled so nobody
110+
// picks it for a new deployment.
111+
{ value: 15, label: 'UA_868 - Ukraine 868MHz (deprecated)' },
109112
{ value: 16, label: 'MY_433 - Malaysia 433MHz' },
110113
{ value: 17, label: 'MY_919 - Malaysia 919MHz' },
111114
{ value: 18, label: 'SG_923 - Singapore' },

src/server/constants/meshtastic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const PortNum = {
4747
RETICULUM_TUNNEL_APP: 76,
4848
CAYENNE_APP: 77,
4949
ATAK_PLUGIN_V2: 78,
50+
LORA_OTA_APP: 79, // Signed firmware updates over LoRa (firmware 2.8+)
5051
GROUPALARM_APP: 112,
5152
PRIVATE_APP: 256,
5253
ATAK_FORWARDER: 257,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { MeshtasticManager } from './meshtasticManager.js';
3+
import databaseService from '../services/database.js';
4+
5+
// Firmware 2.8 gave MeshPacket.rx_rssi explicit presence (`optional int32
6+
// rx_rssi = 12`, firmware PR #11271). An absent field now decodes to null,
7+
// while a genuine 0 dBm reading decodes to 0 and is carried on the wire — 0 dBm
8+
// is a real reading on SX126x/LR11x0/LR20x0 and can even go positive on SX127x.
9+
//
10+
// The pre-2.8 `rxRssi !== 0` guards therefore rendered a real 0 dBm reception as
11+
// "no data". These tests exercise the production token path (not a re-implementation
12+
// of it) so a revert to `!== 0` fails here. See issue #3548.
13+
//
14+
// rx_snr deliberately keeps proto3 implicit presence, so 0 there stays
15+
// indistinguishable from absent and must still render N/A.
16+
17+
vi.mock('../services/database.js', () => ({
18+
default: {
19+
settings: {
20+
getSetting: vi.fn().mockResolvedValue(null),
21+
getSettingForSource: vi.fn().mockResolvedValue(null),
22+
},
23+
nodes: {
24+
getNode: vi.fn().mockResolvedValue(null),
25+
getActiveNodes: vi.fn().mockResolvedValue([]),
26+
},
27+
channels: {
28+
getChannelById: vi.fn().mockResolvedValue(null),
29+
},
30+
},
31+
}));
32+
33+
vi.mock('./messageQueueService.js', () => {
34+
const mockInstance = {
35+
enqueue: vi.fn(),
36+
setSendCallback: vi.fn(),
37+
clear: vi.fn(),
38+
getStatus: vi.fn(() => ({ queueLength: 0, pendingAcks: 0, processing: false })),
39+
};
40+
function MessageQueueService() { return mockInstance as any; }
41+
return { messageQueueService: mockInstance, MessageQueueService };
42+
});
43+
44+
describe('{RSSI}/{SNR} token presence semantics (#3548)', () => {
45+
const SOURCE_ID = 'source-rssi-presence';
46+
const NODE_ID = '!11223344';
47+
const FROM_NUM = 0x11223344;
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
vi.mocked(databaseService.nodes.getNode).mockResolvedValue(null);
52+
vi.mocked(databaseService.nodes.getActiveNodes).mockResolvedValue([]);
53+
vi.mocked(databaseService.settings.getSetting).mockResolvedValue(null);
54+
vi.mocked(databaseService.settings.getSettingForSource).mockResolvedValue(null);
55+
});
56+
57+
/** Expand a template through the real replaceAcknowledgementTokens path. */
58+
const expand = async (template: string, rxSnr?: number, rxRssi?: number) => {
59+
const manager = new MeshtasticManager(SOURCE_ID);
60+
return (manager as any).replaceAcknowledgementTokens(
61+
template,
62+
NODE_ID,
63+
FROM_NUM,
64+
0, // numberHops
65+
'2026-07-31',
66+
'12:00:00',
67+
0, // channelIndex
68+
true, // isDirectMessage
69+
rxSnr,
70+
rxRssi,
71+
false, // viaMqtt
72+
);
73+
};
74+
75+
it('renders a present rxRssi of 0 as "0", not "N/A"', async () => {
76+
await expect(expand('RSSI: {RSSI}', undefined, 0)).resolves.toBe('RSSI: 0');
77+
});
78+
79+
it('renders an absent rxRssi as "N/A"', async () => {
80+
await expect(expand('RSSI: {RSSI}', undefined, undefined)).resolves.toBe('RSSI: N/A');
81+
});
82+
83+
it('still renders a normal negative rxRssi', async () => {
84+
await expect(expand('RSSI: {RSSI}', undefined, -95)).resolves.toBe('RSSI: -95');
85+
});
86+
87+
it('renders an rxSnr of 0 as "N/A" (implicit presence keeps 0 ambiguous)', async () => {
88+
await expect(expand('SNR: {SNR}', 0, undefined)).resolves.toBe('SNR: N/A');
89+
});
90+
91+
it('renders a non-zero rxSnr normally', async () => {
92+
await expect(expand('SNR: {SNR}', 7.5, undefined)).resolves.toBe('SNR: 7.5');
93+
});
94+
});

src/server/meshtasticManager.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,9 @@ class MeshtasticManager implements ISourceManager {
797797
hasWifi?: boolean;
798798
hasEthernet?: boolean;
799799
hasBluetooth?: boolean;
800+
// #3923: firmware 2.8 build capability — XEdDSA signature verification
801+
// compiled in. Distinguishes "cannot sign" from "did not sign this packet".
802+
hasXeddsa?: boolean;
800803
// #3684: User capability flags from the local node's NodeInfo, surfaced to the
801804
// frontend Config tab via getCurrentConfig().localNodeInfo.
802805
isUnmessagable?: boolean;
@@ -4850,7 +4853,7 @@ class MeshtasticManager implements ISourceManager {
48504853
// Note: Local node's public key is extracted from security config when received
48514854
}
48524855

4853-
getLocalNodeInfo(): { nodeNum: number; nodeId: string; longName: string; shortName: string; hwModel?: number; firmwareVersion?: string; rebootCount?: number; isLocked?: boolean; hasWifi?: boolean; hasEthernet?: boolean; hasBluetooth?: boolean } | null {
4856+
getLocalNodeInfo(): { nodeNum: number; nodeId: string; longName: string; shortName: string; hwModel?: number; firmwareVersion?: string; rebootCount?: number; isLocked?: boolean; hasWifi?: boolean; hasEthernet?: boolean; hasBluetooth?: boolean; hasXeddsa?: boolean } | null {
48544857
return this.localNodeInfo;
48554858
}
48564859

@@ -5288,6 +5291,9 @@ class MeshtasticManager implements ISourceManager {
52885291
this.localNodeInfo.hasWifi = metadata.hasWifi === true;
52895292
this.localNodeInfo.hasEthernet = metadata.hasEthernet === true;
52905293
this.localNodeInfo.hasBluetooth = metadata.hasBluetooth === true;
5294+
// Firmware 2.8 build capability, surfaced alongside the transport flags so
5295+
// the local node reports it the same way a remote node does (#3923).
5296+
this.localNodeInfo.hasXeddsa = metadata.hasXeddsa === true;
52915297
if (this.isLocalNodeBridged()) {
52925298
logger.debug('🌉 Connected node reports no native WiFi/Ethernet — treating as a bridged node (OTA firmware update disabled)');
52935299
}
@@ -5785,11 +5791,15 @@ class MeshtasticManager implements ISourceManager {
57855791
nodeData.shortName = nodeId.slice(-4);
57865792
}
57875793

5788-
// Only include SNR/RSSI if they have valid values
5794+
// Only include SNR/RSSI if they have valid values.
5795+
// -128 is the firmware "no SNR" sentinel; 0 dB is a real reading (#3590).
5796+
// rx_rssi gained explicit presence in firmware 2.8 (`optional int32
5797+
// rx_rssi = 12`, firmware PR #11271), so absent decodes to null and a
5798+
// present 0 is a genuine 0 dBm reception — do not filter it out (#3548).
57895799
if (meshPacket.rxSnr != null && meshPacket.rxSnr !== -128) {
57905800
nodeData.snr = meshPacket.rxSnr;
57915801
}
5792-
if (meshPacket.rxRssi != null && meshPacket.rxRssi !== 0) {
5802+
if (meshPacket.rxRssi != null) {
57935803
nodeData.rssi = meshPacket.rxRssi;
57945804
}
57955805
await databaseService.upsertNodeAsync(nodeData, this.sourceId);
@@ -6974,7 +6984,7 @@ class MeshtasticManager implements ISourceManager {
69746984
if (meshPacket.rxSnr != null && meshPacket.rxSnr !== -128) {
69756985
technicalData.snr = meshPacket.rxSnr;
69766986
}
6977-
if (meshPacket.rxRssi && meshPacket.rxRssi !== 0) {
6987+
if (meshPacket.rxRssi != null) {
69786988
technicalData.rssi = meshPacket.rxRssi;
69796989
}
69806990
await databaseService.upsertNodeAsync(technicalData, this.sourceId);
@@ -7002,7 +7012,7 @@ class MeshtasticManager implements ISourceManager {
70027012
if (meshPacket.rxSnr != null && meshPacket.rxSnr !== -128) {
70037013
nodeData.snr = meshPacket.rxSnr;
70047014
}
7005-
if (meshPacket.rxRssi && meshPacket.rxRssi !== 0) {
7015+
if (meshPacket.rxRssi != null) {
70067016
nodeData.rssi = meshPacket.rxRssi;
70077017
}
70087018

@@ -7256,7 +7266,7 @@ class MeshtasticManager implements ISourceManager {
72567266
logger.debug(`📊 Saved local SNR telemetry: ${meshPacket.rxSnr} dB (${reason}, previous: ${latestSnrTelemetry?.value || 'N/A'})`);
72577267
}
72587268
}
7259-
if (meshPacket.rxRssi && meshPacket.rxRssi !== 0) {
7269+
if (meshPacket.rxRssi != null) {
72607270
nodeData.rssi = meshPacket.rxRssi;
72617271

72627272
// Save RSSI as telemetry if it has changed OR if 10+ minutes have passed
@@ -7341,7 +7351,7 @@ class MeshtasticManager implements ISourceManager {
73417351
if (meshPacket.rxSnr != null && meshPacket.rxSnr !== -128) {
73427352
nodeData.snr = meshPacket.rxSnr;
73437353
}
7344-
if (meshPacket.rxRssi != null && meshPacket.rxRssi !== 0) {
7354+
if (meshPacket.rxRssi != null) {
73457355
nodeData.rssi = meshPacket.rxRssi;
73467356
}
73477357

@@ -7502,7 +7512,7 @@ class MeshtasticManager implements ISourceManager {
75027512
if (meshPacket.rxSnr != null && meshPacket.rxSnr !== -128) {
75037513
nodeData.snr = meshPacket.rxSnr;
75047514
}
7505-
if (meshPacket.rxRssi != null && meshPacket.rxRssi !== 0) {
7515+
if (meshPacket.rxRssi != null) {
75067516
nodeData.rssi = meshPacket.rxRssi;
75077517
}
75087518

@@ -11552,8 +11562,11 @@ class MeshtasticManager implements ISourceManager {
1155211562
scriptEnv.HOPS = String(context.hopsTraveled);
1155311563
scriptEnv.IS_DIRECT = String(context.isDirectMessage);
1155411564
}
11555-
if (message.rxSnr !== undefined) scriptEnv.SNR = String(message.rxSnr);
11556-
if (message.rxRssi !== undefined) scriptEnv.RSSI = String(message.rxRssi);
11565+
// Guard null as well as undefined: an absent rx_rssi decodes to null under
11566+
// firmware 2.8's explicit presence, and String(null) would hand scripts the
11567+
// literal string "null" — truthy and non-empty — instead of omitting the var.
11568+
if (message.rxSnr !== undefined && message.rxSnr !== null) scriptEnv.SNR = String(message.rxSnr);
11569+
if (message.rxRssi !== undefined && message.rxRssi !== null) scriptEnv.RSSI = String(message.rxRssi);
1155711570
scriptEnv.CHANNEL = String(message.channel);
1155811571
scriptEnv.VIA_MQTT = String(message.viaMqtt);
1155911572

@@ -12488,7 +12501,9 @@ class MeshtasticManager implements ISourceManager {
1248812501

1248912502
// {RSSI} - Received Signal Strength Indicator
1249012503
if (result.includes('{RSSI}')) {
12491-
const rssiValue = (rxRssi !== undefined && rxRssi !== null && rxRssi !== 0)
12504+
// rx_rssi has explicit presence since firmware 2.8, so a present 0 is a
12505+
// genuine 0 dBm reading, not "unset" (issue #3548).
12506+
const rssiValue = (rxRssi !== undefined && rxRssi !== null)
1249212507
? rxRssi.toString()
1249312508
: 'N/A';
1249412509
result = result.replace(/{RSSI}/g, encode(rssiValue));

src/server/meshtasticProtobufService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,7 @@ export class MeshtasticProtobufService {
996996
'RETICULUM_TUNNEL_APP': 76,
997997
'CAYENNE_APP': 77,
998998
'ATAK_PLUGIN_V2': 78,
999+
'LORA_OTA_APP': 79,
9991000
'GROUPALARM_APP': 112,
10001001
'PRIVATE_APP': 256,
10011002
'ATAK_FORWARDER': 257,
@@ -1062,6 +1063,7 @@ export class MeshtasticProtobufService {
10621063
76: 'RETICULUM_TUNNEL_APP',
10631064
77: 'CAYENNE_APP',
10641065
78: 'ATAK_PLUGIN_V2',
1066+
79: 'LORA_OTA_APP',
10651067
112: 'GROUPALARM_APP',
10661068
256: 'PRIVATE_APP',
10671069
257: 'ATAK_FORWARDER',

src/server/routes/adminRoutes.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,7 @@ router.post('/get-device-metadata', requireAdmin(), async (req, res) => {
879879
hasWifi: localNodeInfo.hasWifi ?? false,
880880
hasBluetooth: localNodeInfo.hasBluetooth ?? false,
881881
hasEthernet: localNodeInfo.hasEthernet ?? false,
882+
hasXeddsa: localNodeInfo.hasXeddsa ?? false,
882883
isBridged: gdmManager.isLocalNodeBridged(),
883884
canShutdown: false,
884885
hasRemoteHardware: false,
@@ -918,7 +919,12 @@ router.post('/get-device-metadata', requireAdmin(), async (req, res) => {
918919
role: metadata.role || 0,
919920
positionFlags: metadata.positionFlags || 0,
920921
hwModel: metadata.hwModel || 0,
921-
hasRemoteHardware: metadata.hasRemoteHardware || false
922+
hasRemoteHardware: metadata.hasRemoteHardware || false,
923+
// Read-only build capability added in firmware 2.8 (DeviceMetadata
924+
// field 14): whether XEdDSA packet signature verification is
925+
// compiled in. Distinguishes "this node cannot sign" from "this
926+
// node did not sign this packet" (#3923).
927+
hasXeddsa: metadata.hasXeddsa || false
922928
}
923929
});
924930
} else {

src/server/utils/spoofDetection.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@ const OTHER = 0xaabbccdd;
1111

1212
// Genuine local transmission as the host sees it: INTERNAL transport, fresh hop
1313
// count, no reception metadata.
14+
//
15+
// `rxRssi` is null, not 0: firmware 2.8 gave rx_rssi explicit presence
16+
// (`optional int32 rx_rssi = 12`), so an absent field decodes to null while a
17+
// genuine 0 dBm reading decodes to 0 and is carried on the wire. `rxSnr` keeps
18+
// proto3 implicit presence, so 0 there is still indistinguishable from absent.
1419
const genuineLocalTx: SpoofDetectionInput = {
1520
fromNum: LOCAL,
1621
localNodeNum: LOCAL,
1722
transportMechanism: 0, // INTERNAL
1823
hopStart: 3,
1924
hopLimit: 3,
2025
rxSnr: 0,
21-
rxRssi: 0,
26+
rxRssi: null,
2227
viaMqtt: false,
2328
};
2429

@@ -49,8 +54,30 @@ describe('hasRfReceptionMarkers', () => {
4954
expect(hasRfReceptionMarkers({ ...genuineLocalTx, rxRssi: -95 })).toBe(true);
5055
});
5156

52-
it('does NOT treat rxSnr/rxRssi of exactly 0 as a marker (self-origin default)', () => {
53-
expect(hasRfReceptionMarkers({ ...genuineLocalTx, rxSnr: 0, rxRssi: 0 })).toBe(false);
57+
it('does NOT treat an rxSnr of exactly 0 as a marker (implicit presence, #3590)', () => {
58+
expect(hasRfReceptionMarkers({ ...genuineLocalTx, rxSnr: 0, rxRssi: null })).toBe(false);
59+
});
60+
61+
// Firmware 2.8 / protobufs `optional int32 rx_rssi = 12` (firmware PR #11271,
62+
// issue #3548): 0 dBm is a real reading on SX126x/LR11x0/LR20x0 and can even go
63+
// positive on SX127x. A *present* 0 is therefore a genuine RF marker; only an
64+
// absent field (null/undefined) means "no reading".
65+
it('treats a present rxRssi of exactly 0 as a reception marker (2.8 explicit presence)', () => {
66+
expect(hasRfReceptionMarkers({ ...genuineLocalTx, rxRssi: 0 })).toBe(true);
67+
});
68+
69+
it('does NOT treat an absent rxRssi as a marker', () => {
70+
expect(hasRfReceptionMarkers({ ...genuineLocalTx, rxRssi: null })).toBe(false);
71+
expect(hasRfReceptionMarkers({ ...genuineLocalTx, rxRssi: undefined })).toBe(false);
72+
});
73+
74+
// A 2.8 node spoofing our local node at point-blank range reports rx_rssi 0.
75+
// Under the pre-2.8 `rxRssi !== 0` rule that packet was misread as our own
76+
// transmission; presence semantics classify it correctly.
77+
it('flags a spoof whose only RF marker is a present rxRssi of 0', () => {
78+
expect(
79+
detectLocalNodeSpoof({ ...genuineLocalTx, rxRssi: 0, packetId: 4242, wasRecentlySentByUs: false })
80+
).toEqual({ isGenuineLocalTx: false, spoofSuspected: true });
5481
});
5582

5683
it('treats hopStart > hopLimit (travelled) as a marker', () => {

src/server/utils/spoofDetection.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,24 @@ function isLocalOriginTransport(transportMechanism: number | null | undefined):
8787

8888
/**
8989
* Whether a packet bears any marker proving it was received over the air rather
90-
* than originated locally. Conservative: a marker must be unambiguously present
91-
* (rx SNR/RSSI default to 0 on self-origin, so 0 is NOT treated as a marker).
90+
* than originated locally. Conservative: a marker must be unambiguously present.
91+
*
92+
* `rx_snr` still has proto3 implicit presence, so a self-originated packet and a
93+
* genuine 0 dB reception are byte-identical on the wire — 0 is NOT a marker.
94+
*
95+
* `rx_rssi` gained explicit presence in firmware 2.8 (`has_rx_rssi`, firmware
96+
* PR #11271, protobufs `optional int32 rx_rssi = 12`). 0 dBm is a real reading
97+
* on SX126x/LR11x0/LR20x0 and can even go positive on SX127x, so a *present*
98+
* rx_rssi of 0 is a genuine RF marker. Absent stays absent: the decoder yields
99+
* null/undefined, and pre-2.8 senders never put a 0 on the wire at all, so this
100+
* is a no-op against 2.7 firmware. See issue #3548.
92101
*/
93102
export function hasRfReceptionMarkers(input: SpoofDetectionInput): boolean {
94103
const { transportMechanism, hopStart, hopLimit, rxSnr, rxRssi, viaMqtt } = input;
95104

96105
// Reception signal metadata — only sent to the host for RF receptions.
97106
if (rxSnr !== undefined && rxSnr !== null && rxSnr !== 0) return true;
98-
if (rxRssi !== undefined && rxRssi !== null && rxRssi !== 0) return true;
107+
if (rxRssi !== undefined && rxRssi !== null) return true;
99108

100109
// Travelled at least one hop (originator set hop_start, relays decremented hop_limit).
101110
if (

0 commit comments

Comments
 (0)