This commit is contained in:
Jack Kingsman
2026-03-07 15:05:13 -08:00
parent f302cc04ae
commit 5f039b9c41
25 changed files with 583 additions and 98 deletions
+7 -2
View File
@@ -1,7 +1,12 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import {
isValidLocation,
calculateDistance,
formatDistance,
parsePathHops,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -413,7 +418,7 @@ export function ContactInfoPane({
className="flex justify-between items-center text-sm"
>
<span className="font-mono text-xs truncate">
{p.path ? p.path.match(/.{2}/g)!.join(' → ') : '(direct)'}
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)}
+1
View File
@@ -1,3 +1,4 @@
import '../utils/meshcoreDecoderPatch';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker';
import NoSleep from 'nosleep.js';
@@ -1,3 +1,4 @@
import '../utils/meshcoreDecoderPatch';
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
+1
View File
@@ -1,3 +1,4 @@
import './utils/meshcoreDecoderPatch';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '../api';
import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactDetail } from '../types';
vi.mock('../api', () => ({
api: {
getContactDetail: vi.fn(),
},
}));
const baseContact: Contact = {
public_key: 'aa'.repeat(32),
name: 'Repeater Alpha',
type: 2,
flags: 0,
last_path: null,
last_path_len: 2,
last_advert: 1700000000,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: 1699990000,
};
describe('ContactInfoPane', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('renders advert paths using hop-aware grouping', async () => {
const detail: ContactDetail = {
contact: baseContact,
name_history: [],
dm_message_count: 0,
channel_message_count: 0,
most_active_rooms: [],
advert_paths: [
{
path: '20273031',
path_len: 2,
next_hop: '2027',
first_seen: 1700000000,
last_seen: 1700000100,
heard_count: 3,
},
],
advert_frequency: null,
nearest_repeaters: [],
};
vi.mocked(api.getContactDetail).mockResolvedValue(detail);
render(
<ContactInfoPane
contactKey={baseContact.public_key}
onClose={vi.fn()}
contacts={[baseContact]}
config={null}
favorites={[]}
onToggleFavorite={vi.fn()}
/>
);
expect(await screen.findByText('2027 → 3031')).toBeInTheDocument();
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import '../utils/meshcoreDecoderPatch';
import { MeshCoreDecoder } from '@michaelhart/meshcore-decoder';
describe('meshcoreDecoderPatch', () => {
it('groups two-byte hops and preserves payload extraction', () => {
const decoded = MeshCoreDecoder.decode('3E4220273031DEADBEEF');
expect(decoded.isValid).toBe(true);
expect(decoded.pathLength).toBe(2);
expect(decoded.path).toEqual(['2027', '3031']);
expect(decoded.payload.raw).toBe('DEADBEEF');
});
it('groups three-byte hops and preserves payload extraction', () => {
const decoded = MeshCoreDecoder.decode('3E82112233445566DEADBEEF');
expect(decoded.isValid).toBe(true);
expect(decoded.pathLength).toBe(2);
expect(decoded.path).toEqual(['112233', '445566']);
expect(decoded.payload.raw).toBe('DEADBEEF');
});
it('patches async decode entrypoints used by the cracker', async () => {
const decoded = await MeshCoreDecoder.decodeWithVerification('3E4220273031DEADBEEF');
expect(decoded.isValid).toBe(true);
expect(decoded.pathLength).toBe(2);
expect(decoded.path).toEqual(['2027', '3031']);
expect(decoded.payload.raw).toBe('DEADBEEF');
});
it('validates multi-byte packets using rewritten byte lengths', () => {
const result = MeshCoreDecoder.validate('3E82112233445566DEADBEEF');
expect(result.isValid).toBe(true);
});
});
+4
View File
@@ -66,6 +66,10 @@ describe('parsePathHops', () => {
expect(parsePathHops('1A2B3C4D', 2)).toEqual(['1A2B', '3C4D']);
});
it('parses three-byte hops when path length is provided', () => {
expect(parsePathHops('1A2B3C4D5E6F', 2)).toEqual(['1A2B3C', '4D5E6F']);
});
it('converts to uppercase', () => {
expect(parsePathHops('1a2b')).toEqual(['1A', '2B']);
});
@@ -15,6 +15,10 @@ describe('extractRawPacketPayload', () => {
expect(extractRawPacketPayload('14010203044220273031DEADBEEF')).toBe('DEADBEEF');
});
it('extracts payload for three-byte-hop packets', () => {
expect(extractRawPacketPayload('1582112233445566DEADBEEF')).toBe('DEADBEEF');
});
it('returns null for truncated multi-byte path data', () => {
expect(extractRawPacketPayload('15422027')).toBeNull();
});
+1
View File
@@ -65,6 +65,7 @@ export interface Contact {
flags: number;
last_path: string | null;
last_path_len: number;
out_path_hash_mode?: number | null;
last_advert: number | null;
lat: number | null;
lon: number | null;
+115
View File
@@ -0,0 +1,115 @@
import { MeshCorePacketDecoder, bytesToHex, hexToBytes } from '@michaelhart/meshcore-decoder';
type DecoderClass = typeof MeshCorePacketDecoder & {
__multiBytePathPatchApplied?: boolean;
};
type DecoderOptions = Parameters<typeof MeshCorePacketDecoder.decode>[1];
interface PathRewrite {
hexData: string;
hopCount: number;
pathHashSize: number;
}
function decodePathMetadata(pathByte: number): {
hopCount: number;
pathHashSize: number;
pathByteLength: number;
} {
const pathHashSize = (pathByte >> 6) + 1;
const hopCount = pathByte & 0x3f;
return {
hopCount,
pathHashSize,
pathByteLength: hopCount * pathHashSize,
};
}
function getPackedPathOffset(bytes: Uint8Array): number | null {
if (bytes.length < 2) return null;
let offset = 1;
const routeType = bytes[0] & 0x03;
if (routeType === 0x00 || routeType === 0x03) {
if (bytes.length < offset + 4) return null;
offset += 4;
}
return bytes.length > offset ? offset : null;
}
function rewritePackedPathHex(hexData: string): PathRewrite | null {
let bytes: Uint8Array;
try {
bytes = hexToBytes(hexData);
} catch {
return null;
}
const pathOffset = getPackedPathOffset(bytes);
if (pathOffset === null) return null;
const { hopCount, pathHashSize, pathByteLength } = decodePathMetadata(bytes[pathOffset]);
if (pathHashSize === 1) return null;
if (bytes.length < pathOffset + 1 + pathByteLength) return null;
const rewritten = bytes.slice();
rewritten[pathOffset] = pathByteLength;
return {
hexData: bytesToHex(rewritten),
hopCount,
pathHashSize,
};
}
function regroupPath(path: string[] | null, pathHashSize: number): string[] | null {
if (!path || pathHashSize <= 1) return path;
const hops: string[] = [];
for (let i = 0; i + pathHashSize <= path.length; i += pathHashSize) {
hops.push(path.slice(i, i + pathHashSize).join(''));
}
return hops;
}
function normalizeDecodedPacket<
T extends {
isValid?: boolean;
pathLength?: number;
path?: string[] | null;
},
>(packet: T, rewrite: PathRewrite | null): T {
if (!rewrite || packet?.isValid === false) return packet;
packet.pathLength = rewrite.hopCount;
packet.path = regroupPath(packet.path ?? null, rewrite.pathHashSize);
return packet;
}
const decoder = MeshCorePacketDecoder as DecoderClass;
if (!decoder.__multiBytePathPatchApplied) {
const originalDecode = decoder.decode.bind(decoder);
const originalDecodeWithVerification = decoder.decodeWithVerification.bind(decoder);
const originalValidate = decoder.validate.bind(decoder);
decoder.decode = ((hexData: string, options?: DecoderOptions) => {
const rewrite = rewritePackedPathHex(hexData);
const packet = originalDecode(rewrite?.hexData ?? hexData, options);
return normalizeDecodedPacket(packet, rewrite);
}) as typeof decoder.decode;
decoder.decodeWithVerification = (async (hexData: string, options?: DecoderOptions) => {
const rewrite = rewritePackedPathHex(hexData);
const packet = await originalDecodeWithVerification(rewrite?.hexData ?? hexData, options);
return normalizeDecodedPacket(packet, rewrite);
}) as typeof decoder.decodeWithVerification;
decoder.validate = ((hexData: string) => {
const rewrite = rewritePackedPathHex(hexData);
return originalValidate(rewrite?.hexData ?? hexData);
}) as typeof decoder.validate;
decoder.__multiBytePathPatchApplied = true;
}
+1
View File
@@ -1,3 +1,4 @@
import './meshcoreDecoderPatch';
import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder';
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types';
import { hashString } from './contactAvatar';