Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents/meshtastic.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshta
- **S&F chat history:** `src/renderer/lib/meshtasticBacklogUtils.ts` — `CLIENT_HISTORY` on primary router heartbeat after RF configure (auto: 50-msg cap, 120 min window cap, 15 min per-server cooldown, 5 min offline gate; `storeForwardAutoFetchHistory` opt-out; `storeForwardHistoryProfile: 'conservative' | 'aggressive'` in `defaultAppSettings.ts` tunes offline gate / cooldown / cap aggressiveness; manual catch-up in Chat). Protobuf decode for replayed text, `via_store_forward` on messages; do not await SDK queue for history (async replay).
- **MQTT broker clientId:** `src/main/mqtt-broker-client-id.ts` — stable per-install IDs in `app_settings` (`meshtasticMqttClientId`, `meshcoreMqttClientId`); MeshCore LetsMesh `v1_` username unchanged as clientId.
- **PKC remote admin (firmware 2.5+):** `meshtasticRemoteAdmin.ts` — PKI-wrapped `AdminMessage` via `MeshDevice.sendRaw()` (`pkiEncrypted: true`, channel omitted on wire); session passkeys (~300s); tab-scoped snapshot routes in `meshtasticRemoteAdminSnapshot.ts` (Channels-first LoRa load). Per-node keys: `meshtasticRemoteAdminKeyStorage.ts` (`meshtasticRemoteAdminKey:<nodeNum>` in `app_settings`; base64 / `base64:` / 64-char hex paste). Dest public key: NodeDB hex first, stored admin-key base64 fallback. `useMeshtasticRuntime`: `configureTargetNodeNum`, `remoteConfigSnapshot`, `runRemoteAdminOp` (errors → UI + toast); serialize admin reads with S&F (`remoteAdminReadsActiveCount` in `meshtasticBacklogUtils.ts`). **Requires connected local radio** (MQTT-only cannot admin). UI: `ConfigureNodeSelector.tsx`; NodeDetailModal admin key + **Configure node remotely**; SecurityPanel **Copy** public key. Persist last target in `meshtasticConfigureTargetNodeNum`. Gate with `hasRemoteAdmin`. Legacy admin channel (PSK + `"admin"`) out of scope.
- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` merges radio NodeDB timestamps with client-side values (max wins). **Configure replay guard** must apply in `nodeStore` (`upsertNode`, `updatePosition`, `meshtasticLastHeardPatch`) via `meshtasticConfigurePhase.ts`, not only in `meshtasticNodeSideEffects` — PacketRouter updates the store before side effects run. During `device.configure()`, the Meshtastic SDK replays NodeDB as `node_info` frames and may emit synthetic `onUserPacket` / `onPositionPacket` with `rxTime = now`; guards skip those bumps. UserPacket path uses `mergeMeshtasticUserPacketLastHeard` (ms); NodeDB path uses `computeNodeInfoLastHeardMs` (sec). SQLite stores `last_heard` as unix seconds; `meshtasticDbCacheHydration.ts` normalizes to ms on hydrate (`normalizeLastHeardMs`); stale/online checks use `effectiveLastHeardMs`. BLE configure uses a **stall watchdog** (`MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`, reset on each replayed `node_info` / position / telemetry via `touchMeshtasticConfigureProgress`) so large NodeDBs are not cut off mid-stream. Node list JSON export emits `last_heard` as unix seconds with `last_heard_unit: 'unix_sec'`.
- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` merges radio NodeDB timestamps with client-side values (max wins). **Configure replay guard** must apply in `nodeStore` (`upsertNode`, `updatePosition`, `meshtasticLastHeardPatch`) via `meshtasticConfigurePhase.ts`, not only in `meshtasticNodeSideEffects` — PacketRouter updates the store before side effects run. During `device.configure()`, the Meshtastic SDK replays NodeDB as `node_info` frames and may emit synthetic `onUserPacket` / `onPositionPacket` with `rxTime = now`; guards skip those bumps. UserPacket path uses `mergeMeshtasticUserPacketLastHeard` (ms); NodeDB path uses `computeNodeInfoLastHeardMs` (sec). **Protocol decode must use `meshtasticPacketRxTimeMs`** — `@meshtastic/core` `PacketMetadata.rxTime` is already a `Date` (ms); never `rxTime * 1000` (Date×1000 → ~1e15; regression lock in `MeshtasticProtocol.test.ts` Date-shaped rxTime + source-policy `meshtastic-protocol-rxtime-via-helper`). SQLite stores `last_heard` as unix seconds; `meshtasticDbCacheHydration.ts` normalizes to ms on hydrate (`normalizeLastHeardMs`); stale/online checks use `effectiveLastHeardMs`. BLE configure uses a **stall watchdog** (`MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`, reset on each replayed `node_info` / position / telemetry via `touchMeshtasticConfigureProgress`) so large NodeDBs are not cut off mid-stream. Node list JSON export emits `last_heard` as unix seconds with `last_heard_unit: 'unix_sec'`.
- **Static GPS:** `src/renderer/lib/gpsSource.ts` — App tab static coordinates sync to self-node, map, and radio `setPosition`.
8 changes: 8 additions & 0 deletions src/architecture/sourcePolicyRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,12 @@ export const SOURCE_POLICY_RULES: SourcePolicyRule[] = [
require: /hydrateAxeThemeColors/,
message: 'Call hydrateAxeThemeColors() before axe() so contrast checks use real theme tokens',
},
{
id: 'meshtastic-protocol-rxtime-via-helper',
include: ['src/renderer/lib/protocols/MeshtasticProtocol.ts'],
require: /meshtasticPacketRxTimeMs/,
forbid: /rxTime\s*\*\s*1000/,
message:
'SDK PacketMetadata.rxTime is Date (ms); use meshtasticPacketRxTimeMs — never rxTime * 1000',
},
];
28 changes: 28 additions & 0 deletions src/renderer/components/NodeListPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,34 @@ describe('NodeListPanel JSON export', () => {
expect(parsed.nodes[0]?.last_heard).toBe(1_700_000_000);
expect(parsed.nodes[0]?.last_heard_unit).toBe('unix_sec');
});

it('exports Date×1000 overshoot last_heard as unix seconds (no 13-digit values)', async () => {
const user = userEvent.setup();
const radioSec = 1_787_340_581;
const doubleConverted = radioSec * 1_000_000;
const nodes = new Map<number, MeshNode>([
[42, makeNode({ node_id: 42, long_name: 'Poisoned', last_heard: doubleConverted })],
]);
render(
<NodeListPanel
nodes={nodes}
myNodeNum={0}
onNodeClick={vi.fn()}
locationFilter={defaultFilter}
onToggleFavorite={vi.fn()}
mode="meshtastic"
/>,
);
await user.click(screen.getByRole('button', { name: 'Export JSON' }));
const [blob] = vi.mocked(downloadBlob).mock.calls[0];
const text = await blob.text();
const parsed = JSON.parse(text) as {
nodes: { last_heard: number; last_heard_unit: string }[];
};
expect(parsed.nodes[0]?.last_heard).toBe(radioSec);
expect(parsed.nodes[0]?.last_heard_unit).toBe('unix_sec');
expect(parsed.nodes[0]?.last_heard).toBeLessThan(1_000_000_000_000);
});
});

describe('NodeListPanel show on map', () => {
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/lib/nodeStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ describe('getNodeStatus', () => {
expect(getNodeStatus(oneSecondOver7d, meshtasticStale, meshtasticOffline)).toBe('offline');
});

it('collapses Date×1000 overshoot so stale wall-time is not stuck online', () => {
const meshtasticStale = 2 * 60 * 60 * 1000;
const meshtasticOffline = 7 * 24 * 60 * 60 * 1000;
const threeHoursAgoMs = Date.now() - 3 * 60 * 60 * 1000;
const doubleConverted = threeHoursAgoMs * 1000;
// Without normalize collapse, effectiveLastHeardMs would clamp far-future to now → online.
expect(getNodeStatus(doubleConverted, meshtasticStale, meshtasticOffline)).toBe('stale');
expect(normalizeLastHeardMs(doubleConverted)).toBe(threeHoursAgoMs);
expect(effectiveLastHeardMs(doubleConverted)).toBe(threeHoursAgoMs);
});

it('effectiveLastHeardMs clamps far-future timestamps to now', () => {
const nowMs = 1_700_000_000_000;
const farFutureSec = 1_700_000_000 + 86_400;
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/lib/nodeStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ export type NodeStatus = 'online' | 'stale' | 'offline';

export function normalizeLastHeardMs(lastHeard: number): number {
if (!lastHeard || !Number.isFinite(lastHeard)) return 0;
let value = lastHeard;
// Collapse Date×1000 overshoot (~1e15) to epoch milliseconds.
const overshootMs = LAST_HEARD_MS_THRESHOLD * 1000;
for (let i = 0; i < 3 && value >= overshootMs; i++) {
value = Math.floor(value / 1000);
}
// MeshCore uses epoch seconds; Meshtastic paths usually use epoch milliseconds.
return lastHeard < LAST_HEARD_MS_THRESHOLD ? lastHeard * 1000 : lastHeard;
return value < LAST_HEARD_MS_THRESHOLD ? value * 1000 : value;
}

/** Normalize epoch seconds or milliseconds to Unix seconds (for MeshCore contact merge). */
Expand Down
122 changes: 122 additions & 0 deletions src/renderer/lib/protocols/MeshtasticProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,125 @@ describe('MeshtasticProtocol position outbound guards', () => {
await expect(meshtasticProtocol.requestPosition(device, Number.NaN)).rejects.toThrow(TypeError);
});
});

/**
* SDK `@meshtastic/core` PacketMetadata.rxTime is a Date (already ms).
* Naive `rxTime * 1000` double-converts to ~1e15 and poisons last_heard / export.
*/
describe('MeshtasticProtocol Date-shaped rxTime', () => {
const RADIO_SEC = 1_787_340_581;
const EXPECTED_MS = RADIO_SEC * 1000;
const DOUBLE_CONVERTED = RADIO_SEC * 1_000_000;

it('maps text_message rxTime Date to epoch ms (not Date×1000)', () => {
const { device, emit } = mockMeshDevice();
const events: DomainEvent[] = [];
const teardown = meshtasticProtocol.subscribe(device, (e) => events.push(e));
emit('onMeshPacket', {
payloadVariant: {
case: 'decoded',
value: {
portnum: Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode('ping'),
},
},
from: 0xabcd,
to: 0xffffffff,
id: 77,
channel: 0,
rxTime: new Date(EXPECTED_MS),
});
const text = events.find((e) => e.type === 'text_message');
expect(text?.type === 'text_message' && text.payload.timestamp).toBe(EXPECTED_MS);
expect(text?.type === 'text_message' && text.payload.timestamp).not.toBe(DOUBLE_CONVERTED);
teardown();
});

it('maps UserPacket rxTime Date to lastHeardAt epoch ms', () => {
const { device, emit } = mockMeshDevice();
const events: DomainEvent[] = [];
const teardown = meshtasticProtocol.subscribe(device, (e) => events.push(e));
emit('onUserPacket', {
from: 0x1234,
rxTime: new Date(EXPECTED_MS),
data: { longName: 'Live', shortName: 'LV' },
});
const info = events.find((e) => e.type === 'node_info');
expect(info?.type === 'node_info' && info.payload.lastHeardAt).toBe(EXPECTED_MS);
expect(info?.type === 'node_info' && info.payload.lastHeardAt).not.toBe(DOUBLE_CONVERTED);
teardown();
});

it('maps position and telemetry rxTime Date to epoch ms', () => {
const { device, emit } = mockMeshDevice();
const events: DomainEvent[] = [];
const teardown = meshtasticProtocol.subscribe(device, (e) => events.push(e));
emit('onPositionPacket', {
from: 42,
rxTime: new Date(EXPECTED_MS),
data: { latitudeI: 400_000_000, longitudeI: -1_050_000_000 },
});
emit('onTelemetryPacket', {
from: 42,
rxTime: new Date(EXPECTED_MS),
data: { deviceMetrics: { batteryLevel: 80 } },
});
const position = events.find((e) => e.type === 'position');
const telemetry = events.find((e) => e.type === 'telemetry');
expect(position?.type === 'position' && position.payload.timestamp).toBe(EXPECTED_MS);
expect(telemetry?.type === 'telemetry' && telemetry.payload.timestamp).toBe(EXPECTED_MS);
teardown();
});

it('maps traceroute rxTime Date to epoch ms', () => {
const { device, emit } = mockMeshDevice();
const events: DomainEvent[] = [];
const teardown = meshtasticProtocol.subscribe(device, (e) => events.push(e));
emit('onTraceRoutePacket', {
id: 99,
from: 1,
to: 2,
rxTime: new Date(EXPECTED_MS),
data: { route: [11, 22] },
});
const tr = events.find((e) => e.type === 'trace_route');
expect(tr?.type === 'trace_route' && tr.payload.timestamp).toBe(EXPECTED_MS);
expect(tr?.type === 'trace_route' && tr.payload.timestamp).not.toBe(DOUBLE_CONVERTED);
teardown();
});

it('maps waypoint rxTime Date to epoch ms', () => {
const { device, emit } = mockMeshDevice();
const events: DomainEvent[] = [];
const teardown = meshtasticProtocol.subscribe(device, (e) => events.push(e));
emit('onWaypointPacket', {
from: 7,
to: 0xffffffff,
rxTime: new Date(EXPECTED_MS),
data: {
id: 1001,
name: 'WP',
latitudeI: 400_000_000,
longitudeI: -1_050_000_000,
},
});
const wp = events.find((e) => e.type === 'waypoint');
expect(wp?.type === 'waypoint' && wp.payload.timestamp).toBe(EXPECTED_MS);
expect(wp?.type === 'waypoint' && wp.payload.timestamp).not.toBe(DOUBLE_CONVERTED);
teardown();
});

it('still converts numeric unix-second rxTime to epoch ms', () => {
const { device, emit } = mockMeshDevice();
const events: DomainEvent[] = [];
const teardown = meshtasticProtocol.subscribe(device, (e) => events.push(e));
emit('onUserPacket', {
from: 0x55,
rxTime: RADIO_SEC,
data: { longName: 'Sec', shortName: 'SC' },
});
const info = events.find((e) => e.type === 'node_info');
expect(info?.type === 'node_info' && info.payload.lastHeardAt).toBe(EXPECTED_MS);
teardown();
});
});
25 changes: 13 additions & 12 deletions src/renderer/lib/protocols/MeshtasticProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { createPacketDedupeRegistry } from '../drivers/packetDedupeRegistry';
import { meshtasticHwModelName } from '../hardwareModels';
import { meshtasticDeviceStatusForCode } from '../meshtastic/meshtasticDeviceStatus';
import { meshtasticPacketRxTimeMs } from '../meshtasticLastHeard';
import { meshtasticComputedRfHopsAway } from '../meshtasticRfHops';
import type { ProtocolCapabilities } from '../radio/BaseRadioProvider';
import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider';
Expand Down Expand Up @@ -133,7 +134,7 @@ function boundedLatLonFromScaledI(
interface TraceRouteMeshPacket {
from: number;
to?: number;
rxTime?: number;
rxTime?: Date | number;
data?: { route?: readonly number[]; routeBack?: readonly number[] };
payloadVariant?: {
case?: string;
Expand Down Expand Up @@ -753,7 +754,7 @@ export class MeshtasticProtocol implements Protocol {
to: number;
id: number;
channel?: number;
rxTime?: number;
rxTime?: Date | number;
rxSnr?: number;
rxRssi?: number;
hopStart?: number;
Expand Down Expand Up @@ -785,7 +786,7 @@ export class MeshtasticProtocol implements Protocol {
payload:
text.length > MAX_TEXT_MESSAGE_CHARS ? text.slice(0, MAX_TEXT_MESSAGE_CHARS) : text,
channelIndex: isFiniteNumber(p.channel) ? Math.trunc(p.channel) : 0,
timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(),
timestamp: meshtasticPacketRxTimeMs(p.rxTime) || Date.now(),
rxSnr: p.rxSnr,
rxRssi: p.rxRssi,
...(hopCount != null ? { hopCount } : {}),
Expand All @@ -799,7 +800,7 @@ export class MeshtasticProtocol implements Protocol {
private decodeUserPacket(raw: unknown): DomainEvent[] {
const p = raw as {
from: number;
rxTime?: number;
rxTime?: Date | number;
data?: {
longName?: string;
shortName?: string;
Expand All @@ -823,7 +824,7 @@ export class MeshtasticProtocol implements Protocol {
role: user.role,
publicKey: user.publicKey,
isLicensed: user.isLicensed,
lastHeardAt: p.rxTime ? p.rxTime * 1000 : Date.now(),
lastHeardAt: meshtasticPacketRxTimeMs(p.rxTime) || Date.now(),
fromUserPacket: true,
},
},
Expand Down Expand Up @@ -887,7 +888,7 @@ export class MeshtasticProtocol implements Protocol {
private decodePosition(raw: unknown): DomainEvent[] {
const p = raw as {
from: number;
rxTime?: number;
rxTime?: Date | number;
data: { latitudeI?: number; longitudeI?: number; altitude?: number };
};
const nodeId = normalizedNodeNum(p.from);
Expand All @@ -903,7 +904,7 @@ export class MeshtasticProtocol implements Protocol {
latitude,
longitude,
altitude: isFiniteNumber(p.data.altitude) ? p.data.altitude : undefined,
timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(),
timestamp: meshtasticPacketRxTimeMs(p.rxTime) || Date.now(),
},
},
];
Expand All @@ -912,7 +913,7 @@ export class MeshtasticProtocol implements Protocol {
private decodeTelemetry(raw: unknown): DomainEvent[] {
const p = raw as {
from: number;
rxTime?: number;
rxTime?: Date | number;
data: {
variant?: { case?: string; value?: Record<string, unknown> };
deviceMetrics?: Record<string, unknown>;
Expand All @@ -925,7 +926,7 @@ export class MeshtasticProtocol implements Protocol {
type: 'telemetry',
payload: {
nodeId: p.from,
timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(),
timestamp: meshtasticPacketRxTimeMs(p.rxTime) || Date.now(),
variantCase: p.data.variant?.case ?? (p.data.deviceMetrics ? 'deviceMetrics' : undefined),
batteryLevel: num('batteryLevel'),
voltage: num('voltage'),
Expand Down Expand Up @@ -958,7 +959,7 @@ export class MeshtasticProtocol implements Protocol {
from: number;
to?: number;
channel?: number;
rxTime?: number;
rxTime?: Date | number;
data: {
id?: number;
name?: string;
Expand Down Expand Up @@ -991,7 +992,7 @@ export class MeshtasticProtocol implements Protocol {
from: normalizedNodeNum(p.from) ?? 0,
to: normalizedNodeNum(p.to),
channelIndex: isFiniteNumber(p.channel) ? Math.trunc(p.channel) : undefined,
timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(),
timestamp: meshtasticPacketRxTimeMs(p.rxTime) || Date.now(),
},
},
];
Expand All @@ -1013,7 +1014,7 @@ export class MeshtasticProtocol implements Protocol {
to: normalizedNodeNum(p.to) ?? fields.dataLayerDest ?? 0,
route: fields.route,
routeBack: fields.routeBack,
timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(),
timestamp: meshtasticPacketRxTimeMs(p.rxTime) || Date.now(),
dataLayerDest: fields.dataLayerDest,
dataLayerSource: fields.dataLayerSource,
replyId: fields.replyId,
Expand Down
Loading