mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Phase 4
This commit is contained in:
@@ -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,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,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user