diff --git a/docs/agents/meshtastic.md b/docs/agents/meshtastic.md index 134eef588..24246e512 100644 --- a/docs/agents/meshtastic.md +++ b/docs/agents/meshtastic.md @@ -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:` 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`. diff --git a/src/architecture/sourcePolicyRules.ts b/src/architecture/sourcePolicyRules.ts index b4c247892..125dcacd3 100644 --- a/src/architecture/sourcePolicyRules.ts +++ b/src/architecture/sourcePolicyRules.ts @@ -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', + }, ]; diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index 8c21ac578..563c45372 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -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([ + [42, makeNode({ node_id: 42, long_name: 'Poisoned', last_heard: doubleConverted })], + ]); + render( + , + ); + 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', () => { diff --git a/src/renderer/lib/nodeStatus.test.ts b/src/renderer/lib/nodeStatus.test.ts index 9a167397a..df3255d4a 100644 --- a/src/renderer/lib/nodeStatus.test.ts +++ b/src/renderer/lib/nodeStatus.test.ts @@ -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; diff --git a/src/renderer/lib/nodeStatus.ts b/src/renderer/lib/nodeStatus.ts index eeaf92e94..9cf4a2b7f 100644 --- a/src/renderer/lib/nodeStatus.ts +++ b/src/renderer/lib/nodeStatus.ts @@ -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). */ diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts index 945c19e34..437d2c45e 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts @@ -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(); + }); +}); diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.ts b/src/renderer/lib/protocols/MeshtasticProtocol.ts index 40ddc6a7f..f665f43d7 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.ts @@ -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'; @@ -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; @@ -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; @@ -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 } : {}), @@ -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; @@ -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, }, }, @@ -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); @@ -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(), }, }, ]; @@ -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 }; deviceMetrics?: Record; @@ -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'), @@ -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; @@ -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(), }, }, ]; @@ -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, diff --git a/src/shared/lastHeardUnits.test.ts b/src/shared/lastHeardUnits.test.ts index 01ccf9a7b..eff29b7c2 100644 --- a/src/shared/lastHeardUnits.test.ts +++ b/src/shared/lastHeardUnits.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { LAST_HEARD_MS_THRESHOLD, normalizeLastHeardToUnixSec } from './lastHeardUnits'; +import { + LAST_HEARD_MS_THRESHOLD, + NODES_LAST_HEARD_SEC_SQL, + normalizeLastHeardToUnixSec, +} from './lastHeardUnits'; + +/** Evaluate {@link NODES_LAST_HEARD_SEC_SQL} CASE branches in JS (same thresholds/divisors). */ +function evalNodesLastHeardSecSql(lastHeard: number): number { + if (lastHeard >= 1_000_000_000_000_000_000) return Math.trunc(lastHeard / 1_000_000_000); + if (lastHeard >= 1_000_000_000_000_000) return Math.trunc(lastHeard / 1_000_000); + if (lastHeard >= 1_000_000_000_000) return Math.trunc(lastHeard / 1_000); + return lastHeard; +} describe('normalizeLastHeardToUnixSec', () => { it('converts epoch milliseconds to seconds', () => { @@ -23,4 +35,38 @@ describe('normalizeLastHeardToUnixSec', () => { LAST_HEARD_MS_THRESHOLD - 1, ); }); + + it('collapses Date×1000 overshoot (~1e15) to unix seconds', () => { + // Field afternoon shape: Protocol did Date×1000 → ~1e15 before a single export /1000. + expect(normalizeLastHeardToUnixSec(1_787_340_581_000_000)).toBe(1_787_340_581); + }); + + it('collapses single-scale epoch ms (~1e12) to unix seconds', () => { + expect(normalizeLastHeardToUnixSec(1_787_340_581_000)).toBe(1_787_340_581); + }); + + it('collapses triple-scale overshoot (~1e18) to unix seconds', () => { + expect(normalizeLastHeardToUnixSec(1_787_340_581_000_000_000)).toBe(1_787_340_581); + }); +}); + +describe('NODES_LAST_HEARD_SEC_SQL', () => { + it('encodes three-pass thresholds matching normalizeLastHeardToUnixSec', () => { + expect(NODES_LAST_HEARD_SEC_SQL).toContain('1000000000000000000'); + expect(NODES_LAST_HEARD_SEC_SQL).toContain('/ 1000000000'); + expect(NODES_LAST_HEARD_SEC_SQL).toContain('1000000000000000'); + expect(NODES_LAST_HEARD_SEC_SQL).toContain('/ 1000000'); + expect(NODES_LAST_HEARD_SEC_SQL).toContain('1000000000000'); + expect(NODES_LAST_HEARD_SEC_SQL).toContain('/ 1000'); + }); + + it.each([ + [1_787_340_581, 1_787_340_581], + [1_787_340_581_000, 1_787_340_581], + [1_787_340_581_000_000, 1_787_340_581], + [1_787_340_581_000_000_000, 1_787_340_581], + ] as const)('CASE(%s) → %s aligned with normalize', (input, expectedSec) => { + expect(evalNodesLastHeardSecSql(input)).toBe(expectedSec); + expect(normalizeLastHeardToUnixSec(input)).toBe(expectedSec); + }); }); diff --git a/src/shared/lastHeardUnits.ts b/src/shared/lastHeardUnits.ts index 97c3bd3d9..6b49cd1ef 100644 --- a/src/shared/lastHeardUnits.ts +++ b/src/shared/lastHeardUnits.ts @@ -1,14 +1,23 @@ /** Values at or above this threshold are treated as epoch milliseconds; below as Unix seconds. */ export const LAST_HEARD_MS_THRESHOLD = 1_000_000_000_000; -/** Normalize epoch seconds or milliseconds to Unix seconds for SQLite `nodes.last_heard`. */ +/** Max divisions when collapsing Date×1000 overshoot (~1e15 / ~1e18) down to unix seconds. */ +const LAST_HEARD_NORMALIZE_MAX_DIVISIONS = 3; + +/** Normalize epoch seconds or milliseconds (incl. double-converted ms) to Unix seconds. */ export function normalizeLastHeardToUnixSec(lastHeard: number): number { if (!lastHeard || !Number.isFinite(lastHeard)) return 0; - return lastHeard >= LAST_HEARD_MS_THRESHOLD - ? Math.floor(lastHeard / 1000) - : Math.floor(lastHeard); + let value = Math.floor(lastHeard); + // Collapse double-converted ms (e.g. Date×1000 → ~1e15) down to unix seconds. + for (let i = 0; i < LAST_HEARD_NORMALIZE_MAX_DIVISIONS && value >= LAST_HEARD_MS_THRESHOLD; i++) { + value = Math.floor(value / 1000); + } + return value; } -/** SQL expression fragment for comparing mixed-unit legacy `last_heard` values as Unix seconds. */ +/** + * SQL expression fragment for comparing mixed-unit legacy `last_heard` values as Unix seconds. + * Mirrors {@link normalizeLastHeardToUnixSec} three-pass /1000: ~1e18 → /1e9, ~1e15 → /1e6, ~1e12 → /1e3. + */ export const NODES_LAST_HEARD_SEC_SQL = - 'CASE WHEN last_heard >= 1000000000000 THEN CAST(last_heard / 1000 AS INTEGER) ELSE last_heard END'; + 'CASE WHEN last_heard >= 1000000000000000000 THEN CAST(last_heard / 1000000000 AS INTEGER) WHEN last_heard >= 1000000000000000 THEN CAST(last_heard / 1000000 AS INTEGER) WHEN last_heard >= 1000000000000 THEN CAST(last_heard / 1000 AS INTEGER) ELSE last_heard END';