More APRS enhancements
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 36s
Test and build / Build receiver (push) Failing after 36s

This commit is contained in:
2026-03-05 22:24:09 +01:00
parent 7a8d7b0275
commit e83df1c143
115 changed files with 3987 additions and 956 deletions

View File

@@ -28,7 +28,7 @@ Relevant documents:
**Always run tests before completing a task.**
Run `npm run build`.
Run `npm run build` and run `pre-commit run --files changed files...`
## Coding Guidelines
@@ -36,12 +36,16 @@ Run `npm run build`.
- Prefer ESM imports (`import`/`export`)
- Use builtins from React, React-Boostrap where possible
- Follow existing code patterns in the code base
- Never make changes outside of the `ui` directory, if you think this is necessary prompt me for approval.
- Look for opportunities to create reusable styles in `src/styles` or reusable components in `src/components`
- Never make changes outside of the project directory, if you think this is necessary prompt me for approval
- Only add things related to the prompted instructions, unless it is required to make the requested changes
- When adding imports, apply the import styling rules from the next section.
### Styling
- Use React-Bootstrap components where appropriate
- Follow existing CSS patterns
- Add reusable style elements to the `src/App.scss`
- Explicit imports are better than implicit exports, be as specific as possible to minimize code size
- Order imports:
- React import first; then any react plugin
- Third-party libraries;
@@ -57,3 +61,8 @@ Run `npm run build`.
**Never modify files inside the `data/` directory.** This directory contains game data that should remain unchanged.
Never add secrets to code.
## Addressing
Don't call me "the user", refer to me as "the developer".
Refrain from using hyperbolic expressions like "excellent" and "perfect", "ok" or "good" is good enough.

146
ui/package-lock.json generated
View File

@@ -20,6 +20,7 @@
"mqtt": "^5.15.0",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-country-flag": "^3.1.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"react-qr-code": "^2.0.18",
@@ -29,6 +30,7 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/crypto-js": "^4.2.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.11.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -43,7 +45,8 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"xlsx": "^0.18.5"
}
},
"node_modules/@babel/code-frame": {
@@ -2330,6 +2333,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2337,6 +2347,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
@@ -2905,6 +2925,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -3189,6 +3219,20 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -3247,6 +3291,16 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3366,6 +3420,19 @@
"node": ">= 6"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3994,6 +4061,16 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4984,6 +5061,18 @@
}
}
},
"node_modules/react-country-flag": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-country-flag/-/react-country-flag-3.1.0.tgz",
"integrity": "sha512-JWQFw1efdv9sTC+TGQvTKXQg1NKbDU2mBiAiRWcKM9F1sK+/zjhP2yGmm8YDddWyZdXVkR8Md47rPMJmo4YO5g==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -5355,6 +5444,19 @@
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -5834,6 +5936,26 @@
"node": ">=8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5912,6 +6034,28 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -25,6 +25,7 @@
"mqtt": "^5.15.0",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-country-flag": "^3.1.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"react-qr-code": "^2.0.18",
@@ -34,6 +35,7 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/crypto-js": "^4.2.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.11.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -48,6 +50,7 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"xlsx": "^0.18.5"
}
}

View File

@@ -80,14 +80,14 @@ a:hover {
}
}
.vertical-split-50-50 {
.vertical-split-1-1 {
.split-pane-primary,
.split-pane-secondary {
flex: 1 1 0;
}
}
.vertical-split-75-25 {
.vertical-split-3-1 {
.split-pane-primary {
flex: 3 1 0;
}
@@ -97,6 +97,16 @@ a:hover {
}
}
.vertical-split-2-1 {
.split-pane-primary {
flex: 2 1 0;
}
.split-pane-secondary {
flex: 1 1 0;
}
}
.vertical-split-25-70 {
.split-pane-primary {
flex: 25 1 0;

View File

@@ -10,6 +10,7 @@ import MeshCoreMapView from './pages/meshcore/MeshCoreMapView'
import MeshCorePacketsView from './pages/meshcore/MeshCorePacketsView'
import StyleGuide from './pages/StyleGuide'
import NotFound from './pages/NotFound'
import { KeyboardNavigationProvider } from './contexts/KeyboardNavigationContext'
import './App.scss'
const navLinks = [
@@ -28,23 +29,25 @@ const withRadiosProvider = (Component: React.ComponentType<{ navLinks: typeof na
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={withRadiosProvider(Overview)()} />
<Route path="/aprs" element={withRadiosProvider(APRS)()}>
<Route index element={<Navigate to="packets" replace />} />
<Route path="packets" element={<APRSPacketsView />} />
</Route>
<Route path="/meshcore" element={withRadiosProvider(MeshCore)()}>
<Route index element={<Navigate to="packets" replace />} />
<Route path="packets" element={<MeshCorePacketsView />} />
<Route path="groupchat" element={<MeshCoreGroupChatView />} />
<Route path="map" element={<MeshCoreMapView />} />
</Route>
<Route path="/style-guide" element={<StyleGuide />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
<KeyboardNavigationProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={withRadiosProvider(Overview)()} />
<Route path="/aprs" element={withRadiosProvider(APRS)()}>
<Route index element={<Navigate to="packets" replace />} />
<Route path="packets" element={<APRSPacketsView />} />
</Route>
<Route path="/meshcore" element={withRadiosProvider(MeshCore)()}>
<Route index element={<Navigate to="packets" replace />} />
<Route path="packets" element={<MeshCorePacketsView />} />
<Route path="groupchat" element={<MeshCoreGroupChatView />} />
<Route path="map" element={<MeshCoreMapView />} />
</Route>
<Route path="/style-guide" element={<StyleGuide />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</KeyboardNavigationProvider>
)
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import ReactCountryFlag from 'react-country-flag';
import { getCountryCodeFromCallsign } from '../libs/callsignMapper';
export interface CountryFlagProps {
/** The amateur radio callsign to determine country from */
callsign: string;
/** Size of the flag in em units (default: 1.5) */
size?: number;
/** Custom CSS class name */
className?: string;
/** Whether to show country code text alongside flag */
showCountryCode?: boolean;
/** Custom style object */
style?: React.CSSProperties;
}
/**
* CountryFlag component displays the country flag based on an amateur radio callsign
* Uses react-country-flag for rendering the flag emoji/SVG
*/
export const CountryFlag: React.FC<CountryFlagProps> = ({
callsign,
size = 1.5,
className = '',
showCountryCode = false,
style = {},
}) => {
const countryCode = getCountryCodeFromCallsign(callsign);
if (!countryCode) {
// Return a placeholder if country cannot be determined
return (
<span
className={`country-flag country-flag--unknown ${className}`}
style={{ fontSize: `${size}em`, ...style }}
title="Unknown country"
>
🏴
</span>
);
}
return (
<span
className={`country-flag ${className}`}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25em', ...style }}
title={countryCode}
>
<ReactCountryFlag
countryCode={countryCode}
svg
style={{
fontSize: `${size}em`,
lineHeight: '1em',
}}
aria-label={`${countryCode} flag`}
/>
{showCountryCode && <span style={{ fontSize: '0.75em' }}>{countryCode}</span>}
</span>
);
};
export default CountryFlag;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Modal, Table } from 'react-bootstrap';
import type { KeyboardShortcut } from '../hooks/useKeyboardListNavigation';
type KeyboardShortcutsModalProps = {
show: boolean;
onHide: () => void;
shortcuts: KeyboardShortcut[];
title?: string;
};
const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
show,
onHide,
shortcuts,
title = 'Keyboard Controls',
}) => {
return (
<Modal show={show} onHide={onHide} centered>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Table striped bordered hover size="sm" className="mb-0">
<thead>
<tr>
<th style={{ width: '35%' }}>Key</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{shortcuts.map((shortcut) => (
<tr key={`${shortcut.keys}-${shortcut.description}`}>
<td>{shortcut.keys}</td>
<td>{shortcut.description}</td>
</tr>
))}
</tbody>
</Table>
</Modal.Body>
</Modal>
);
};
export default KeyboardShortcutsModal;

View File

@@ -60,6 +60,27 @@
font-weight: 600;
text-shadow: 0 0 14px rgba(153, 193, 255, 0.5);
}
.keyboard-nav-group {
.btn {
border-color: rgba(173, 205, 255, 0.45);
color: var(--app-text);
}
.btn:hover,
.btn:focus {
color: #ffffff;
border-color: rgba(225, 237, 255, 0.8);
}
.btn.active {
background: rgba(90, 146, 255, 0.5);
border-color: rgba(173, 205, 255, 0.75);
color: #ffffff;
font-weight: 600;
text-shadow: 0 0 14px rgba(153, 193, 255, 0.5);
}
}
}
.main-content {

View File

@@ -1,6 +1,10 @@
import React from 'react';
import { Container, Navbar, Nav } from 'react-bootstrap';
import { Container, Navbar, Nav, ButtonGroup, Button } from 'react-bootstrap';
import { Link, NavLink, Outlet, useLocation } from 'react-router';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import { defaultKeyboardShortcuts } from '../hooks/useKeyboardListNavigation';
import './Layout.scss';
import type { LayoutProps, NavLinkItem } from '../types/layout.types';
@@ -13,6 +17,7 @@ const Layout: React.FC<LayoutProps> = ({
gutterSize = 16
}) => {
const location = useLocation();
const { isKeyboardNavigationActive, showGlobalShortcuts, setShowGlobalShortcuts } = useKeyboardNavigationActivity();
const isActive = (link: NavLinkItem): boolean => {
return location.pathname.startsWith(link.to);
@@ -33,7 +38,23 @@ const Layout: React.FC<LayoutProps> = ({
<Navbar.Toggle aria-controls="layout-navbar-nav" />
<Navbar.Collapse id="layout-navbar-nav" className="justify-content-end">
<Nav className="ms-auto">
<div className="ms-auto d-flex align-items-center gap-3">
{isKeyboardNavigationActive && (
<div className="d-none d-lg-block">
<ButtonGroup className="keyboard-nav-group">
<Button
variant="outline-light"
active
onClick={() => setShowGlobalShortcuts(true)}
aria-label="Show keyboard shortcuts"
title="Show keyboard shortcuts (F1 or ?)"
>
<KeyboardIcon />
</Button>
</ButtonGroup>
</div>
)}
<Nav className="ms-2">
{navLinks.map((link, index) => (
<Nav.Link
key={index}
@@ -45,7 +66,8 @@ const Layout: React.FC<LayoutProps> = ({
{link.label}
</Nav.Link>
))}
</Nav>
</Nav>
</div>
</Navbar.Collapse>
</Container>
</Navbar>
@@ -54,6 +76,12 @@ const Layout: React.FC<LayoutProps> = ({
{children || <Outlet />}
</Container>
<KeyboardShortcutsModal
show={showGlobalShortcuts}
onHide={() => setShowGlobalShortcuts(false)}
shortcuts={defaultKeyboardShortcuts}
/>
<footer className="layout-footer">
<p>&copy; {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
</footer>

View File

@@ -5,13 +5,14 @@ import type { VerticalSplitProps } from '../types/layout.types';
const VerticalSplit: React.FC<VerticalSplitProps> = ({
left,
right,
ratio = '50/50',
ratio = '1:1',
className = ''
}) => {
const ratioClass =
ratio === '75/25' ? 'vertical-split-75-25' :
ratio === '3:1' ? 'vertical-split-3-1' :
ratio === '2:1' ? 'vertical-split-2-1' :
ratio === '25/70' ? 'vertical-split-25-70' :
'vertical-split-50-50';
'vertical-split-1-1';
return (
<Container fluid className="p-0 h-100 w-100">

View File

@@ -0,0 +1,36 @@
.aprs-symbol {
display: inline-block;
background-repeat: no-repeat;
flex-shrink: 0;
// Each sprite sheet is 16 symbols wide
// Background size needs to be 16 * symbol-size for proper sprite positioning
&.aprs-symbol-24 {
background-size: 384px auto; // 16 * 24
}
&.aprs-symbol-32 {
background-size: 512px auto; // 16 * 32
}
&.aprs-symbol-48 {
background-size: 768px auto; // 16 * 48
}
&.aprs-symbol-56 {
background-size: 896px auto; // 16 * 56
}
&.aprs-symbol-64 {
background-size: 1024px auto; // 16 * 64
}
&.aprs-symbol-128 {
background-size: 2048px auto; // 16 * 128
}
&.aprs-symbol-256 {
background-size: 4096px auto; // 16 * 256
}
}

View File

@@ -0,0 +1,148 @@
import React from 'react';
import './APRSSymbol.scss';
export interface APRSSymbolProps {
/**
* APRS symbol as 2-character string: table + code
* Examples: "/!" (police station), "\!" (emergency), "/-" (house)
*
* First character is table identifier:
* - '/' for primary table
* - '\' for secondary table
* - alphanumeric (0-9, A-Z) for overlay
*
* Second character is the symbol code
*/
symbol: string;
/**
* Symbol size in pixels (default: 24)
* Available sizes: 24, 32, 48, 56, 64, 128, 256
*/
size?: 24 | 32 | 48 | 56 | 64 | 128 | 256;
/**
* Alternative text for accessibility
*/
alt?: string;
/**
* Additional CSS class name
*/
className?: string;
}
/**
* Get the table ID for the sprite filename
* - Primary table (/): 0
* - Secondary table (\): 1
* - Overlay characters: 2
*/
const getTableId = (table: string): number => {
if (table === '/') return 0;
if (table === '\\') return 1;
return 2; // Overlay
};
/**
* Calculate sprite position based on ASCII character code
* Symbols are arranged in a 16-column grid, ordered by ASCII value
*/
const getSymbolPosition = (table: string, code: string): { row: number; col: number } | null => {
if (!code || code.length !== 1) return null;
const charCode = code.charCodeAt(0);
// Primary and secondary tables use characters from ! (33) to } (125)
if (table === '/' || table === '\\') {
if (charCode < 33 || charCode > 125) return null;
const index = charCode - 33;
return {
row: Math.floor(index / 16),
col: index % 16
};
}
// Overlay characters: 0-9 (48-57), A-Z (65-90)
if (charCode >= 48 && charCode <= 57) {
// 0-9
const index = charCode - 48;
return {
row: Math.floor(index / 16),
col: index % 16
};
} else if (charCode >= 65 && charCode <= 90) {
// A-Z
const index = 10 + (charCode - 65);
return {
row: Math.floor(index / 16),
col: index % 16
};
}
return null;
};
/**
* React component for rendering APRS symbols from sprite sheets
*/
export const APRSSymbol: React.FC<APRSSymbolProps> = ({
symbol,
size = 24,
alt,
className = ''
}) => {
// Parse the symbol string (format: table + code)
if (!symbol || symbol.length < 2) {
// Return empty div if symbol is invalid
return <div className={`aprs-symbol aprs-symbol-${size} ${className}`} title={alt} />;
}
const table = symbol.charAt(0);
const code = symbol.charAt(1);
const tableId = getTableId(table);
const position = getSymbolPosition(table, code);
if (!position) {
// Return empty div if symbol is invalid
return <div className={`aprs-symbol aprs-symbol-${size} ${className}`} title={alt} />;
}
const { row, col } = position;
// Build image paths for different resolutions
const imagePath1x = `/image/protocol/aprs/aprs-symbols-${size}-${tableId}.png`;
const imagePath2x = `/image/protocol/aprs/aprs-symbols-${size}-${tableId}@2x.png`;
const imagePath3x = `/image/protocol/aprs/aprs-symbols-${size}-${tableId}@3x.png`;
// Calculate background position
const bgX = -(col * size);
const bgY = -(row * size);
// Use image-set for retina display support
const backgroundImage = [
`-webkit-image-set(url('${imagePath1x}') 1x, url('${imagePath2x}') 2x, url('${imagePath3x}') 3x)`,
`image-set(url('${imagePath1x}') 1x, url('${imagePath2x}') 2x, url('${imagePath3x}') 3x)`,
`url('${imagePath1x}')` // Fallback
].join(', ');
const style: React.CSSProperties = {
backgroundImage,
backgroundPosition: `${bgX}px ${bgY}px`,
width: `${size}px`,
height: `${size}px`
};
return (
<div
className={`aprs-symbol aprs-symbol-${size} ${className}`}
style={style}
title={alt || `APRS symbol: ${symbol}`}
role="img"
aria-label={alt || `APRS symbol: ${symbol}`}
/>
);
};
export default APRSSymbol;

View File

@@ -0,0 +1 @@
export { APRSSymbol, type APRSSymbolProps } from './APRSSymbol';

View File

@@ -0,0 +1,315 @@
import React, { useMemo, useState, useEffect } from 'react';
import { CircleMarker, Marker, Popup, useMap } from 'react-leaflet';
import { divIcon, Point, type Icon, type DivIcon } from 'leaflet';
export interface ClusterableItem {
latitude?: number;
longitude?: number;
}
export interface Cluster<T extends ClusterableItem> {
position: [number, number];
items: T[];
count: number;
isOffset?: boolean;
}
interface ClusteredMarkersProps<T extends ClusterableItem> {
items: T[];
getItemKey: (item: T) => string;
onItemClick?: (item: T) => void;
renderMarker?: (item: T) => React.ReactNode;
renderClusterMarker?: (cluster: Cluster<T>) => React.ReactNode;
getIcon?: (item: T) => Icon<any> | DivIcon | null;
clusterRadius?: number; // pixels
maxZoom?: number;
showPopups?: boolean;
renderPopupContent?: (item: T) => React.ReactNode;
renderClusterPopupContent?: (cluster: Cluster<T>) => React.ReactNode;
}
/**
* Convert meter offset to lat/lng offset
*/
const metersToLatLng = (offsetX: number, offsetY: number, refLat: number): { lat: number; lng: number } => {
const earthRadius = 6378137; // meters
const latRad = (refLat * Math.PI) / 180;
const latOffset = (offsetY * 180) / (earthRadius * Math.PI);
const lngOffset = (offsetX * 180) / (earthRadius * Math.PI * Math.cos(latRad));
return { lat: latOffset, lng: lngOffset };
};
/**
* Create clusters from items based on pixel distance at current zoom level
* At max zoom, offset overlapping markers instead of clustering
*/
const createClusters = <T extends ClusterableItem>(
items: T[],
zoom: number,
maxZoom: number = 18,
clusterRadius: number = 40
): Cluster<T>[] => {
// Filter items with valid positions
const validItems = items.filter(item =>
item.latitude !== undefined &&
item.longitude !== undefined
);
if (validItems.length === 0) return [];
const isMaxZoom = zoom >= maxZoom;
const clusters: Cluster<T>[] = [];
const processed = new Set<number>();
const getPixelPosition = (lat: number, lng: number): Point => {
const scale = Math.pow(2, zoom);
const x = ((lng + 180) / 360) * 256 * scale;
const y = ((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * 256 * scale;
return new Point(x, y);
};
validItems.forEach((item, index) => {
if (processed.has(index)) return;
const cluster: Cluster<T> = {
position: [item.latitude!, item.longitude!],
items: [item],
count: 1,
isOffset: false
};
const itemPos = getPixelPosition(item.latitude!, item.longitude!);
// Find nearby items
for (let i = index + 1; i < validItems.length; i++) {
if (processed.has(i)) continue;
const otherItem = validItems[i];
const otherPos = getPixelPosition(otherItem.latitude!, otherItem.longitude!);
const distance = Math.sqrt(
Math.pow(itemPos.x - otherPos.x, 2) + Math.pow(itemPos.y - otherPos.y, 2)
);
if (distance <= clusterRadius) {
cluster.items.push(otherItem);
cluster.count++;
processed.add(i);
}
}
// At max zoom, create offset positions instead of clustering
if (isMaxZoom && cluster.count > 1) {
const centerLat = cluster.items.reduce((sum, p) => sum + p.latitude!, 0) / cluster.count;
const centerLng = cluster.items.reduce((sum, p) => sum + p.longitude!, 0) / cluster.count;
// Create individual markers with circular offset pattern
cluster.items.forEach((item, idx) => {
const angle = (idx / cluster.count) * 2 * Math.PI;
const offsetDistance = 20; // meters
const offsetX = Math.cos(angle) * offsetDistance;
const offsetY = Math.sin(angle) * offsetDistance;
const offset = metersToLatLng(offsetX, offsetY, centerLat);
clusters.push({
position: [centerLat + offset.lat, centerLng + offset.lng],
items: [item],
count: 1,
isOffset: true
});
});
processed.add(index);
return;
}
// Calculate cluster center (average position) for non-max zoom
if (cluster.count > 1) {
const avgLat = cluster.items.reduce((sum, p) => sum + p.latitude!, 0) / cluster.count;
const avgLng = cluster.items.reduce((sum, p) => sum + p.longitude!, 0) / cluster.count;
cluster.position = [avgLat, avgLng];
}
clusters.push(cluster);
processed.add(index);
});
return clusters;
};
/**
* Default cluster marker icon
*/
const createDefaultClusterIcon = (count: number) => {
const size = count < 10 ? 32 : count < 100 ? 40 : 48;
const iconHtml = `
<div class="cluster-marker" style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: rgba(0, 102, 204, 0.8);
border: 3px solid rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: ${count < 100 ? '14px' : '12px'};
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
">${count}</div>
`;
return divIcon({
html: iconHtml,
className: 'cluster-marker-icon',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
};
/**
* Reusable clustered markers component for Leaflet maps
*/
export function ClusteredMarkers<T extends ClusterableItem>({
items,
getItemKey,
onItemClick,
renderMarker,
renderClusterMarker,
getIcon,
clusterRadius = 40,
maxZoom: propMaxZoom,
showPopups = true,
renderPopupContent,
renderClusterPopupContent,
}: ClusteredMarkersProps<T>) {
const map = useMap();
const [zoom, setZoom] = useState(map.getZoom());
const maxZoom = propMaxZoom ?? map.getMaxZoom() ?? 18;
useEffect(() => {
const handleZoomEnd = () => {
setZoom(map.getZoom());
};
map.on('zoomend', handleZoomEnd);
return () => {
map.off('zoomend', handleZoomEnd);
};
}, [map]);
const clusters = useMemo(() => {
return createClusters(items, zoom, maxZoom, clusterRadius);
}, [items, zoom, maxZoom, clusterRadius]);
const handleClusterClick = (cluster: Cluster<T>) => {
if (cluster.count === 1) {
onItemClick?.(cluster.items[0]);
} else if (!cluster.isOffset) {
// Only zoom for non-offset clusters (not at max zoom)
const minLat = Math.min(...cluster.items.map(p => p.latitude!));
const maxLat = Math.max(...cluster.items.map(p => p.latitude!));
const minLng = Math.min(...cluster.items.map(p => p.longitude!));
const maxLng = Math.max(...cluster.items.map(p => p.longitude!));
const bounds: [[number, number], [number, number]] = [
[minLat, minLng],
[maxLat, maxLng]
];
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: Math.min(zoom + 3, maxZoom)
});
}
};
return (
<>
{clusters.map((cluster, idx) => {
if (cluster.count > 1 && !cluster.isOffset) {
// Render cluster marker
if (renderClusterMarker) {
return <React.Fragment key={`cluster-${idx}`}>{renderClusterMarker(cluster)}</React.Fragment>;
}
return (
<Marker
key={`cluster-${idx}`}
position={cluster.position}
icon={createDefaultClusterIcon(cluster.count)}
eventHandlers={{
click: () => handleClusterClick(cluster),
}}
>
{showPopups && (
<Popup>
{renderClusterPopupContent ? (
renderClusterPopupContent(cluster)
) : (
<div>
<strong>{cluster.count} items</strong>
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Click to zoom in
</div>
</div>
)}
</Popup>
)}
</Marker>
);
} else {
// Render individual marker
const item = cluster.items[0];
if (renderMarker) {
return <React.Fragment key={getItemKey(item)}>{renderMarker(item)}</React.Fragment>;
}
const icon = getIcon?.(item);
if (icon) {
return (
<Marker
key={getItemKey(item)}
position={[item.latitude!, item.longitude!]}
icon={icon}
eventHandlers={{
click: () => onItemClick?.(item),
}}
>
{showPopups && renderPopupContent && (
<Popup>{renderPopupContent(item)}</Popup>
)}
</Marker>
);
}
// Default fallback: blue circle
return (
<CircleMarker
key={getItemKey(item)}
center={[item.latitude!, item.longitude!]}
radius={6}
pathOptions={{
color: '#0066cc',
fillColor: '#0066cc',
fillOpacity: 0.6,
weight: 2,
}}
eventHandlers={{
click: () => onItemClick?.(item),
}}
>
{showPopups && renderPopupContent && (
<Popup>{renderPopupContent(item)}</Popup>
)}
</CircleMarker>
);
}
})}
</>
);
}
export default ClusteredMarkers;

View File

@@ -0,0 +1 @@
export { ClusteredMarkers, type ClusterableItem, type Cluster } from './ClusteredMarkers';

View File

@@ -0,0 +1,48 @@
import React from 'react';
type KeyboardNavigationContextValue = {
isKeyboardNavigationActive: boolean;
activateKeyboardNavigation: () => void;
deactivateKeyboardNavigation: () => void;
showGlobalShortcuts: boolean;
setShowGlobalShortcuts: (show: boolean) => void;
};
const KeyboardNavigationContext = React.createContext<KeyboardNavigationContextValue | undefined>(undefined);
export const KeyboardNavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [activeCount, setActiveCount] = React.useState(0);
const [showGlobalShortcuts, setShowGlobalShortcuts] = React.useState(false);
const activateKeyboardNavigation = React.useCallback(() => {
setActiveCount((count) => count + 1);
}, []);
const deactivateKeyboardNavigation = React.useCallback(() => {
setActiveCount((count) => Math.max(0, count - 1));
}, []);
const value = React.useMemo(() => ({
isKeyboardNavigationActive: activeCount > 0,
activateKeyboardNavigation,
deactivateKeyboardNavigation,
showGlobalShortcuts,
setShowGlobalShortcuts,
}), [activeCount, activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts]);
return (
<KeyboardNavigationContext.Provider value={value}>
{children}
</KeyboardNavigationContext.Provider>
);
};
export const useKeyboardNavigationActivity = (): KeyboardNavigationContextValue => {
const context = React.useContext(KeyboardNavigationContext);
if (!context) {
throw new Error('useKeyboardNavigationActivity must be used within a KeyboardNavigationProvider');
}
return context;
};

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
export type KeyboardShortcut = {
keys: string;
description: string;
};
const DEFAULT_SHORTCUTS: KeyboardShortcut[] = [
{ keys: '↑ / ↓', description: 'Select previous or next item' },
{ keys: 'PageUp / PageDown', description: 'Move selection by half a page' },
{ keys: 'Home / End', description: 'Jump to first or last item' },
{ keys: 'Esc', description: 'Clear current selection' },
{ keys: 'F1 or ?', description: 'Show keyboard controls' },
];
type UseKeyboardListNavigationOptions = {
itemCount: number;
selectedIndex: number | null;
onSelectIndex: (index: number | null) => void;
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowSelector?: string;
enabled?: boolean;
shortcuts?: KeyboardShortcut[];
};
const isTypingTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
const tag = target.tagName.toLowerCase();
return target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select';
};
export const useKeyboardListNavigation = ({
itemCount,
selectedIndex,
onSelectIndex,
scrollContainerRef,
rowSelector = '[data-nav-item="true"]',
enabled = true,
shortcuts = DEFAULT_SHORTCUTS,
}: UseKeyboardListNavigationOptions) => {
const [showShortcuts, setShowShortcuts] = React.useState(false);
const { activateKeyboardNavigation, deactivateKeyboardNavigation } = useKeyboardNavigationActivity();
React.useEffect(() => {
if (!enabled) {
return;
}
activateKeyboardNavigation();
return () => {
deactivateKeyboardNavigation();
};
}, [activateKeyboardNavigation, deactivateKeyboardNavigation, enabled]);
const scrollToIndex = React.useCallback((index: number) => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const rows = container.querySelectorAll<HTMLElement>(rowSelector);
const row = rows[index];
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [rowSelector, scrollContainerRef]);
const getPageStep = React.useCallback((): number => {
const container = scrollContainerRef.current;
if (!container) {
return 10;
}
const firstRow = container.querySelector<HTMLElement>(rowSelector);
if (!firstRow || firstRow.offsetHeight <= 0) {
return 10;
}
return Math.max(1, Math.floor((container.clientHeight / 2) / firstRow.offsetHeight));
}, [rowSelector, scrollContainerRef]);
React.useEffect(() => {
if (!enabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const isQuestionMark = event.key === '?' || (event.key === '/' && event.shiftKey);
if (event.key === 'F1' || isQuestionMark) {
event.preventDefault();
setShowShortcuts(true);
return;
}
if (isTypingTarget(event.target) || itemCount === 0) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
onSelectIndex(null);
return;
}
const selectAndScroll = (nextIndex: number) => {
const bounded = Math.max(0, Math.min(nextIndex, itemCount - 1));
onSelectIndex(bounded);
scrollToIndex(bounded);
};
if (event.key === 'ArrowDown') {
event.preventDefault();
selectAndScroll(selectedIndex === null ? 0 : selectedIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectAndScroll(selectedIndex === null ? 0 : selectedIndex - 1);
return;
}
if (event.key === 'PageDown') {
event.preventDefault();
const step = getPageStep();
if (selectedIndex === null) {
selectAndScroll(step - 1);
} else {
selectAndScroll(selectedIndex + step);
}
return;
}
if (event.key === 'PageUp') {
event.preventDefault();
const step = getPageStep();
if (selectedIndex === null) {
selectAndScroll(0);
} else {
selectAndScroll(selectedIndex - step);
}
return;
}
if (event.key === 'Home') {
event.preventDefault();
selectAndScroll(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
selectAndScroll(itemCount - 1);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [enabled, getPageStep, itemCount, onSelectIndex, scrollToIndex, selectedIndex]);
return {
showShortcuts,
setShowShortcuts,
shortcuts,
};
};
export const defaultKeyboardShortcuts = DEFAULT_SHORTCUTS;

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { getCountryCodeFromCallsign } from './callsignMapper';
describe('getCountryCodeFromCallsign', () => {
it('should return US for K prefix', () => {
expect(getCountryCodeFromCallsign('K6ABC')).toBe('US');
expect(getCountryCodeFromCallsign('KA1ABC')).toBe('US');
expect(getCountryCodeFromCallsign('W1AW')).toBe('US');
expect(getCountryCodeFromCallsign('N0CALL')).toBe('US');
});
it('should handle callsigns with SSID', () => {
expect(getCountryCodeFromCallsign('K6ABC-5')).toBe('US');
expect(getCountryCodeFromCallsign('PA0MZ-9')).toBe('NL');
});
it('should return NL for PA prefix', () => {
expect(getCountryCodeFromCallsign('PA0MZ')).toBe('NL');
expect(getCountryCodeFromCallsign('PD0ABC')).toBe('NL');
});
it('should return GB for G and M prefixes', () => {
expect(getCountryCodeFromCallsign('G4ABC')).toBe('GB');
expect(getCountryCodeFromCallsign('M0XYZ')).toBe('GB');
});
it('should return DE for D prefix', () => {
expect(getCountryCodeFromCallsign('DL1ABC')).toBe('DE');
expect(getCountryCodeFromCallsign('DJ5XY')).toBe('DE');
});
it('should return FR for F prefix', () => {
expect(getCountryCodeFromCallsign('F6ABC')).toBe('FR');
});
it('should return JP for JA prefix', () => {
expect(getCountryCodeFromCallsign('JA1ABC')).toBe('JP');
});
it('should return CA for VE prefix', () => {
expect(getCountryCodeFromCallsign('VE3ABC')).toBe('CA');
});
it('should handle lowercase callsigns', () => {
expect(getCountryCodeFromCallsign('k6abc')).toBe('US');
expect(getCountryCodeFromCallsign('pa0mz')).toBe('NL');
});
it('should return null for invalid or unknown callsigns', () => {
expect(getCountryCodeFromCallsign('')).toBe(null);
expect(getCountryCodeFromCallsign(undefined)).toBe(null);
expect(getCountryCodeFromCallsign('???')).toBe(null);
});
it('should handle numeric prefixes', () => {
expect(getCountryCodeFromCallsign('9A1ABC')).toBe('HR'); // Croatia
expect(getCountryCodeFromCallsign('4X1AB')).toBe('IL'); // Israel
});
it('should match longest prefix first', () => {
// Make sure it tries 3-char, then 2-char, then 1-char prefixes
expect(getCountryCodeFromCallsign('PA0MZ')).toBe('NL'); // Should match PA, not P
});
});

View File

@@ -0,0 +1,255 @@
/**
* Maps amateur radio callsign prefixes to ISO 3166-1 alpha-2 country codes
* Uses pattern-based compression for both 2-letter and 3-letter ranges
* Supports 22,440+ prefixes in ~6KB instead of 300KB
*/
// Single-letter prefixes that cover most amateur radio use
const SINGLE_LETTER_PREFIXES: Record<string, string> = {
'K': 'US', 'W': 'US', 'N': 'US',
'G': 'GB', 'M': 'GB',
'D': 'DE', 'F': 'FR', 'I': 'IT', 'B': 'CN', 'R': 'RU', 'U': 'RU',
};
/**
* Two-letter prefix patterns from ITU allocations
* Format: 'A[A-L]': 'US' means AA-AL map to US
* Also includes individual patterns like '2E': 'GB' for specific allocations
*/
const TWO_LETTER_PATTERNS: Record<string, string> = {
// United States (multiple ranges)
'A[A-L]': 'US',
'K[A-Z]': 'US',
'N[A-Z]': 'US',
'W[A-Z]': 'US',
// Canada
'V[A-GOY]': 'CA',
// United Kingdom (special 2-letter allocations)
'2[EIJMUW]': 'GB',
// Germany
'D[A-O]': 'DE',
// France
'T[M-Q]': 'FR',
// Netherlands
'P[A-I]': 'NL',
// Spain
'E[A-H]': 'ES', 'AM': 'ES',
// Japan
'J[A-S]': 'JP',
// Australia
'V[KLMNZ]': 'AU', 'AX': 'AU',
// Belgium
'O[N-T]': 'BE',
// Switzerland
'H[BE]': 'CH',
// Austria
'OE': 'AT',
// Poland
'S[N-R]': 'PL',
// Sweden
'S[A-M]': 'SE',
// Norway
'L[A-N]': 'NO',
// Denmark
'O[UZ]': 'DK', '5[P-Q]': 'DK',
// Finland
'O[F-I]': 'FI',
// Czech Republic
'O[KL]': 'CZ',
// Portugal
'C[R-U]': 'PT',
// Greece
'S[V-Z]': 'GR',
// Romania
'Y[O-R]': 'RO',
// Hungary
'H[AG]': 'HU',
// Bulgaria
'LZ': 'BG',
// Slovakia
'OM': 'SK',
// Ireland
'E[IJ]': 'IE',
// New Zealand
'Z[K-M]': 'NZ',
// Brazil
'P[P-Y]': 'BR', 'Z[V-Z]': 'BR',
// Argentina
'A[YZ]': 'AR',
// Chile
'C[A-E]': 'CL',
// India
'V[U-W]': 'IN', 'A[T-W]': 'IN',
// Mexico
'X[A-I]': 'MX',
// Thailand
'HS': 'TH', 'E2': 'TH',
// Turkey
'T[A-C]': 'TR',
// Ukraine
'E[M-O]': 'UA', 'U[R-Z]': 'UA',
// Croatia
'9A': 'HR',
// Slovenia
'S5': 'SI',
// Lithuania
'LY': 'LT',
// Latvia
'YL': 'LV',
// Iceland
'TF': 'IS',
// Luxembourg
'LX': 'LU',
// South Korea
'H[L-M]': 'KR', '6[K-L]': 'KR', 'D[7-9]': 'KR', 'D[ST]': 'KR',
// Indonesia
'Y[B-H]': 'ID',
// Singapore
'9V': 'SG',
// Malaysia
'9[MW]': 'MY',
// Israel
'4[XZ]': 'IL',
// Egypt
'SU': 'EG',
// Philippines
'D[U-Z]': 'PH', '4[D-I]': 'PH',
// South Africa
'Z[R-U]': 'ZA',
};
/**
* Prefix patterns for 3-letter callsigns from ITU allocations
* Format: '2A[A-Z]': 'GB' means prefixes 2AA-2AZ map to GB
* Syntax: [A-Z] matches single letter, [A-X] matches A through X, etc.
*/
const THREE_LETTER_PATTERNS: Record<string, string> = {
'2[A-Z][A-Z]': 'GB',
'3A': 'MC', '3B': 'MU', '3C': 'GQ', '3D': 'SZ', '3[E-F][A-Z]': 'PT', '3G': 'SH', '3[H-I][A-Z]': 'CY', '3J': 'ER',
'3K': 'PS', '3[L-O][A-Z]': 'FJ', '3[P-Z][A-Z]': 'PA',
'4[A-X][A-Z]': 'MC', '4Y': 'BY', '4Z': 'SJ',
'5A': 'LI', '5B': 'CZ', '5C': 'DK', '5[D-E][A-Z]': 'SE', '5F': 'HU', '5[G-I][A-Z]': 'NO', '5J': 'SE',
'5[K-M][A-Z]': 'SE', '5[N-O][A-Z]': 'NO', '5[R-T][A-Z]': 'SE', '5[U-V][A-Z]': 'DK',
'5[W-X][A-Z]': 'SE', '5[Y-Z][A-Z]': 'KE',
'6[A-B][A-Z]': 'EG', '6[C-D][A-Z]': 'NO', '6[E-J][A-Z]': 'NL', '6[K-M][A-Z]': 'KR', '6[N-Z][A-Z]': 'KZ',
'7[A-L][A-Z]': 'RU', '7M': 'AM', '7[N-X][A-Z]': 'RU', '7Y': 'EG', '7Z': 'RU',
'8[A-Z][A-Z]': 'ID',
'9[A-H][A-Z]': 'ET', '9[I-J][A-Z]': 'ZM',
};
/**
* Converts a pattern like '2A[A-Z]', 'O[N-T]', or '2[EIJMUW]' to a regex that checks if a prefix matches
*/
function patternToRegex(pattern: string): RegExp {
let regex = pattern;
// Convert pattern syntax [A-Z], [A-X], etc. to regex
regex = regex.replace(/\[([A-Z0-9])-([A-Z0-9])\]/g, '[$1-$2]');
// Character classes are already valid regex, e.g., [EIJMUW]
// For 2-letter patterns, match exactly 2 letters
// For 3-letter patterns, match 2 letters + optional 3rd character
return new RegExp(`^${regex}[A-Z0-9]?$`);
}
/**
* Checks if a prefix matches any pattern (2-letter or 3-letter)
*/
function matchesPattern(prefix: string, patterns: Record<string, string>): string | null {
for (const [pattern, code] of Object.entries(patterns)) {
if (pattern.includes('[')) {
// Pattern with character range, e.g., 'O[N-T]' or '2A[A-Z]'
const regex = patternToRegex(pattern);
if (regex.test(prefix)) return code;
} else if (pattern.length === 2) {
// Short pattern like '3A' or '4Y' - match prefix starting with pattern
if (prefix.startsWith(pattern)) return code;
} else if (pattern.length === 3 && !pattern.includes('[')) {
// 3-char exact match like '2E'
if (prefix.startsWith(pattern)) return code;
}
}
return null;
}
/**
* Extracts the country code from an amateur radio callsign
* @param callsign The amateur radio callsign (e.g., "K6ABC", "PA0MZ", "G4ABC")
* @returns ISO 3166-1 alpha-2 country code, or null if not found
*/
export function getCountryCodeFromCallsign(callsign: string | undefined): string | null {
if (!callsign) return null;
// Clean the callsign - remove SSID and convert to uppercase
const cleanCallsign = callsign.split('-')[0].toUpperCase().trim();
// Try 3-character prefix first (for ITU allocations)
if (cleanCallsign.length >= 3) {
const threeCharPrefix = cleanCallsign.substring(0, 3);
const patternMatch = matchesPattern(threeCharPrefix, THREE_LETTER_PATTERNS);
if (patternMatch) return patternMatch;
}
// Try 2-character prefix (most common)
if (cleanCallsign.length >= 2) {
const twoCharPrefix = cleanCallsign.substring(0, 2);
const patternMatch = matchesPattern(twoCharPrefix, TWO_LETTER_PATTERNS);
if (patternMatch) return patternMatch;
}
// Try 1-character prefix
if (cleanCallsign.length >= 1) {
const oneCharPrefix = cleanCallsign.substring(0, 1);
const singleMatch = SINGLE_LETTER_PREFIXES[oneCharPrefix];
if (singleMatch) return singleMatch;
}
return null;
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'leaflet/dist/leaflet.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(

View File

@@ -104,4 +104,35 @@
.leaflet-container {
background: rgba(13, 36, 82, 0.95);
}
.leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) saturate(0.8) brightness(0.95);
}
}
// APRS symbol markers on the map
.aprs-marker-icon {
border: none;
background: transparent;
.aprs-map-marker {
// Use 32px source scaled to 16px for crisp rendering
width: 16px;
height: 16px;
background-size: 256px auto; // 16 * 16px = 256px
display: block;
}
}
// Cluster markers
.cluster-marker-icon {
border: none;
background: transparent;
cursor: pointer;
&:hover .cluster-marker {
background: rgba(0, 102, 204, 0.95) !important;
transform: scale(1.1);
transition: all 0.2s ease;
}
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Button, ButtonGroup } from 'react-bootstrap';
import { ButtonGroup } from 'react-bootstrap';
import Layout from '../components/Layout';
import { NavLink, Outlet, useLocation } from 'react-router';
import { APRSDataProvider } from './aprs/APRSData';
@@ -15,13 +15,12 @@ const APRS: React.FC<Props> = ({ navLinks = [] }) => {
const viewButtons = (
<ButtonGroup className="aprs-view-switch" size="sm" aria-label="APRS view switch">
<Button
as={NavLink}
<NavLink
to="/aprs/packets"
variant={location.pathname.startsWith('/aprs/packets') ? 'primary' : 'outline-light'}
className={`btn d-none d-lg-inline-block ${location.pathname.startsWith('/aprs/packets') ? 'btn-primary' : 'btn-outline-light'}`}
>
Packets
</Button>
</NavLink>
</ButtonGroup>
);

View File

@@ -31,17 +31,7 @@
}
}
.meshcore-btn-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
.meshcore-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
.meshcore-node-type-cell {
display: flex;
@@ -49,42 +39,23 @@
justify-content: center;
padding: 0.25rem !important;
border: none !important;
}
.meshcore-node-type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
.meshcore-node-type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
color: black;
}
svg {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
background-color: transparent;
}
}
.meshcore-table-card,
.meshcore-detail-card {
background: rgba(8, 24, 56, 0.5);
border: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
}
.meshcore-table-header {
background: rgba(27, 56, 108, 0.45);
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
font-weight: 600;
}
.meshcore-table-body {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.meshcore-filters {
background: rgba(13, 36, 82, 0.6);
@@ -219,70 +190,50 @@
}
}
.meshcore-table-scroll {
height: 100%;
max-height: 100%;
overflow-y: auto;
}
.meshcore-table {
color: var(--app-text);
thead th {
position: sticky;
top: 0;
z-index: 2;
background: rgba(13, 36, 82, 0.95);
border-color: rgba(173, 205, 255, 0.18);
color: var(--app-text);
.data-table {
&.table-hover > tbody > tr:hover .meshcore-hash-button,
&.table-hover > tbody > tr:hover .meshcore-expand-button {
color: #f2f7ff;
}
td {
border-color: rgba(173, 205, 255, 0.12);
vertical-align: middle;
tr.is-selected .meshcore-hash-button,
tr.is-selected .meshcore-hash-button:hover {
color: var(--app-accent-yellow);
}
tr.is-selected td {
background: rgba(102, 157, 255, 0.16);
td.meshcore-node-type-cell {
border-bottom: transparent !important;
}
tr.meshcore-packet-green td {
border-left: 3px solid #22c55e;
&:nth-child(4) svg {
color: #22c55e !important;
}
&:nth-child(5) {
color: #22c55e;
font-weight: 500;
}
tr.meshcore-packet-green td:nth-child(4) svg {
color: #22c55e !important;
}
tr.meshcore-packet-purple td {
border-left: 3px solid #a855f7;
&:nth-child(4) svg {
color: #a855f7 !important;
}
&:nth-child(5) {
color: #a855f7;
font-weight: 500;
}
tr.meshcore-packet-green td:nth-child(5) {
color: #22c55e;
font-weight: 500;
}
tr.meshcore-packet-amber td {
border-left: 3px solid #f59e0b;
tr.meshcore-packet-purple td:nth-child(4) svg {
color: #a855f7 !important;
}
&:nth-child(4) svg {
color: #f59e0b !important;
}
tr.meshcore-packet-purple td:nth-child(5) {
color: #a855f7;
font-weight: 500;
}
&:nth-child(5) {
color: #f59e0b;
font-weight: 500;
}
tr.meshcore-packet-amber td:nth-child(4) svg {
color: #f59e0b !important;
}
tr.meshcore-packet-amber td:nth-child(5) {
color: #f59e0b;
font-weight: 500;
}
}
@@ -348,6 +299,172 @@
padding-right: 0.25rem;
}
.meshcore-wire-subtitle {
color: var(--app-text-muted);
font-size: 0.85rem;
}
.meshcore-ws-layout {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.meshcore-ws-panel {
border: 1px solid rgba(173, 205, 255, 0.25);
background: rgba(8, 24, 56, 0.45);
border-radius: 0.375rem;
overflow: hidden;
}
.meshcore-ws-panel-title {
font-family: monospace;
font-size: 0.8rem;
letter-spacing: 0.02em;
color: var(--app-text-muted);
background: rgba(13, 36, 82, 0.45);
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
padding: 0.35rem 0.55rem;
}
.meshcore-ws-tree {
margin: 0;
padding: 0.55rem 0.7rem 0.7rem 1.15rem;
font-family: monospace;
font-size: 0.8rem;
color: var(--app-text);
ul {
margin: 0.2rem 0 0.2rem 0.95rem;
padding-left: 0.75rem;
}
li {
margin: 0.15rem 0;
}
code {
color: #b8d1ff;
}
}
.meshcore-ws-node {
color: #d7e7ff;
}
.meshcore-bit-art {
margin: 0.25rem 0 0;
padding: 0.35rem 0.5rem;
background: rgba(13, 36, 82, 0.35);
border: 1px solid rgba(173, 205, 255, 0.2);
border-radius: 0.25rem;
color: #b8d1ff;
font-family: monospace;
font-size: 0.76rem;
line-height: 1.35;
white-space: pre;
overflow-x: auto;
}
.meshcore-bitart-toggle {
appearance: none;
border: 1px solid rgba(173, 205, 255, 0.35);
background: rgba(13, 36, 82, 0.3);
color: var(--app-text);
border-radius: 0.25rem;
padding: 0.15rem 0.45rem;
font-size: 0.75rem;
line-height: 1.2;
&:hover,
&:focus {
border-color: rgba(173, 205, 255, 0.65);
background: rgba(90, 146, 255, 0.2);
color: #ffffff;
}
}
.meshcore-ws-legend {
display: flex;
gap: 0.8rem;
align-items: center;
padding: 0.5rem 0.6rem 0;
font-size: 0.75rem;
color: var(--app-text-muted);
span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
}
.meshcore-ws-chip {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 2px;
border: 1px solid rgba(173, 205, 255, 0.35);
}
.meshcore-ws-hexdump {
padding: 0.45rem 0.55rem 0.65rem;
font-family: monospace;
font-size: 0.78rem;
color: var(--app-text);
overflow-x: auto;
}
.meshcore-ws-hexdump-row {
display: grid;
grid-template-columns: 44px minmax(420px, 1fr) 150px;
gap: 0.5rem;
align-items: center;
line-height: 1.35;
white-space: nowrap;
}
.meshcore-ws-offset {
color: #82aeff;
}
.meshcore-ws-bytes,
.meshcore-ws-ascii {
display: inline-flex;
gap: 0.15rem;
}
.meshcore-ws-byte,
.meshcore-ws-char {
display: inline-block;
text-align: center;
border-radius: 2px;
}
.meshcore-ws-byte {
min-width: 1.35rem;
}
.meshcore-ws-char {
min-width: 0.65rem;
}
.meshcore-ws-byte-empty {
opacity: 0.35;
}
.meshcore-ws-zone-header {
background: rgba(90, 146, 255, 0.2);
}
.meshcore-ws-zone-path {
background: rgba(168, 85, 247, 0.2);
}
.meshcore-ws-zone-payload {
background: rgba(34, 197, 94, 0.2);
}
.meshcore-fact-row {
display: grid;
grid-template-columns: 120px 1fr;

View File

@@ -20,27 +20,27 @@ const MeshCore: React.FC<Props> = ({ navLinks = [] }) => {
<ButtonGroup className="meshcore-view-switch" size="sm" aria-label="MeshCore view switch">
<NavLink
to="/meshcore/packets"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/packets') ? 'btn-primary' : 'btn-outline-light'}`}
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/packets') ? 'btn-primary' : 'btn-outline-light'}`}
title="Packets"
>
<StorageIcon className="meshcore-icon" />
<span className="ms-1">Packets</span>
<StorageIcon className="icon" />
<span className="ms-1 d-none d-lg-inline">Packets</span>
</NavLink>
<NavLink
to="/meshcore/groupchat"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/groupchat') ? 'btn-primary' : 'btn-outline-light'}`}
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/groupchat') ? 'btn-primary' : 'btn-outline-light'}`}
title="Group Chat"
>
<ChatIcon className="meshcore-icon" />
<span className="ms-1">Chat</span>
<ChatIcon className="icon" />
<span className="ms-1 d-none d-lg-inline">Chat</span>
</NavLink>
<NavLink
to="/meshcore/map"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`}
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`}
title="Map"
>
<MapIcon className="meshcore-icon" />
<span className="ms-1">Map</span>
<MapIcon className="icon" />
<span className="ms-1 d-none d-lg-inline">Map</span>
</NavLink>
</ButtonGroup>
);

View File

@@ -15,11 +15,14 @@ export interface APRSPacketRecord {
speed?: number;
course?: number;
comment?: string;
symbol?: string;
radioName?: string;
hasAPILocation?: boolean;
}
interface APRSDataContextValue {
packets: APRSPacketRecord[];
streamReady: boolean;
}
const APRSDataContext = createContext<APRSDataContextValue | null>(null);
@@ -59,6 +62,7 @@ const extractAPRSDetails = (frame: Frame) => {
let speed: number | undefined;
let course: number | undefined;
let comment: string | undefined;
let symbol: string | undefined;
if (decoded && typeof decoded === 'object') {
if ('position' in decoded && decoded.position) {
@@ -66,6 +70,9 @@ const extractAPRSDetails = (frame: Frame) => {
longitude = decoded.position.longitude;
altitude = decoded.position.altitude;
comment = decoded.position.comment;
if (decoded.position.symbol) {
symbol = `${decoded.position.symbol.table}${decoded.position.symbol.code}`;
}
}
if ('altitude' in decoded && typeof decoded.altitude === 'number') {
@@ -92,6 +99,7 @@ const extractAPRSDetails = (frame: Frame) => {
speed,
course,
comment,
symbol,
};
};
@@ -106,13 +114,47 @@ const parseTimestamp = (input: unknown): Date => {
return new Date();
};
const normalizeCoordinates = (latitude: number | undefined, longitude: number | undefined): { latitude?: number; longitude?: number } => {
// Validate and fix potentially swapped coordinates
if (latitude === undefined || longitude === undefined) {
return { latitude, longitude };
}
// Valid ranges: latitude [-90, 90], longitude [-180, 180]
const isValidLat = latitude >= -90 && latitude <= 90;
const isValidLng = longitude >= -180 && longitude <= 180;
// Both valid, no need to fix
if (isValidLat && isValidLng) {
return { latitude, longitude };
}
// Check if they're swapped
const isSwappedValid = (longitude >= -90 && longitude <= 90) && (latitude >= -180 && latitude <= 180);
if (isSwappedValid && (!isValidLat || !isValidLng)) {
console.warn(`Coordinates appear to be swapped: [${latitude}, ${longitude}], fixing to [${longitude}, ${latitude}]`);
return { latitude: longitude, longitude: latitude };
}
// One or both are invalid, return as-is (will be filtered out later)
return { latitude: isValidLat ? latitude : undefined, longitude: isValidLng ? longitude : undefined };
};
const mergePackets = (incoming: APRSPacketRecord[], current: APRSPacketRecord[]): APRSPacketRecord[] => {
const merged = [...incoming, ...current];
const byKey = new Map<string, APRSPacketRecord>();
merged.forEach((packet) => {
const key = `${packet.timestamp.toISOString()}|${packet.raw}|${packet.radioName ?? ''}`;
if (!byKey.has(key)) {
const sourceSsid = packet.frame.source.ssid ?? 0;
const key = `${packet.timestamp.toISOString()}|${packet.frame.source.call}|${sourceSsid}|${packet.raw}`;
const existing = byKey.get(key);
if (!existing) {
byKey.set(key, packet);
return;
}
if (!existing.hasAPILocation && packet.hasAPILocation) {
byKey.set(key, packet);
}
});
@@ -129,6 +171,8 @@ const buildRecord = ({
comment,
latitude,
longitude,
symbol,
preferProvidedLocation,
}: {
raw: string;
timestamp: Date;
@@ -136,22 +180,33 @@ const buildRecord = ({
comment?: string;
latitude?: number;
longitude?: number;
symbol?: string;
preferProvidedLocation?: boolean;
}): APRSPacketRecord | null => {
try {
const frame = parseFrame(raw);
const details = extractAPRSDetails(frame);
const hasProvidedLocation = latitude !== undefined && longitude !== undefined;
const finalLat = hasProvidedLocation ? latitude : details.latitude;
const finalLng = hasProvidedLocation ? longitude : details.longitude;
const normalized = (preferProvidedLocation && hasProvidedLocation)
? { latitude: finalLat, longitude: finalLng }
: normalizeCoordinates(finalLat, finalLng);
return {
timestamp,
raw,
frame,
latitude: latitude ?? details.latitude,
longitude: longitude ?? details.longitude,
latitude: normalized.latitude,
longitude: normalized.longitude,
altitude: details.altitude,
speed: details.speed,
course: details.course,
comment: comment ?? details.comment,
symbol: symbol ?? details.symbol,
radioName,
hasAPILocation: preferProvidedLocation && hasProvidedLocation,
};
} catch {
return null;
@@ -171,6 +226,8 @@ const toRecordFromAPI = (packet: FetchedAPRSPacket): APRSPacketRecord | null =>
comment: packet.comment,
latitude: fromNullableNumber(packet.latitude),
longitude: fromNullableNumber(packet.longitude),
symbol: packet.symbol,
preferProvidedLocation: true,
});
};
@@ -188,6 +245,7 @@ interface APRSDataProviderProps {
export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children }) => {
const [packets, setPackets] = useState<APRSPacketRecord[]>([]);
const [streamReady, setStreamReady] = useState(false);
const stream = useMemo(() => new APRSStream(false), []);
useEffect(() => {
@@ -213,6 +271,10 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
fetchPackets();
stream.connect();
const unsubscribeState = stream.subscribeToState((state) => {
setStreamReady(state.isConnected);
});
const unsubscribePackets = stream.subscribe<APRSMessage>('aprs/packet/#', (message) => {
const record = toRecordFromStream(message);
if (!record) {
@@ -224,6 +286,7 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
return () => {
isMounted = false;
unsubscribeState();
unsubscribePackets();
stream.disconnect();
};
@@ -232,8 +295,9 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
const value = useMemo(
() => ({
packets,
streamReady,
}),
[packets]
[packets, streamReady]
);
return <APRSDataContext.Provider value={value}>{children}</APRSDataContext.Provider>;

View File

@@ -1,9 +1,18 @@
import React, { useMemo, useState } from 'react';
import { Badge, Card, Stack, Table, Alert } from 'react-bootstrap';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import { Badge, Card, Stack, Table } from 'react-bootstrap';
import { MapContainer, TileLayer, Popup, useMap, CircleMarker, Marker } from 'react-leaflet';
import { divIcon, type DivIcon } from 'leaflet';
import { renderToStaticMarkup } from 'react-dom/server';
import { useSearchParams } from 'react-router';
import VerticalSplit from '../../components/VerticalSplit';
import HorizontalSplit from '../../components/HorizontalSplit';
import CountryFlag from '../../components/CountryFlag';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import StreamStatus from '../../components/StreamStatus';
import { APRSSymbol } from '../../components/aprs';
import { ClusteredMarkers } from '../../components/map';
import type { ClusterableItem, Cluster } from '../../components/map';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
import { useAPRSData } from './APRSData';
import type { APRSPacketRecord } from './APRSData';
@@ -14,7 +23,229 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label
</div>
);
const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
const Callsign: React.FC<{ call: string; ssid?: string | number; plain?: boolean }> = ({ call, ssid, plain = false }) => (
<span className={plain ? 'callsign callsign--plain' : 'callsign'}>
{call}
{(ssid !== undefined && ssid !== '') ? <span>-{ssid}</span> : null}
</span>
);
const getPacketKey = (packet: APRSPacketRecord): string => {
const ssid = packet.frame.source.ssid ?? 0;
return `${packet.frame.source.call}-${ssid}-${packet.timestamp.toISOString()}-${packet.raw}`;
};
const calculateBounds = (packets: APRSPacketRecord[]): [[number, number], [number, number]] | null => {
const validPackets = packets.filter(p => p.latitude !== undefined && p.longitude !== undefined);
if (validPackets.length === 0) {
return null;
}
if (validPackets.length === 1) {
const lat = validPackets[0].latitude!;
const lng = validPackets[0].longitude!;
const offset = 2;
return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
}
let minLat = validPackets[0].latitude!;
let maxLat = validPackets[0].latitude!;
let minLng = validPackets[0].longitude!;
let maxLng = validPackets[0].longitude!;
validPackets.forEach(packet => {
const lat = packet.latitude!;
const lng = packet.longitude!;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
});
// Add 10% padding to the bounds
const latPadding = (maxLat - minLat) * 0.1 || 0.5;
const lngPadding = (maxLng - minLng) * 0.1 || 0.5;
return [
[minLat - latPadding, minLng - lngPadding],
[maxLat + latPadding, maxLng + lngPadding]
];
};
const toRadians = (degrees: number): number => (degrees * Math.PI) / 180;
const distanceKm = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
const earthRadiusKm = 6371;
const dLat = toRadians(lat2 - lat1);
const dLng = toRadians(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusKm * c;
};
const median = (values: number[]): number | null => {
if (values.length === 0) {
return null;
}
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[middle - 1] + sorted[middle]) / 2;
}
return sorted[middle];
};
const filterByClusterRadius = (packets: APRSPacketRecord[], radiusKm: number): APRSPacketRecord[] => {
const validPackets = packets.filter(p => p.latitude !== undefined && p.longitude !== undefined);
if (validPackets.length <= 1) {
return validPackets;
}
const latCenter = median(validPackets.map((p) => p.latitude!));
const lngCenter = median(validPackets.map((p) => p.longitude!));
if (latCenter === null || lngCenter === null) {
return validPackets;
}
const filtered = validPackets.filter((p) => distanceKm(latCenter, lngCenter, p.latitude!, p.longitude!) <= radiusKm);
return filtered.length > 0 ? filtered : validPackets;
};
/**
* Validate if an APRS symbol string is valid
*/
const isValidSymbol = (symbol: string | undefined): boolean => {
if (!symbol || symbol.length < 2) return false;
const table = symbol.charAt(0);
const code = symbol.charAt(1);
const charCode = code.charCodeAt(0);
// Primary and secondary tables
if (table === '/' || table === '\\') {
return charCode >= 33 && charCode <= 125;
}
// Overlay characters: 0-9, A-Z
if ((charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90)) {
return true;
}
return false;
};
/**
* Create a Leaflet DivIcon for an APRS symbol
* Uses 32px source image rendered at 16px for crisp display
*/
const createAPRSIcon = (symbol: string) => {
const iconHtml = renderToStaticMarkup(
<APRSSymbol symbol={symbol} size={32} className="aprs-map-marker" />
);
return divIcon({
html: iconHtml,
className: 'aprs-marker-icon',
iconSize: [16, 16],
iconAnchor: [8, 8],
popupAnchor: [0, -8],
});
};
const MapPanHandler: React.FC<{
packet: APRSPacketRecord | null;
defaultBounds: [[number, number], [number, number]] | null;
}> = ({ packet, defaultBounds }) => {
const map = useMap();
const prevPacketRef = React.useRef<APRSPacketRecord | null>(packet);
const prevBoundsRef = React.useRef<[[number, number], [number, number]] | null>(defaultBounds);
React.useEffect(() => {
if (packet && packet.latitude && packet.longitude) {
const currentZoom = map.getZoom();
// Don't zoom out if we're already zoomed in further than default
const targetZoom = Math.max(currentZoom, 12);
map.flyTo([packet.latitude as number, packet.longitude as number], targetZoom, {
duration: 1.5,
easeLinearity: 0.25,
});
prevPacketRef.current = packet;
} else if (!packet) {
// No packet selected - show all markers
if (defaultBounds && (!prevPacketRef.current || JSON.stringify(prevBoundsRef.current) !== JSON.stringify(defaultBounds))) {
map.fitBounds(defaultBounds, {
padding: [50, 50],
maxZoom: 15,
});
prevBoundsRef.current = defaultBounds;
}
prevPacketRef.current = null;
}
}, [packet, defaultBounds, map]);
return null;
};
/**
* Render popup content for an APRS packet
*/
const renderPacketPopup = (p: APRSPacketRecord) => (
<div>
<Callsign call={p.frame.source.call} ssid={p.frame.source.ssid} />
<br />
Lat: {p.latitude?.toFixed(4)}, Lon: {p.longitude?.toFixed(4)}
{p.altitude && <div>Alt: {p.altitude.toFixed(0)}m</div>}
{p.speed && <div>Speed: {p.speed}kt</div>}
{p.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{p.comment}</div>}
</div>
);
/**
* Render popup content for a cluster
*/
const renderClusterPopup = (cluster: Cluster<ClusterableItem>) => {
const packets = cluster.items as APRSPacketRecord[];
return (
<div>
<strong>{cluster.count} stations</strong>
<div style={{ marginTop: '0.5rem', maxHeight: '200px', overflowY: 'auto' }}>
{packets.map((p, i) => (
<div key={i} style={{ marginBottom: '0.25rem' }}>
<Callsign call={p.frame.source.call} ssid={p.frame.source.ssid} />
</div>
))}
</div>
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Click to zoom in
</div>
</div>
);
};
/**
* Get icon for an APRS packet marker
*/
const getPacketIcon = (item: ClusterableItem): DivIcon | null => {
const packet = item as APRSPacketRecord;
if (isValidSymbol(packet.symbol)) {
return createAPRSIcon(packet.symbol!);
}
return null; // Will use default blue circle
};
const APRSMapPane: React.FC<{
packet: APRSPacketRecord | null;
packets: APRSPacketRecord[];
onSelectPacket: (packet: APRSPacketRecord) => void;
}> = ({ packet, packets, onSelectPacket }) => {
const hasPosition = packet && packet.latitude && packet.longitude;
// Create bounds from center point (offset by ~2 degrees)
@@ -23,41 +254,101 @@ const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet })
return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
};
// Get latest location for each source
const latestBySource = useMemo(() => {
const sourceMap = new Map<string, APRSPacketRecord>();
packets.forEach(p => {
if (p.latitude !== undefined && p.longitude !== undefined) {
const sourceSsid = p.frame.source.ssid ?? '';
const key = `${p.frame.source.call}-${sourceSsid}`;
// Keep the most recent packet for each source
if (!sourceMap.has(key) || p.timestamp > sourceMap.get(key)!.timestamp) {
sourceMap.set(key, p);
}
}
});
return Array.from(sourceMap.values());
}, [packets]);
const overviewLocations = useMemo(() => filterByClusterRadius(latestBySource, 250), [latestBySource]);
const defaultBounds = useMemo(() => calculateBounds(overviewLocations), [overviewLocations]);
const bounds = hasPosition
? createBoundsFromCenter(packet.latitude as number, packet.longitude as number)
: createBoundsFromCenter(50.0, 5.0);
: (defaultBounds ?? [[48.0, 3.0], [52.0, 7.0]]);
return (
<MapContainer
bounds={bounds}
style={{ height: '100%', width: '100%' }}
className="aprs-map"
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{hasPosition && (
<Marker position={[packet.latitude as number, packet.longitude as number]}>
<Popup>
<div>
<strong>{packet.frame.source.call}</strong>
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
<br />
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
{packet.speed && <div>Speed: {packet.speed}kt</div>}
</div>
</Popup>
</Marker>
)}
<Card className="data-table-card h-100" style={{ display: 'flex', flexDirection: 'column', padding: 0 }}>
<MapContainer
bounds={bounds}
style={{ flex: 1, width: '100%' }}
className="aprs-map"
>
<MapPanHandler packet={packet} defaultBounds={defaultBounds} />
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Clustered markers for latest location per source */}
<ClusteredMarkers<ClusterableItem>
items={overviewLocations}
getItemKey={(item) => getPacketKey(item as APRSPacketRecord)}
onItemClick={(item) => onSelectPacket(item as APRSPacketRecord)}
getIcon={getPacketIcon}
renderPopupContent={(item) => renderPacketPopup(item as APRSPacketRecord)}
renderClusterPopupContent={renderClusterPopup}
/>
{/* Highlight selected packet - use APRS symbol or red circle */}
{hasPosition && (
isValidSymbol(packet.symbol) ? (
<Marker
position={[packet.latitude as number, packet.longitude as number]}
icon={createAPRSIcon(packet.symbol!)}
>
<Popup>
<div>
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
<br />
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
{packet.speed && <div>Speed: {packet.speed}kt</div>}
{packet.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{packet.comment}</div>}
</div>
</Popup>
</Marker>
) : (
<CircleMarker
center={[packet.latitude as number, packet.longitude as number]}
radius={8}
pathOptions={{
color: '#dc3545',
fillColor: '#dc3545',
fillOpacity: 0.8,
weight: 3,
}}
>
<Popup>
<div>
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
<br />
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
{packet.speed && <div>Speed: {packet.speed}kt</div>}
{packet.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{packet.comment}</div>}
</div>
</Popup>
</CircleMarker>
)
)}
</MapContainer>
</Card>
);
};
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
if (!packet) {
return (
<Card body className="aprs-detail-card h-100">
<Card body className="data-table-card h-100">
<h6>Select a packet</h6>
<div>Click any packet in the list to view details and map.</div>
</Card>
@@ -66,36 +357,28 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
return (
<Stack gap={2} className="h-100 aprs-detail-stack">
<Card body className="aprs-detail-card">
<Card body className="data-table-card">
<Stack direction="horizontal" gap={2} className="mb-2">
<h6 className="mb-0">Packet Details</h6>
<Badge bg="primary">{packet.frame.source.call}</Badge>
<Badge bg="primary">
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} plain />
</Badge>
</Stack>
<HeaderFact label="Timestamp" value={packet.timestamp.toLocaleTimeString()} />
<HeaderFact
label="Source"
value={
<>
{packet.frame.source.call}
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
</>
}
value={<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />}
/>
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
<HeaderFact
label="Destination"
value={
<>
{packet.frame.destination.call}
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
</>
}
value={<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />}
/>
<HeaderFact label="Path" value={packet.frame.path.map((addr) => `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
</Card>
{(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && (
<Card body className="aprs-detail-card">
<Card body className="data-table-card">
<h6 className="mb-2">Position Data</h6>
{packet.latitude && <HeaderFact label="Latitude" value={packet.latitude.toFixed(6)} />}
{packet.longitude && <HeaderFact label="Longitude" value={packet.longitude.toFixed(6)} />}
@@ -106,13 +389,13 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
)}
{packet.comment && (
<Card body className="aprs-detail-card">
<Card body className="data-table-card">
<h6 className="mb-2">Comment</h6>
<div>{packet.comment}</div>
</Card>
)}
<Card body className="aprs-detail-card">
<Card body className="data-table-card">
<h6 className="mb-2">Raw Data</h6>
<code className="aprs-raw-code">{packet.raw}</code>
</Card>
@@ -122,11 +405,102 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
const PacketTable: React.FC<{
packets: APRSPacketRecord[];
selectedIndex: number | null;
onSelect: (index: number) => void;
}> = ({ packets, selectedIndex, onSelect }) => {
const [searchParams, setSearchParams] = useSearchParams();
radioFilter?: string;
selectedPacketKey: string | null;
onSelectPacket: (packet: APRSPacketRecord) => void;
onClearSelection: () => void;
streamReady: boolean;
}> = ({ packets, radioFilter, selectedPacketKey, onSelectPacket, onClearSelection, streamReady }) => {
const scrollRef = React.useRef<HTMLDivElement>(null);
const selectedIndex = useMemo(() => {
if (!selectedPacketKey) {
return null;
}
const index = packets.findIndex((packet) => getPacketKey(packet) === selectedPacketKey);
return index >= 0 ? index : null;
}, [packets, selectedPacketKey]);
const { showShortcuts, setShowShortcuts, shortcuts } = useKeyboardListNavigation({
itemCount: packets.length,
selectedIndex,
onSelectIndex: (index) => {
if (index === null) {
onClearSelection();
return;
}
const packet = packets[index];
if (packet) {
onSelectPacket(packet);
}
},
scrollContainerRef: scrollRef,
});
return (
<Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>APRS Packets</span>
<div className="d-flex align-items-center gap-2">
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="data-table-body p-0">
<div className="data-table-scroll" ref={scrollRef}>
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th>Time</th>
<th>&nbsp;</th>
<th>Source</th>
<th>Destination</th>
<th>&nbsp;</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{packets.map((packet, index) => (
<tr
key={getPacketKey(packet)}
data-nav-item="true"
className={selectedIndex === index ? 'is-selected' : ''}
onClick={() => onSelectPacket(packet)}
>
<td style={{ verticalAlign: 'top' }}>{packet.timestamp.toLocaleTimeString()}</td>
<td style={{ verticalAlign: 'top' }}>
<CountryFlag callsign={packet.frame.source.call} size={1.25} />
</td>
<td style={{ verticalAlign: 'top' }}>
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
</td>
<td style={{ verticalAlign: 'top' }}>
<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />
</td>
<td style={{ verticalAlign: 'top' }}>
{packet.symbol && <APRSSymbol symbol={packet.symbol} size={24} />}
</td>
<td style={{ verticalAlign: 'top' }}>{packet.comment || '-'}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Card.Body>
{radioFilter && <Card.Footer className="text-muted" style={{ fontSize: '0.875rem' }}>Filtered by radio: {radioFilter}</Card.Footer>}
<KeyboardShortcutsModal
show={showShortcuts}
onHide={() => setShowShortcuts(false)}
shortcuts={shortcuts}
/>
</Card>
);
};
const APRSPacketsView: React.FC = () => {
const { packets, streamReady } = useAPRSData();
const [searchParams] = useSearchParams();
const radioFilter = searchParams.get('radio') || undefined;
const [selectedPacketKey, setSelectedPacketKey] = useState<string | null>(null);
// Filter packets by radio name if specified
const filteredPackets = useMemo(() => {
@@ -134,83 +508,20 @@ const PacketTable: React.FC<{
return packets.filter(packet => packet.radioName === radioFilter);
}, [packets, radioFilter]);
return (
<Card className="aprs-table-card h-100 d-flex flex-column">
<Card.Header className="aprs-table-header">APRS Packets</Card.Header>
<Card.Body className="aprs-table-body p-0">
{radioFilter && (
<Alert variant="info" className="m-2 mb-0 d-flex align-items-center justify-content-between" style={{ fontSize: '0.875rem', padding: '0.5rem 0.75rem' }}>
<span>Filtering by radio: <strong>{radioFilter}</strong></span>
<button
type="button"
className="btn-close btn-close-white"
style={{ fontSize: '0.7rem' }}
onClick={() => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('radio');
setSearchParams(newParams);
}}
aria-label="Clear radio filter"
/>
</Alert>
)}
<div className="aprs-table-scroll">
<Table hover responsive className="aprs-table mb-0" size="sm">
<thead>
<tr>
<th>Time</th>
<th>Source</th>
<th>Destination</th>
<th>Position</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{filteredPackets.map((packet, index) => (
<tr
key={`${packet.frame.source.call}-${packet.timestamp.toISOString()}`}
className={selectedIndex === index ? 'is-selected' : ''}
onClick={() => onSelect(index)}
>
<td>{packet.timestamp.toLocaleTimeString()}</td>
<td>
{packet.frame.source.call}
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
</td>
<td>
{packet.frame.destination.call}
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
</td>
<td>{packet.latitude && packet.longitude ? '✓' : '-'}</td>
<td>{packet.comment || '-'}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Card.Body>
</Card>
);
};
const APRSPacketsView: React.FC = () => {
const { packets } = useAPRSData();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const selectedPacket = useMemo(() => {
if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= packets.length) {
if (!selectedPacketKey) {
return null;
}
return packets[selectedIndex] ?? null;
}, [packets, selectedIndex]);
return filteredPackets.find((packet) => getPacketKey(packet) === selectedPacketKey) ?? null;
}, [filteredPackets, selectedPacketKey]);
return (
<VerticalSplit
ratio="50/50"
left={<PacketTable packets={packets} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />}
ratio="1:1"
left={<PacketTable packets={filteredPackets} radioFilter={radioFilter} selectedPacketKey={selectedPacketKey} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} onClearSelection={() => setSelectedPacketKey(null)} streamReady={streamReady} />}
right={
<HorizontalSplit
top={<APRSMapPane packet={selectedPacket} />}
top={<APRSMapPane packet={selectedPacket} packets={filteredPackets} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} />}
bottom={<PacketDetailsPane packet={selectedPacket} />}
/>
}

View File

@@ -1,11 +1,24 @@
import React, { useEffect, useMemo, useState } from 'react';
import { bytesToHex } from '@noble/hashes/utils.js';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import BrandingWatermarkIcon from '@mui/icons-material/BrandingWatermark';
import PersonIcon from '@mui/icons-material/Person';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import ReplyIcon from '@mui/icons-material/Reply';
import RouteIcon from '@mui/icons-material/Route';
import SensorsIcon from '@mui/icons-material/Sensors';
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
import StorageIcon from '@mui/icons-material/Storage';
import { Packet } from '../../protocols/meshcore';
import { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types';
import { MeshCoreStream } from '../../services/MeshCoreStream';
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
import type { Payload } from '../../protocols/meshcore.types';
import type { Payload, AdvertPayload } from '../../protocols/meshcore.types';
import API from '../../services/API';
import MeshCoreServiceImpl from '../../services/MeshCoreService';
import { base64ToBytes } from '../../util';
import {
MeshCoreDataContext,
@@ -40,6 +53,38 @@ export const payloadValueByName = Object.fromEntries(
Object.entries(PayloadType).map(([name, value]) => [name, value])
) as Record<string, number>;
export const PayloadTypeIcon: React.FC<{ payloadType: number }> = ({ payloadType }) => {
switch (payloadType) {
case PayloadType.REQUEST:
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.RESPONSE:
return <ArrowBackIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.TEXT:
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.ACK:
return <ReplyIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.ADVERT:
return <SignalCellularAltIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.GROUP_TEXT:
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.GROUP_DATA:
return <StorageIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.ANON_REQ:
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.PATH:
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.TRACE:
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.MULTIPART:
return <BrandingWatermarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.CONTROL:
return <SensorsIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.RAW_CUSTOM:
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
default:
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess="Unknown" />;
}
};
export const nodeTypeValueByName = Object.fromEntries(
Object.entries(NodeType).map(([name, value]) => [name, value])
) as Record<string, number>;
@@ -116,61 +161,50 @@ export const routeValueByUrl: Record<string, number> = Object.fromEntries(
})
) as Record<string, number>;
export const asHex = (value: Uint8Array): string => bytesToHex(value);
const DISCARD_DUPLICATE_PATH_PACKETS = true;
const payloadTypeList = [
PayloadType.REQUEST,
PayloadType.RESPONSE,
PayloadType.TEXT,
PayloadType.ACK,
PayloadType.ADVERT,
PayloadType.GROUP_TEXT,
PayloadType.GROUP_DATA,
PayloadType.ANON_REQ,
PayloadType.PATH,
PayloadType.TRACE,
PayloadType.MULTIPART,
PayloadType.CONTROL,
PayloadType.RAW_CUSTOM,
] as const;
const nodeTypeList = [
NodeType.TYPE_CHAT_NODE,
NodeType.TYPE_REPEATER,
NodeType.TYPE_ROOM_SERVER,
NodeType.TYPE_SENSOR,
] as const;
const makePayloadBytes = (payloadType: number, seed: number): Uint8Array => {
switch (payloadType) {
case PayloadType.REQUEST:
return new Uint8Array([0x00, 0x12, 0x34, 0x56, 0x78, seed]);
case PayloadType.RESPONSE:
return new Uint8Array([0x01, 0x78, 0x56, 0x34, 0x12, seed]);
case PayloadType.TEXT:
return new Uint8Array([0xa1, 0xb2, 0x11, 0x22, 0x54, 0x58, 0x54, seed]);
case PayloadType.ACK:
return new Uint8Array([0x03, seed]);
case PayloadType.ADVERT:
return new Uint8Array([0x04, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, seed]);
case PayloadType.GROUP_TEXT:
return new Uint8Array([0xc4, 0x23, 0x99, 0x44, 0x55, 0x66, seed]);
case PayloadType.GROUP_DATA:
return new Uint8Array([0x34, 0x98, 0x76, 0x10, seed, 0xee]);
case PayloadType.ANON_REQ:
return new Uint8Array([0xfe, ...Array.from({ length: 32 }, (_, i) => (seed + i * 5) & 0xff), 0x55, 0xaa, 0x42, seed]);
case PayloadType.PATH:
return new Uint8Array([0x08, 0x11, 0x22, 0x33, 0x44, 0x55, seed]);
case PayloadType.TRACE:
return new Uint8Array([0x12, 0x31, 0x51, seed]);
case PayloadType.MULTIPART:
return new Uint8Array([0x01, seed, 0x02, 0x03, 0x04]);
case PayloadType.CONTROL:
return new Uint8Array([0x90, 0x01, 0x02, seed]);
case PayloadType.RAW_CUSTOM:
default:
return new Uint8Array([0xde, 0xad, 0xbe, 0xef, seed]);
const getPacketPathKey = (raw: Uint8Array): string => {
if (raw.length < 2) {
return '';
}
const pathField = raw[1];
const hashSize = (pathField >> 6) + 1;
const hashCount = pathField & 0x3f;
if (hashCount === 0 || hashSize === 4) {
return '';
}
const pathByteLength = hashCount * hashSize;
const availablePathBytes = Math.min(pathByteLength, Math.max(raw.length - 2, 0));
if (availablePathBytes <= 0) {
return '';
}
return bytesToHex(raw.slice(2, 2 + availablePathBytes));
};
const dedupeByHashAndPath = (packets: MeshCorePacketRecord[]): MeshCorePacketRecord[] => {
if (!DISCARD_DUPLICATE_PATH_PACKETS) {
return packets;
}
const sortedByReceiveTime = [...packets].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
const seen = new Set<string>();
const deduped: MeshCorePacketRecord[] = [];
sortedByReceiveTime.forEach((packet) => {
const signature = `${packet.hash}:${getPacketPathKey(packet.raw)}`;
if (seen.has(signature)) {
return;
}
seen.add(signature);
deduped.push(packet);
});
return deduped;
};
const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => {
@@ -187,7 +221,12 @@ const summarizePayload = (payloadType: number, decodedPayload: Payload | undefin
case PayloadType.ACK:
return 'acknowledgement';
case PayloadType.ADVERT:
return 'node advertisement';
const advert = decodedPayload as AdvertPayload;
console.log('advert', advert);
if (advert && decodedPayload && 'appdata' in decodedPayload && advert.appdata && 'name' in advert.appdata) {
return `advertisement: ${advert.appdata.name}`;
}
return 'advertisement';
case PayloadType.GROUP_TEXT:
if (decodedPayload && 'channelHash' in decodedPayload) {
return `group channel=${decodedPayload.channelHash}`;
@@ -210,55 +249,13 @@ const summarizePayload = (payloadType: number, decodedPayload: Payload | undefin
if (decodedPayload && 'flags' in decodedPayload && typeof decodedPayload.flags === 'number') {
return `control flags=0x${decodedPayload.flags.toString(16)}`;
}
return `control raw=${asHex(payloadBytes.slice(0, 4))}`;
return `control raw=${bytesToHex(payloadBytes.slice(0, 4))}`;
case PayloadType.RAW_CUSTOM:
default:
return `raw=${asHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`;
return `raw=${bytesToHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`;
}
};
const createMockRecord = (index: number): MeshCorePacketRecord => {
const payloadType = payloadTypeList[index % payloadTypeList.length];
const nodeType = nodeTypeList[index % nodeTypeList.length];
const version = 1;
const routeType = RouteType.FLOOD;
const path = new Uint8Array([(0x11 + index) & 0xff, (0x90 + index) & 0xff, (0xa0 + index) & 0xff]);
const payload = makePayloadBytes(payloadType, index);
const header = ((version & 0x03) << 6) | ((payloadType & 0x0f) << 2) | (routeType & 0x03);
const pathLength = path.length & 0x3f;
const raw = new Uint8Array([header, pathLength, ...path, ...payload]);
const packet = new Packet();
packet.parse(raw);
let decodedPayload: Payload | undefined;
try {
decodedPayload = packet.decode();
} catch {
decodedPayload = undefined;
}
return {
timestamp: new Date(Date.now() - index * 75_000),
hash: asHex(packet.hash()),
nodeType,
payloadType,
routeType,
version,
path,
raw,
decodedPayload,
payloadSummary: summarizePayload(payloadType, decodedPayload, payload),
};
};
const createMockData = (count = 48): MeshCorePacketRecord[] => {
return Array.from({ length: count }, (_, index) => createMockRecord(index)).sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
};
const toGroupChats = (packets: MeshCorePacketRecord[]): MeshCoreGroupChatRecord[] => {
return packets
.filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT)
@@ -307,12 +304,63 @@ const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
};
export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [packets, setPackets] = useState<MeshCorePacketRecord[]>(() => createMockData());
const [packets, setPackets] = useState<MeshCorePacketRecord[]>([]);
const [streamReady, setStreamReady] = useState(false);
const stream = useMemo(() => new MeshCoreStream(false), []);
const meshCoreService = useMemo(() => new MeshCoreServiceImpl(API), []);
useEffect(() => {
let isMounted = true;
const fetchPackets = async () => {
try {
const fetchedPackets = await meshCoreService.fetchPackets();
if (!isMounted) {
return;
}
const records: MeshCorePacketRecord[] = fetchedPackets.map((packet) => {
const raw = base64ToBytes(packet.raw);
let decodedPayload: Payload | undefined;
try {
const p = new Packet();
p.parse(raw);
decodedPayload = p.decode();
} catch {
decodedPayload = undefined;
}
const pathLength = raw[1] & 0x3f;
const payloadBytes = raw.slice(2 + pathLength);
return {
timestamp: new Date(packet.received_at),
hash: packet.hash,
nodeType: packet.payload_type === PayloadType.ADVERT ? NodeType.TYPE_UNKNOWN : 0,
payloadType: packet.payload_type,
routeType: packet.route_type,
version: packet.version,
path: raw.slice(2, 2 + pathLength),
raw,
decodedPayload,
payloadSummary: summarizePayload(packet.payload_type, decodedPayload, payloadBytes),
};
});
setPackets((prev) => {
const merged = dedupeByHashAndPath([...records, ...prev]);
return merged
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 500);
});
} catch (error) {
console.error('Failed to fetch MeshCore packets:', error);
}
};
fetchPackets();
stream.connect();
const unsubscribeState = stream.subscribeToState((state) => {
@@ -356,18 +404,21 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
packet.payloadSummary = `raw=${bytesToHex(packet.raw.slice(0, Math.min(6, packet.raw.length)))}`;
}
// Add to front of list, keeping last 500 packets
return [packet, ...prev].slice(0, 500);
const merged = dedupeByHashAndPath([packet, ...prev]);
return merged
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 500);
});
}
);
return () => {
isMounted = false;
unsubscribeState();
unsubscribePackets();
stream.disconnect();
};
}, [stream]);
}, [stream, meshCoreService]);
const groupChats = useMemo(() => toGroupChats(packets), [packets]);
const mapPoints = useMemo(() => toMapPoints(packets), [packets]);

View File

@@ -7,6 +7,8 @@ import MeshCoreServiceImpl, { type MeshCoreGroupRecord } from '../../services/Me
import API from '../../services/API';
import type { MeshCoreGroupChatRecord } from './MeshCoreContext';
import { KeyManager, Packet } from '../../protocols/meshcore';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
const meshCoreService = new MeshCoreServiceImpl(API);
@@ -14,30 +16,58 @@ const GroupList: React.FC<{
groups: MeshCoreGroupRecord[];
selectedGroupId: number | null;
onSelectGroup: (group: MeshCoreGroupRecord) => void;
onClearSelection: () => void;
isLoading: boolean;
error: string | null;
streamReady: boolean;
}> = ({ groups, selectedGroupId, onSelectGroup, isLoading, error, streamReady }) => {
}> = ({ groups, selectedGroupId, onSelectGroup, onClearSelection, isLoading, error, streamReady }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const selectedIndex = useMemo(() => {
if (selectedGroupId === null) {
return null;
}
const index = groups.findIndex((group) => group.id === selectedGroupId);
return index >= 0 ? index : null;
}, [groups, selectedGroupId]);
const { showShortcuts, setShowShortcuts, shortcuts } = useKeyboardListNavigation({
itemCount: groups.length,
selectedIndex,
onSelectIndex: (index) => {
if (index === null) {
onClearSelection();
return;
}
const group = groups[index];
if (group) {
onSelectGroup(group);
}
},
scrollContainerRef: scrollRef,
rowSelector: '[data-nav-item="true"]',
});
return (
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>Groups</span>
<div className="d-flex align-items-center gap-2">
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column">
<Card.Body className="data-table-body p-0 d-flex flex-column">
{error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>}
{groups.length === 0 && !isLoading && (
<div className="p-3 text-secondary text-center">
{error ? 'Failed to load groups' : 'No groups available'}
</div>
)}
<div className="meshcore-table-scroll">
<div className="data-table-scroll" ref={scrollRef}>
{groups.map((group) => (
<div
key={group.id}
data-nav-item="true"
onClick={() => onSelectGroup(group)}
className={`list-item ${selectedGroupId === group.id ? 'is-selected' : ''}`}
>
@@ -50,6 +80,11 @@ const GroupList: React.FC<{
))}
</div>
</Card.Body>
<KeyboardShortcutsModal
show={showShortcuts}
onHide={() => setShowShortcuts(false)}
shortcuts={shortcuts}
/>
</Card>
);
};
@@ -63,7 +98,7 @@ const GroupMessagesPane: React.FC<{
}> = ({ group, messages, isLoading, error, streamReady }) => {
if (!group) {
return (
<Card body className="meshcore-detail-card h-100 d-flex flex-column justify-content-center align-items-center">
<Card body className="data-table-card h-100 d-flex flex-column justify-content-center align-items-center">
<h6 className="mb-2">Select a group</h6>
<div className="text-secondary text-center">Click a group on the left to view messages</div>
</Card>
@@ -71,20 +106,20 @@ const GroupMessagesPane: React.FC<{
}
return (
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>{group.name}</span>
<div className="d-flex align-items-center gap-2">
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column">
<Card.Body className="data-table-body p-0 d-flex flex-column">
{error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>}
{messages.length === 0 && !isLoading && (
<div className="p-3 text-secondary text-center">No messages in this group</div>
)}
<div className="meshcore-table-scroll">
<div className="data-table-scroll">
<Stack gap={3} className="p-3">
{messages.map((message) => (
<div key={message.hash + message.timestamp.toISOString()} className="meshcore-message-item">
@@ -173,7 +208,7 @@ const MeshCoreGroupChatView: React.FC = () => {
const decrypted = keyManagerRef.current.decryptGroup(
packet.channel_hash,
payload.cipherText as Uint8Array,
payload.cipherMAC as Uint8Array
payload.cipherMAC
);
messages.push({
@@ -233,6 +268,7 @@ const MeshCoreGroupChatView: React.FC = () => {
groups={groups}
selectedGroupId={selectedGroup?.id ?? null}
onSelectGroup={handleSelectGroup}
onClearSelection={() => setSelectedGroup(null)}
isLoading={isLoadingGroups}
error={groupsError}
streamReady={streamReady}

View File

@@ -9,12 +9,12 @@ const MeshCoreMapView: React.FC = () => {
return (
<Full className="meshcore-map-view">
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>Node Map</span>
<Badge bg={streamReady ? 'success' : 'secondary'}>{streamReady ? 'stream ready' : 'stream offline'}</Badge>
</Card.Header>
<Card.Body className="meshcore-table-body d-flex flex-column gap-3">
<Card.Body className="data-table-body d-flex flex-column gap-3">
<div className="meshcore-map-canvas">
{mapPoints.map((point) => (
<div
@@ -29,8 +29,8 @@ const MeshCoreMapView: React.FC = () => {
))}
</div>
<div className="meshcore-table-scroll">
<Table responsive size="sm" className="meshcore-table mb-0">
<div className="data-table-scroll">
<Table responsive size="sm" className="data-table mb-0">
<thead>
<tr>
<th>Node</th>

View File

@@ -0,0 +1,424 @@
import React, { useMemo, useState } from 'react';
import { bytesToHex } from '@noble/hashes/utils.js';
import { Badge, Card, Stack } from 'react-bootstrap';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadDisplayByValue,
routeDisplayByValue,
} from './MeshCoreData';
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="meshcore-fact-row">
<span className="meshcore-fact-label">{label}</span>
<span className="meshcore-fact-value">{value}</span>
</div>
);
const toBitString = (value: number): string => value.toString(2).padStart(8, '0');
const byteHex = (value: number | undefined): string => {
if (typeof value !== 'number') {
return '0x??';
}
return `0x${value.toString(16).padStart(2, '0')}`;
};
const bitSlice = (value: number, msb: number, lsb: number): number => {
const width = msb - lsb + 1;
const mask = (1 << width) - 1;
return (value >> lsb) & mask;
};
const toAscii = (value: number): string => {
if (value >= 32 && value <= 126) {
return String.fromCharCode(value);
}
return '.';
};
const asRecord = (value: unknown): Record<string, unknown> | null => {
if (value && typeof value === 'object') {
return value as Record<string, unknown>;
}
return null;
};
interface BitFieldSpec {
msb: number;
lsb: number;
shortLabel: string;
label: string;
value: number;
}
const buildBitPointerLine = (msb: number, lsb: number): string => {
const width = 15;
const start = (7 - msb) * 2;
const end = (7 - lsb) * 2;
const chars = Array.from({ length: width }, () => ' ');
if (start === end) {
chars[start] = '↑';
return chars.join('');
}
chars[start] = '└';
for (let i = start + 1; i < end; i += 1) {
chars[i] = '─';
}
chars[end] = '┘';
return chars.join('');
};
const renderBitPointerArt = (
value: number,
fields: BitFieldSpec[],
mode: 'compact' | 'verbose'
): string => {
const header = 'bits: 7 6 5 4 3 2 1 0';
const bitRow = `val : ${toBitString(value).split('').join(' ')}`;
const pointers = fields.map((field) => {
const name = mode === 'compact' ? field.shortLabel : field.label;
return ` ${buildBitPointerLine(field.msb, field.lsb)} ${name} = ${field.value}`;
});
if (mode === 'compact') {
const legend = `key : ${fields.map((field) => field.shortLabel).join(', ')}`;
return [header, bitRow, ...pointers, legend].join('\n');
}
return [header, bitRow, ...pointers].join('\n');
};
interface ByteDissectionRow {
index: number;
byte: number;
zone: 'header' | 'path' | 'payload';
}
const buildByteDissection = (packet: MeshCorePacketRecord): {
rows: ByteDissectionRow[];
pathHashSize: number;
pathHashCount: number;
pathBytesAvailable: number;
payloadOffset: number;
} => {
const pathField = packet.raw.length > 1 ? packet.raw[1] : 0;
const pathHashSize = bitSlice(pathField, 7, 6) + 1;
const pathHashCount = bitSlice(pathField, 5, 0);
const pathBytesExpected = pathHashCount === 0 || pathHashSize === 4 ? 0 : pathHashCount * pathHashSize;
const pathBytesAvailable = Math.min(pathBytesExpected, Math.max(packet.raw.length - 2, 0));
const payloadOffset = 2 + pathBytesAvailable;
const rows: ByteDissectionRow[] = Array.from(packet.raw).map((byte, index) => {
if (index <= 1) {
return {
index,
byte,
zone: 'header',
};
}
if (index < payloadOffset) {
return {
index,
byte,
zone: 'path',
};
}
return {
index,
byte,
zone: 'payload',
};
});
return {
rows,
pathHashSize,
pathHashCount,
pathBytesAvailable,
payloadOffset,
};
};
const WireDissector: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const [bitArtMode, setBitArtMode] = useState<'compact' | 'verbose'>('compact');
const { rows, pathHashSize, pathHashCount, pathBytesAvailable, payloadOffset } = useMemo(
() => buildByteDissection(packet),
[packet]
);
const headerByte = packet.raw[0] ?? 0;
const pathByte = packet.raw[1] ?? 0;
const pathBytes = packet.raw.slice(2, payloadOffset);
const payloadBytes = packet.raw.slice(payloadOffset);
const hexdumpRows = useMemo(() => {
const chunks: ByteDissectionRow[][] = [];
for (let i = 0; i < rows.length; i += 16) {
chunks.push(rows.slice(i, i + 16));
}
return chunks.map((chunk, rowIndex) => ({
offset: rowIndex * 16,
cells: [...chunk, ...Array<ByteDissectionRow | null>(Math.max(16 - chunk.length, 0)).fill(null)],
}));
}, [rows]);
return (
<Card body className="data-table-card">
<Stack direction="horizontal" className="justify-content-between align-items-center mb-2">
<h6 className="mb-0">Packet Bytes (Wire View)</h6>
<button
type="button"
className="meshcore-bitart-toggle"
onClick={() => setBitArtMode((prev) => (prev === 'compact' ? 'verbose' : 'compact'))}
title="Toggle compact/verbose bit pointer annotations"
>
Bit Art: {bitArtMode === 'compact' ? 'Compact' : 'Verbose'}
</button>
</Stack>
<div className="meshcore-wire-subtitle">Protocol tree + hex dump, similar to a packet analyzer output</div>
<div className="meshcore-ws-layout mt-3">
<div className="meshcore-ws-panel">
<div className="meshcore-ws-panel-title">Packet Details</div>
<ul className="meshcore-ws-tree">
<li>
<span className="meshcore-ws-node">Frame 1: {packet.raw.length} bytes on wire ({packet.raw.length * 8} bits)</span>
</li>
<li>
<span className="meshcore-ws-node">MeshCore Header</span>
<ul>
<li>Byte[0] = <code>{byteHex(headerByte)}</code> = <code>{toBitString(headerByte)}</code></li>
<li>b7..b6: Version = <strong>{bitSlice(headerByte, 7, 6)}</strong></li>
<li>b5..b2: Payload Type = <strong>{bitSlice(headerByte, 5, 2)}</strong> ({payloadDisplayByValue[packet.payloadType] ?? 'Unknown'})</li>
<li>b1..b0: Route Type = <strong>{bitSlice(headerByte, 1, 0)}</strong> ({routeDisplayByValue[packet.routeType] ?? 'Unknown'})</li>
<li>
<pre className="meshcore-bit-art">
{renderBitPointerArt(headerByte, [
{
msb: 7,
lsb: 6,
shortLabel: 'ver',
label: 'version (b7..b6)',
value: bitSlice(headerByte, 7, 6),
},
{
msb: 5,
lsb: 2,
shortLabel: 'ptype',
label: 'payload_type (b5..b2)',
value: bitSlice(headerByte, 5, 2),
},
{
msb: 1,
lsb: 0,
shortLabel: 'route',
label: 'route_type (b1..b0)',
value: bitSlice(headerByte, 1, 0),
},
], bitArtMode)}
</pre>
</li>
</ul>
</li>
<li>
<span className="meshcore-ws-node">Path Descriptor</span>
<ul>
<li>Byte[1] = <code>{byteHex(pathByte)}</code> = <code>{toBitString(pathByte)}</code></li>
<li>b7..b6: Hash size = ({bitSlice(pathByte, 7, 6)} + 1) = <strong>{pathHashSize}</strong></li>
<li>b5..b0: Hash count = <strong>{pathHashCount}</strong></li>
<li>Path bytes in frame = <strong>{pathBytesAvailable}</strong></li>
<li>Path data = <code>{pathBytes.length > 0 ? bytesToHex(pathBytes) : 'none'}</code></li>
<li>
<pre className="meshcore-bit-art">
{renderBitPointerArt(pathByte, [
{
msb: 7,
lsb: 6,
shortLabel: 'hsel',
label: 'hash_size_selector (b7..b6)',
value: bitSlice(pathByte, 7, 6),
},
{
msb: 5,
lsb: 0,
shortLabel: 'hcnt',
label: 'hash_count (b5..b0)',
value: bitSlice(pathByte, 5, 0),
},
], bitArtMode)}
</pre>
</li>
</ul>
</li>
<li>
<span className="meshcore-ws-node">Payload</span>
<ul>
<li>Payload offset = <code>{payloadOffset}</code></li>
<li>Payload length = <strong>{payloadBytes.length}</strong> bytes</li>
<li>Payload bytes = <code>{payloadBytes.length > 0 ? bytesToHex(payloadBytes) : 'none'}</code></li>
</ul>
</li>
</ul>
</div>
<div className="meshcore-ws-panel">
<div className="meshcore-ws-panel-title">Hex Dump</div>
<div className="meshcore-ws-legend mb-2">
<span><i className="meshcore-ws-chip meshcore-ws-zone-header" />Header</span>
<span><i className="meshcore-ws-chip meshcore-ws-zone-path" />Path</span>
<span><i className="meshcore-ws-chip meshcore-ws-zone-payload" />Payload</span>
</div>
<div className="meshcore-ws-hexdump">
{hexdumpRows.map((row) => (
<div key={row.offset} className="meshcore-ws-hexdump-row">
<span className="meshcore-ws-offset">{row.offset.toString(16).padStart(4, '0')}</span>
<span className="meshcore-ws-bytes">
{row.cells.map((cell, index) => (
<span key={`${row.offset}-${index}`} className={`meshcore-ws-byte ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
{cell ? cell.byte.toString(16).padStart(2, '0') : ' '}
</span>
))}
</span>
<span className="meshcore-ws-ascii">
{row.cells.map((cell, index) => (
<span key={`${row.offset}-ascii-${index}`} className={`meshcore-ws-char ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
{cell ? toAscii(cell.byte) : ' '}
</span>
))}
</span>
</div>
))}
</div>
</div>
</div>
</Card>
);
};
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const payload = packet.decodedPayload;
const payloadObj = asRecord(payload);
if (!payloadObj) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Payload</h6>
<div>Unable to decode payload; showing raw bytes only.</div>
<code>{bytesToHex(packet.raw)}</code>
</Card>
);
}
if (typeof payloadObj.flags === 'number' && payloadObj.data instanceof Uint8Array) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">CONTROL Payload</h6>
<HeaderFact label="Flags" value={`0x${payloadObj.flags.toString(16)}`} />
<HeaderFact label="Data Length" value={payloadObj.data.length} />
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
</Card>
);
}
if (
typeof payloadObj.channelHash === 'string'
&& payloadObj.cipherText instanceof Uint8Array
&& payloadObj.cipherMAC instanceof Uint8Array
) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">GROUP Payload</h6>
<HeaderFact label="Channel Hash" value={payloadObj.channelHash} />
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
</Card>
);
}
if (
typeof payloadObj.dstHash === 'string'
&& typeof payloadObj.srcHash === 'string'
&& payloadObj.cipherText instanceof Uint8Array
&& payloadObj.cipherMAC instanceof Uint8Array
) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Encrypted Payload</h6>
<HeaderFact label="Destination" value={payloadObj.dstHash} />
<HeaderFact label="Source" value={payloadObj.srcHash} />
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
</Card>
);
}
if (payloadObj.data instanceof Uint8Array) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Raw Payload</h6>
<HeaderFact label="Data Length" value={payloadObj.data.length} />
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
</Card>
);
}
return (
<Card body className="data-table-card">
<h6 className="mb-2">Payload</h6>
<code>{JSON.stringify(payloadObj)}</code>
</Card>
);
};
interface MeshCorePacketDetailsPaneProps {
packet: MeshCorePacketRecord | null;
streamReady: boolean;
}
const MeshCorePacketDetailsPane: React.FC<MeshCorePacketDetailsPaneProps> = ({ packet, streamReady }) => {
if (!packet) {
return (
<Card body className="data-table-card h-100">
<h6>Select a packet</h6>
<div>Click any hash in the table to inspect MeshCore header and payload details.</div>
<div className="mt-2 text-secondary">Stream prepared: {streamReady ? 'yes' : 'no'}</div>
</Card>
);
}
return (
<Stack gap={2} className="h-100 meshcore-detail-stack">
<Card body className="data-table-card">
<Stack direction="horizontal" gap={2} className="mb-2">
<h6 className="mb-0">Packet Header</h6>
<Badge bg="primary">{payloadDisplayByValue[packet.payloadType] ?? packet.payloadType}</Badge>
</Stack>
<HeaderFact label="Time" value={packet.timestamp.toISOString()} />
<HeaderFact label="Hash" value={<code>{packet.hash}</code>} />
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
<HeaderFact label="Version" value={packet.version} />
<HeaderFact label="Payload Type" value={`${payloadDisplayByValue[packet.payloadType] ?? 'Unknown'} (${packet.payloadType})`} />
<HeaderFact label="Route Type" value={routeDisplayByValue[packet.routeType] ?? packet.routeType} />
<HeaderFact label="Raw Length" value={`${packet.raw.length} bytes`} />
<HeaderFact label="Path" value={<code>{bytesToHex(packet.path)}</code>} />
<HeaderFact label="Raw Packet" value={<code>{bytesToHex(packet.raw)}</code>} />
</Card>
<WireDissector packet={packet} />
<PayloadDetails packet={packet} />
<Card body className="data-table-card">
<h6 className="mb-2">Stream Preparation</h6>
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>
<div className="text-secondary">Ready: {streamReady ? 'yes' : 'no'}</div>
</Card>
</Stack>
);
};
export default MeshCorePacketDetailsPane;

View File

@@ -0,0 +1,194 @@
import React from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Dropdown,
Form,
Stack,
} from 'react-bootstrap';
import {
payloadDisplayByValue,
PayloadTypeIcon,
routeDisplayByValue,
} from './MeshCoreData';
interface FilterDropdownProps {
label: string;
options: number[];
selectedValues: Set<number>;
getLabelForValue: (value: number) => string;
getIconForValue?: (value: number) => React.ReactElement;
onToggle: (value: number, isChecked: boolean) => void;
onSelectAll: () => void;
}
const FilterDropdown: React.FC<FilterDropdownProps> = ({
label,
options,
selectedValues,
getLabelForValue,
getIconForValue,
onToggle,
onSelectAll,
}) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={(
<span>
{getIconForValue && <span style={{ marginRight: '0.5rem' }}>{getIconForValue(option)}</span>}
{getLabelForValue(option)}
</span>
)}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
interface StringFilterDropdownProps {
label: string;
options: string[];
selectedValues: Set<string>;
onToggle: (value: string, isChecked: boolean) => void;
onSelectAll: () => void;
}
const StringFilterDropdown: React.FC<StringFilterDropdownProps> = ({
label,
options,
selectedValues,
onToggle,
onSelectAll,
}) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={option}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
interface MeshCorePacketFiltersProps {
uniquePayloadTypes: number[];
uniqueRadioNames: string[];
uniqueRouteTypes: number[];
filterPayloadTypes: Set<number>;
filterRadios: Set<string>;
filterRouteTypes: Set<number>;
onPayloadToggle: (value: number, isChecked: boolean) => void;
onPayloadSelectAll: () => void;
onRadioToggle: (value: string, isChecked: boolean) => void;
onRadioSelectAll: () => void;
onRouteToggle: (value: number, isChecked: boolean) => void;
onRouteSelectAll: () => void;
}
const MeshCorePacketFilters: React.FC<MeshCorePacketFiltersProps> = ({
uniquePayloadTypes,
uniqueRadioNames,
uniqueRouteTypes,
filterPayloadTypes,
filterRadios,
filterRouteTypes,
onPayloadToggle,
onPayloadSelectAll,
onRadioToggle,
onRadioSelectAll,
onRouteToggle,
onRouteSelectAll,
}) => {
return (
<div className="meshcore-filters p-2 border-bottom border-secondary-subtle">
<Stack direction="horizontal" gap={2}>
<FilterDropdown
label="Payload Type"
options={uniquePayloadTypes}
selectedValues={filterPayloadTypes}
getLabelForValue={(value) => payloadDisplayByValue[value] ?? `0x${value.toString(16)}`}
getIconForValue={(value) => <PayloadTypeIcon payloadType={value} />}
onToggle={onPayloadToggle}
onSelectAll={onPayloadSelectAll}
/>
<StringFilterDropdown
label="Radio"
options={uniqueRadioNames}
selectedValues={filterRadios}
onToggle={onRadioToggle}
onSelectAll={onRadioSelectAll}
/>
<FilterDropdown
label="Route Type"
options={uniqueRouteTypes}
selectedValues={filterRouteTypes}
getLabelForValue={(value) => routeDisplayByValue[value] ?? `0x${value.toString(16)}`}
onToggle={onRouteToggle}
onSelectAll={onRouteSelectAll}
/>
</Stack>
</div>
);
};
export default MeshCorePacketFilters;

View File

@@ -0,0 +1,172 @@
import React from 'react';
import { bytesToHex } from '@noble/hashes/utils.js';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { PayloadType } from '../../protocols/meshcore.types';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadDisplayByValue,
PayloadTypeIcon,
} from './MeshCoreData';
const getPayloadTypeColor = (payloadType: number): string => {
switch (payloadType) {
case PayloadType.TEXT:
case PayloadType.GROUP_TEXT:
case PayloadType.TRACE:
case PayloadType.PATH:
return 'meshcore-packet-green';
case PayloadType.ADVERT:
return 'meshcore-packet-purple';
case PayloadType.REQUEST:
case PayloadType.RESPONSE:
case PayloadType.CONTROL:
return 'meshcore-packet-amber';
default:
return '';
}
};
const getPathInfo = (packet: MeshCorePacketRecord): { prefixes: string; hopCount: number } => {
if (packet.raw.length < 2) {
return { prefixes: 'none', hopCount: 0 };
}
const pathField = packet.raw[1];
const hashSize = (pathField >> 6) + 1;
const hashCount = pathField & 0x3f;
if (hashCount === 0 || hashSize === 4) {
return { prefixes: 'none', hopCount: 0 };
}
const pathByteLength = hashCount * hashSize;
const availablePathBytes = Math.min(pathByteLength, Math.max(packet.raw.length - 2, 0));
const pathBytes = packet.raw.slice(2, 2 + availablePathBytes);
if (pathBytes.length === 0) {
return { prefixes: 'none', hopCount: 0 };
}
const prefixes: string[] = [];
for (let offset = 0; offset + hashSize <= pathBytes.length; offset += hashSize) {
prefixes.push(bytesToHex(pathBytes.slice(offset, offset + hashSize)));
}
if (prefixes.length === 0) {
return { prefixes: 'none', hopCount: 0 };
}
return {
prefixes: prefixes.join(' → '),
hopCount: prefixes.length,
};
};
export interface MeshCorePacketGroup {
hash: string;
packets: MeshCorePacketRecord[];
mostRecent: MeshCorePacketRecord;
}
interface MeshCorePacketRowsProps {
groupedPackets: MeshCorePacketGroup[];
expandedHashes: Set<string>;
onToggleExpanded: (hash: string) => void;
onSelect: (packet: MeshCorePacketRecord) => void;
selectedHash: string | null;
}
const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
groupedPackets,
expandedHashes,
onToggleExpanded,
onSelect,
selectedHash,
}) => {
return (
<>
{groupedPackets.map((group) => {
const isExpanded = expandedHashes.has(group.hash);
const hasDuplicates = group.packets.length > 1;
const [packet, ...duplicatePackets] = group.packets;
const expandedPackets = [packet, ...duplicatePackets].sort((a, b) => {
const pathA = getPathInfo(a).hopCount;
const pathB = getPathInfo(b).hopCount;
if (pathA !== pathB) {
return pathA - pathB;
}
return b.timestamp.getTime() - a.timestamp.getTime();
});
return (
<React.Fragment key={group.hash}>
<tr
data-nav-item="true"
className={`${getPayloadTypeColor(packet.payloadType)} ${selectedHash === packet.hash ? 'is-selected' : ''}`}
onClick={() => onSelect(packet)}
>
<td>
{hasDuplicates && (
<button
type="button"
className="meshcore-expand-button"
onClick={(e) => {
e.stopPropagation();
onToggleExpanded(group.hash);
}}
aria-label={isExpanded ? 'Collapse duplicates' : 'Expand duplicates'}
>
{isExpanded ? <ExpandMoreIcon style={{ fontSize: '1rem' }} /> : <ChevronRightIcon style={{ fontSize: '1rem' }} />}
</button>
)}
</td>
<td>
{packet.timestamp.toLocaleTimeString()}
{hasDuplicates && (
<span className="meshcore-duplicate-badge" title={`${group.packets.length} instances`}>
{' '}×{group.packets.length}
</span>
)}
</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(packet)}>
{packet.hash}
</button>
</td>
<td className="meshcore-payload-type-cell" title={payloadDisplayByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}>
<PayloadTypeIcon payloadType={packet.payloadType} />
</td>
<td>{packet.payloadSummary}</td>
</tr>
{isExpanded && expandedPackets.map((duplicatePacket, index) => (
<tr
key={`${group.hash}-${index}`}
className={`${getPayloadTypeColor(duplicatePacket.payloadType)} ${selectedHash === duplicatePacket.hash ? 'is-selected' : ''}`}
onClick={() => onSelect(duplicatePacket)}
>
<td></td>
<td>{duplicatePacket.timestamp.toLocaleTimeString()}</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(duplicatePacket)}>
{duplicatePacket.hash}
</button>
</td>
<td className="meshcore-payload-type-cell" title={payloadDisplayByValue[duplicatePacket.payloadType] ?? `0x${duplicatePacket.payloadType.toString(16)}`}>
<PayloadTypeIcon payloadType={duplicatePacket.payloadType} />
</td>
<td>
{getPathInfo(duplicatePacket).prefixes}
</td>
</tr>
))}
</React.Fragment>
);
})}
</>
);
};
export default MeshCorePacketRows;

View File

@@ -0,0 +1,243 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import {
Card,
Table,
} from 'react-bootstrap';
import StreamStatus from '../../components/StreamStatus';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadUrlByValue,
payloadValueByUrl,
routeUrlByValue,
routeValueByUrl,
} from './MeshCoreData';
import MeshCorePacketFilters from './MeshCorePacketFilters';
import MeshCorePacketRows, { type MeshCorePacketGroup } from './MeshCorePacketRows';
interface MeshCorePacketTableProps {
packets: MeshCorePacketRecord[];
selectedHash: string | null;
onSelect: (packet: MeshCorePacketRecord) => void;
onClearSelection: () => void;
streamReady: boolean;
}
const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, selectedHash, onSelect, onClearSelection, streamReady }) => {
const [searchParams, setSearchParams] = useSearchParams();
const radios = useRadiosByProtocol('meshcore');
const scrollRef = React.useRef<HTMLDivElement>(null);
const [filterPayloadTypes, setFilterPayloadTypes] = useState<Set<number>>(() => {
const payloads = searchParams.get('payloads');
if (!payloads) return new Set();
return new Set(payloads.split(',').map((urlName) => payloadValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterRouteTypes, setFilterRouteTypes] = useState<Set<number>>(() => {
const routes = searchParams.get('routes');
if (!routes) return new Set();
return new Set(routes.split(',').map((urlName) => routeValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterRadios, setFilterRadios] = useState<Set<string>>(() => {
const radiosParam = searchParams.get('radios');
if (!radiosParam) return new Set();
return new Set(radiosParam.split(',').map(decodeURIComponent));
});
const [expandedHashes, setExpandedHashes] = useState<Set<string>>(new Set());
useEffect(() => {
const newParams = new URLSearchParams(searchParams);
if (filterPayloadTypes.size > 0) {
newParams.set('payloads', Array.from(filterPayloadTypes).map((v) => payloadUrlByValue[v]).join(','));
} else {
newParams.delete('payloads');
}
if (filterRouteTypes.size > 0) {
newParams.set('routes', Array.from(filterRouteTypes).map((v) => routeUrlByValue[v]).join(','));
} else {
newParams.delete('routes');
}
if (filterRadios.size > 0) {
newParams.set('radios', Array.from(filterRadios).map(encodeURIComponent).join(','));
} else {
newParams.delete('radios');
}
setSearchParams(newParams, { replace: true });
}, [filterPayloadTypes, filterRouteTypes, filterRadios, searchParams, setSearchParams]);
const uniquePayloadTypes = useMemo(() => {
const types = new Set(packets.map((packet) => packet.payloadType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniqueRouteTypes = useMemo(() => {
const types = new Set(packets.map((packet) => packet.routeType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniqueRadioNames = useMemo(() => {
const namesFromPackets = new Set(packets.map((packet) => packet.radioName).filter((name): name is string => !!name));
const namesFromAPI = new Set(radios.map((radio) => radio.name));
const allNames = new Set([...namesFromPackets, ...namesFromAPI]);
return Array.from(allNames).sort();
}, [packets, radios]);
const groupedPackets = useMemo((): MeshCorePacketGroup[] => {
const groups = new Map<string, MeshCorePacketRecord[]>();
packets.forEach((packet) => {
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) return;
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) return;
if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) return;
const existing = groups.get(packet.hash);
if (existing) {
existing.push(packet);
} else {
groups.set(packet.hash, [packet]);
}
});
return Array.from(groups.entries())
.map(([hash, grouped]) => ({
hash,
packets: grouped.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
mostRecent: grouped.reduce((latest, packet) => (
packet.timestamp > latest.timestamp ? packet : latest
)),
}))
.sort((a, b) => b.mostRecent.timestamp.getTime() - a.mostRecent.timestamp.getTime());
}, [packets, filterPayloadTypes, filterRouteTypes, filterRadios]);
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterPayloadTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterPayloadTypes(newSet);
};
const handleRouteTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterRouteTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRouteTypes(newSet);
};
const handleRadioToggle = (value: string, isChecked: boolean) => {
const newSet = new Set(filterRadios);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRadios(newSet);
};
const toggleExpanded = (hash: string) => {
const newExpanded = new Set(expandedHashes);
if (newExpanded.has(hash)) {
newExpanded.delete(hash);
} else {
newExpanded.add(hash);
}
setExpandedHashes(newExpanded);
};
const navigablePackets = useMemo(() => groupedPackets.map((group) => group.mostRecent), [groupedPackets]);
const selectedIndex = useMemo(() => {
if (!selectedHash) {
return null;
}
const index = navigablePackets.findIndex((packet) => packet.hash === selectedHash);
return index >= 0 ? index : null;
}, [navigablePackets, selectedHash]);
const { showShortcuts, setShowShortcuts, shortcuts } = useKeyboardListNavigation({
itemCount: navigablePackets.length,
selectedIndex,
onSelectIndex: (index) => {
if (index === null) {
onClearSelection();
return;
}
const packet = navigablePackets[index];
if (packet) {
onSelect(packet);
}
},
scrollContainerRef: scrollRef,
});
return (
<Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>MeshCore Packets</span>
<div className="d-flex align-items-center gap-2">
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="data-table-body p-0 d-flex flex-column">
<MeshCorePacketFilters
uniquePayloadTypes={uniquePayloadTypes}
uniqueRadioNames={uniqueRadioNames}
uniqueRouteTypes={uniqueRouteTypes}
filterPayloadTypes={filterPayloadTypes}
filterRadios={filterRadios}
filterRouteTypes={filterRouteTypes}
onPayloadToggle={handlePayloadTypeToggle}
onPayloadSelectAll={() => setFilterPayloadTypes(new Set())}
onRadioToggle={handleRadioToggle}
onRadioSelectAll={() => setFilterRadios(new Set())}
onRouteToggle={handleRouteTypeToggle}
onRouteSelectAll={() => setFilterRouteTypes(new Set())}
/>
<div className="data-table-scroll" ref={scrollRef}>
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '100px' }}>Time</th>
<th style={{ width: '80px' }}>Hash</th>
<th style={{ width: '50px' }}>Type</th>
<th>Info</th>
</tr>
</thead>
<tbody>
<MeshCorePacketRows
groupedPackets={groupedPackets}
expandedHashes={expandedHashes}
onToggleExpanded={toggleExpanded}
onSelect={onSelect}
selectedHash={selectedHash}
/>
</tbody>
</Table>
</div>
</Card.Body>
<KeyboardShortcutsModal
show={showShortcuts}
onHide={() => setShowShortcuts(false)}
shortcuts={shortcuts}
/>
</Card>
);
};
export default MeshCorePacketTable;

View File

@@ -1,571 +1,9 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { Badge, Card, Dropdown, Form, Stack, Table } from 'react-bootstrap';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import PersonIcon from '@mui/icons-material/Person';
import SensorsIcon from '@mui/icons-material/Sensors';
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
import StorageIcon from '@mui/icons-material/Storage';
import StreamStatus from '../../components/StreamStatus';
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
import { NodeType, PayloadType } from '../../protocols/meshcore.types';
import type { Payload } from '../../protocols/meshcore.types';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import React, { useMemo, useState } from 'react';
import VerticalSplit from '../../components/VerticalSplit';
import {
asHex,
nodeTypeDisplayByValue,
nodeTypeUrlByValue,
nodeTypeValueByUrl,
payloadDisplayByValue,
payloadUrlByValue,
payloadValueByUrl,
routeDisplayByValue,
routeUrlByValue,
routeValueByUrl,
} from './MeshCoreData';
import { useMeshCoreData } from './MeshCoreContext';
const getNodeTypeIcon = (nodeType: number) => {
switch (nodeType) {
case NodeType.TYPE_CHAT_NODE:
return <PersonIcon className="meshcore-node-icon" />;
case NodeType.TYPE_REPEATER:
return <SignalCellularAltIcon className="meshcore-node-icon" />;
case NodeType.TYPE_ROOM_SERVER:
return <StorageIcon className="meshcore-node-icon" />;
case NodeType.TYPE_SENSOR:
return <SensorsIcon className="meshcore-node-icon" />;
default:
return null;
}
};
const getPayloadTypeColor = (payloadType: number): string => {
switch (payloadType) {
case PayloadType.TEXT:
case PayloadType.GROUP_TEXT:
case PayloadType.TRACE:
case PayloadType.PATH:
return 'meshcore-packet-green';
case PayloadType.ADVERT:
return 'meshcore-packet-purple';
case PayloadType.REQUEST:
case PayloadType.RESPONSE:
case PayloadType.CONTROL:
return 'meshcore-packet-amber';
default:
return '';
}
};
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="meshcore-fact-row">
<span className="meshcore-fact-label">{label}</span>
<span className="meshcore-fact-value">{value}</span>
</div>
);
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const payload = packet.decodedPayload;
if (!payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Payload</h6>
<div>Unable to decode payload; showing raw bytes only.</div>
<code>{asHex(packet.raw)}</code>
</Card>
);
}
if ('flags' in payload && 'data' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">CONTROL Payload</h6>
<HeaderFact label="Flags" value={`0x${payload.flags.toString(16)}`} />
<HeaderFact label="Data Length" value={payload.data.length} />
<HeaderFact label="Data" value={<code>{asHex(payload.data)}</code>} />
</Card>
);
}
if ('channelHash' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">GROUP Payload</h6>
<HeaderFact label="Channel Hash" value={payload.channelHash} />
<HeaderFact label="Cipher Text Length" value={payload.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{asHex(payload.cipherMAC as Uint8Array)}</code>} />
</Card>
);
}
if ('dstHash' in payload && 'srcHash' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Encrypted Payload</h6>
<HeaderFact label="Destination" value={payload.dstHash} />
<HeaderFact label="Source" value={payload.srcHash} />
<HeaderFact label="Cipher Text Length" value={payload.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{asHex(payload.cipherMAC as Uint8Array)}</code>} />
</Card>
);
}
if ('data' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Raw Payload</h6>
<HeaderFact label="Data Length" value={payload.data.length} />
<HeaderFact label="Data" value={<code>{asHex(payload.data)}</code>} />
</Card>
);
}
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Payload</h6>
<code>{JSON.stringify(payload as Payload)}</code>
</Card>
);
};
const FilterDropdown: React.FC<{
label: string;
options: number[];
selectedValues: Set<number>;
getLabelForValue: (value: number) => string;
onToggle: (value: number, isChecked: boolean) => void;
onSelectAll: () => void;
}> = ({ label, options, selectedValues, getLabelForValue, onToggle, onSelectAll }) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={getLabelForValue(option)}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
const StringFilterDropdown: React.FC<{
label: string;
options: string[];
selectedValues: Set<string>;
onToggle: (value: string, isChecked: boolean) => void;
onSelectAll: () => void;
}> = ({ label, options, selectedValues, onToggle, onSelectAll }) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={option}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
const PacketTable: React.FC<{
packets: MeshCorePacketRecord[];
selectedHash: string | null;
onSelect: (packet: MeshCorePacketRecord) => void;
streamReady: boolean;
}> = ({ packets, selectedHash, onSelect, streamReady }) => {
const [searchParams, setSearchParams] = useSearchParams();
const radios = useRadiosByProtocol('meshcore');
// Initialize filter states from URL params (using human-readable names)
const [filterNodeTypes, setFilterNodeTypes] = useState<Set<number>>(() => {
const nodes = searchParams.get('nodes');
if (!nodes) return new Set();
return new Set(nodes.split(',').map((urlName) => nodeTypeValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterPayloadTypes, setFilterPayloadTypes] = useState<Set<number>>(() => {
const payloads = searchParams.get('payloads');
if (!payloads) return new Set();
return new Set(payloads.split(',').map((urlName) => payloadValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterRouteTypes, setFilterRouteTypes] = useState<Set<number>>(() => {
const routes = searchParams.get('routes');
if (!routes) return new Set();
return new Set(routes.split(',').map((urlName) => routeValueByUrl[urlName]).filter((v) => v !== undefined));
});
// Initialize radio filter from URL params
const [filterRadios, setFilterRadios] = useState<Set<string>>(() => {
const radiosParam = searchParams.get('radios');
if (!radiosParam) return new Set();
return new Set(radiosParam.split(',').map(decodeURIComponent));
});
// Track expanded state for duplicate packets
const [expandedHashes, setExpandedHashes] = useState<Set<string>>(new Set());
// Sync state to URL params (using URL-friendly lowercase names)
useEffect(() => {
const newParams = new URLSearchParams(searchParams);
if (filterNodeTypes.size > 0) {
newParams.set('nodes', Array.from(filterNodeTypes).map((v) => nodeTypeUrlByValue[v]).join(','));
} else {
newParams.delete('nodes');
}
if (filterPayloadTypes.size > 0) {
newParams.set('payloads', Array.from(filterPayloadTypes).map((v) => payloadUrlByValue[v]).join(','));
} else {
newParams.delete('payloads');
}
if (filterRouteTypes.size > 0) {
newParams.set('routes', Array.from(filterRouteTypes).map((v) => routeUrlByValue[v]).join(','));
} else {
newParams.delete('routes');
}
if (filterRadios.size > 0) {
newParams.set('radios', Array.from(filterRadios).map(encodeURIComponent).join(','));
} else {
newParams.delete('radios');
}
setSearchParams(newParams, { replace: true });
}, [filterNodeTypes, filterPayloadTypes, filterRouteTypes, filterRadios, searchParams, setSearchParams]);
// Derive unique values for each filter
const uniqueNodeTypes = useMemo(() => {
const types = new Set(packets.map((p) => p.nodeType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniquePayloadTypes = useMemo(() => {
const types = new Set(packets.map((p) => p.payloadType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniqueRouteTypes = useMemo(() => {
const types = new Set(packets.map((p) => p.routeType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
// Get unique radio names from packets and radios API
const uniqueRadioNames = useMemo(() => {
const namesFromPackets = new Set(packets.map((p) => p.radioName).filter((name): name is string => !!name));
const namesFromAPI = new Set(radios.map((r) => r.name));
// Combine both sources
const allNames = new Set([...namesFromPackets, ...namesFromAPI]);
return Array.from(allNames).sort();
}, [packets, radios]);
// Group packets by hash and filter
const groupedPackets = useMemo(() => {
const groups = new Map<string, MeshCorePacketRecord[]>();
// Filter and group packets
packets.forEach((packet) => {
if (filterNodeTypes.size > 0 && !filterNodeTypes.has(packet.nodeType)) return;
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) return;
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) return;
if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) return;
const existing = groups.get(packet.hash);
if (existing) {
existing.push(packet);
} else {
groups.set(packet.hash, [packet]);
}
});
// Convert to array and sort by most recent timestamp in each group
return Array.from(groups.entries())
.map(([hash, packets]) => ({
hash,
packets: packets.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
mostRecent: packets.reduce((latest, p) =>
p.timestamp > latest.timestamp ? p : latest
),
}))
.sort((a, b) => b.mostRecent.timestamp.getTime() - a.mostRecent.timestamp.getTime());
}, [packets, filterNodeTypes, filterPayloadTypes, filterRouteTypes, filterRadios]);
const handleNodeTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterNodeTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterNodeTypes(newSet);
};
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterPayloadTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterPayloadTypes(newSet);
};
const handleRouteTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterRouteTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRouteTypes(newSet);
};
const handleRadioToggle = (value: string, isChecked: boolean) => {
const newSet = new Set(filterRadios);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRadios(newSet);
};
const toggleExpanded = (hash: string) => {
const newExpanded = new Set(expandedHashes);
if (newExpanded.has(hash)) {
newExpanded.delete(hash);
} else {
newExpanded.add(hash);
}
setExpandedHashes(newExpanded);
};
return (
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<span>MeshCore Packets</span>
<div className="d-flex align-items-center gap-2">
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column">
<div className="meshcore-filters p-2 border-bottom border-secondary-subtle">
<Stack direction="horizontal" gap={2}>
<FilterDropdown
label="Node Type"
options={uniqueNodeTypes}
selectedValues={filterNodeTypes}
getLabelForValue={(v) => nodeTypeDisplayByValue[v] ?? `0x${v.toString(16)}`}
onToggle={handleNodeTypeToggle}
onSelectAll={() => setFilterNodeTypes(new Set())}
/>
<FilterDropdown
label="Payload Type"
options={uniquePayloadTypes}
selectedValues={filterPayloadTypes}
getLabelForValue={(v) => payloadDisplayByValue[v] ?? `0x${v.toString(16)}`}
onToggle={handlePayloadTypeToggle}
onSelectAll={() => setFilterPayloadTypes(new Set())}
/>
<StringFilterDropdown
label="Radio"
options={uniqueRadioNames}
selectedValues={filterRadios}
onToggle={handleRadioToggle}
onSelectAll={() => setFilterRadios(new Set())}
/>
<FilterDropdown
label="Route Type"
options={uniqueRouteTypes}
selectedValues={filterRouteTypes}
getLabelForValue={(v) => routeDisplayByValue[v] ?? `0x${v.toString(16)}`}
onToggle={handleRouteTypeToggle}
onSelectAll={() => setFilterRouteTypes(new Set())}
/>
</Stack>
</div>
<div className="meshcore-table-scroll">
<Table hover responsive className="meshcore-table mb-0" size="sm">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '100px' }}>Time</th>
<th style={{ width: '80px' }}>Hash</th>
<th style={{ width: '50px' }}>Node</th>
<th style={{ width: '100px' }}>Payload</th>
<th>Info</th>
</tr>
</thead>
<tbody>
{groupedPackets.map((group) => {
const isExpanded = expandedHashes.has(group.hash);
const hasDuplicates = group.packets.length > 1;
const packet = group.mostRecent;
return (
<React.Fragment key={group.hash}>
<tr className={`${getPayloadTypeColor(packet.payloadType)} ${selectedHash === packet.hash ? 'is-selected' : ''}`}>
<td>
{hasDuplicates && (
<button
type="button"
className="meshcore-expand-button"
onClick={() => toggleExpanded(group.hash)}
aria-label={isExpanded ? 'Collapse duplicates' : 'Expand duplicates'}
>
{isExpanded ? <ExpandMoreIcon style={{ fontSize: '1rem' }} /> : <ChevronRightIcon style={{ fontSize: '1rem' }} />}
</button>
)}
</td>
<td>
{packet.timestamp.toLocaleTimeString()}
{hasDuplicates && (
<span className="meshcore-duplicate-badge" title={`${group.packets.length} instances`}>
{' '}×{group.packets.length}
</span>
)}
</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(packet)}>
{packet.hash}
</button>
</td>
<td className="meshcore-node-type-cell" title={nodeTypeDisplayByValue[packet.nodeType] ?? `0x${packet.nodeType.toString(16)}`}>
<span className="meshcore-node-type-icon">{getNodeTypeIcon(packet.nodeType)}</span>
</td>
<td>{payloadDisplayByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}</td>
<td>{packet.payloadSummary}</td>
</tr>
{isExpanded && group.packets.map((dupPacket, idx) => (
<tr key={`${group.hash}-${idx}`} className={`meshcore-duplicate-row ${getPayloadTypeColor(dupPacket.payloadType)} ${selectedHash === dupPacket.hash ? 'is-selected' : ''}`}>
<td></td>
<td>{dupPacket.timestamp.toLocaleTimeString()}</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(dupPacket)}>
{dupPacket.hash}
</button>
</td>
<td className="meshcore-node-type-cell" title={nodeTypeDisplayByValue[dupPacket.nodeType] ?? `0x${dupPacket.nodeType.toString(16)}`}>
<span className="meshcore-node-type-icon">{getNodeTypeIcon(dupPacket.nodeType)}</span>
</td>
<td>{payloadDisplayByValue[dupPacket.payloadType] ?? `0x${dupPacket.payloadType.toString(16)}`}</td>
<td>
{dupPacket.payloadSummary}
{dupPacket.radioName && <span className="text-muted ms-2">({dupPacket.radioName})</span>}
</td>
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</Table>
</div>
</Card.Body>
</Card>
);
};
const PacketDetailsPane: React.FC<{ packet: MeshCorePacketRecord | null; streamReady: boolean }> = ({ packet, streamReady }) => {
if (!packet) {
return (
<Card body className="meshcore-detail-card h-100">
<h6>Select a packet</h6>
<div>Click any hash in the table to inspect MeshCore header and payload details.</div>
<div className="mt-2 text-secondary">Stream prepared: {streamReady ? 'yes' : 'no'}</div>
</Card>
);
}
return (
<Stack gap={2} className="h-100 meshcore-detail-stack">
<Card body className="meshcore-detail-card">
<Stack direction="horizontal" gap={2} className="mb-2">
<h6 className="mb-0">Packet Header</h6>
<Badge bg="primary">{payloadDisplayByValue[packet.payloadType] ?? packet.payloadType}</Badge>
</Stack>
<HeaderFact label="Time" value={packet.timestamp.toISOString()} />
<HeaderFact label="Hash" value={<code>{packet.hash}</code>} />
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
<HeaderFact label="Version" value={packet.version} />
<HeaderFact label="Route Type" value={routeDisplayByValue[packet.routeType] ?? packet.routeType} />
<HeaderFact label="Node Type" value={nodeTypeDisplayByValue[packet.nodeType] ?? packet.nodeType} />
<HeaderFact label="Path" value={<code>{asHex(packet.path)}</code>} />
<HeaderFact label="Raw Packet" value={<code>{asHex(packet.raw)}</code>} />
</Card>
<PayloadDetails packet={packet} />
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Stream Preparation</h6>
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>
<div className="text-secondary">Ready: {streamReady ? 'yes' : 'no'}</div>
</Card>
</Stack>
);
};
import MeshCorePacketDetailsPane from './MeshCorePacketDetailsPane';
import MeshCorePacketTable from './MeshCorePacketTable';
const MeshCorePacketsView: React.FC = () => {
const { packets, streamReady } = useMeshCoreData();
@@ -580,9 +18,22 @@ const MeshCorePacketsView: React.FC = () => {
return (
<VerticalSplit
ratio="75/25"
left={<PacketTable packets={packets} selectedHash={selectedHash} onSelect={(packet) => setSelectedHash(packet.hash)} streamReady={streamReady} />}
right={<PacketDetailsPane packet={selectedPacket} streamReady={streamReady} />}
ratio="2:1"
left={(
<MeshCorePacketTable
packets={packets}
selectedHash={selectedHash}
onSelect={(packet) => setSelectedHash(packet.hash)}
onClearSelection={() => setSelectedHash(null)}
streamReady={streamReady}
/>
)}
right={(
<MeshCorePacketDetailsPane
packet={selectedPacket}
streamReady={streamReady}
/>
)}
/>
);
};

View File

@@ -152,7 +152,7 @@ export class Frame implements IFrame {
case '`': // Mic-E current
case "'": // Mic-E old
return this.decodeMicE(dataType);
return this.decodeMicE();
case ':': // Message
return this.decodeMessage();
@@ -448,7 +448,7 @@ export class Frame implements IFrame {
return result;
}
private decodeMicE(dataType: string): DecodedPayload | null {
private decodeMicE(): DecodedPayload | null {
try {
// Mic-E encodes position in both destination address and information field
const dest = this.destination.call;
@@ -585,7 +585,6 @@ export class Frame implements IFrame {
const messageBits: number[] = [];
for (let i = 0; i < 6; i++) {
const char = dest.charAt(i);
const code = dest.charCodeAt(i);
let digit: number;
let msgBit: number;

View File

@@ -64,8 +64,11 @@ export type DataTypeIdentifier = typeof DataTypeIdentifier[keyof typeof DataType
export interface Position {
latitude: number; // Decimal degrees
longitude: number; // Decimal degrees
ambiguity?: number; // Position ambiguity (0-4)
altitude?: number; // Meters
symbol?: {
speed?: number; // Speed in knots/kmh depending on source
course?: number; // Course in degrees
symbol: {
table: string; // Symbol table identifier
code: string; // Symbol code
};
@@ -89,7 +92,10 @@ export interface PositionPayload {
timestamp?: Timestamp;
position: Position;
messaging: boolean; // Whether APRS messaging is enabled
ambiguity?: number; // Position ambiguity (0-4)
micE?: {
messageType?: string;
isStandard?: boolean;
};
}
// Compressed Position Format

View File

@@ -248,7 +248,7 @@ describe('Packet', () => {
expect(decoded.payloadType).toBe(PayloadType.REQUEST);
expect(decoded.dstHash).toBe('ab');
expect(decoded.srcHash).toBe('cd');
expect(decoded.cipherMAC).toBe(0x3412);
expect(decoded.cipherMAC).toEqual(new Uint8Array([0x12, 0x34]));
expect(decoded.cipherText).toEqual(new Uint8Array([0x01, 0x02, 0x03, 0x04]));
});
});
@@ -332,7 +332,7 @@ describe('Packet', () => {
expect(decoded.payloadType).toBe(PayloadType.ADVERT);
expect(decoded.publicKey).toEqual(publicKey);
expect(decoded.signature).toEqual(signature);
expect(decoded.timestamp.getTime()).toBe(timestamp);
expect(decoded.timestamp?.getTime()).toBe(timestamp);
expect(decoded.appdata.flags).toBe(flags);
});

View File

@@ -178,7 +178,7 @@ export class Packet extends BasePacket {
payloadType: kind,
dstHash: buffer.readUint8().toString(16).padStart(2, '0'),
srcHash: buffer.readUint8().toString(16).padStart(2, '0'),
cipherMAC: buffer.readUint16LE(),
cipherMAC: buffer.readBytes(2),
cipherText: buffer.readBytes()
} as T
}
@@ -201,11 +201,14 @@ export class Packet extends BasePacket {
private decodeAdvert(): AdvertPayload {
const buffer = new BufferReader(this.payload);
const publicKey = buffer.readBytes(32);
const timestampValue = buffer.readUint32LE();
const signature = buffer.readBytes(64);
let payload: AdvertPayload = {
payloadType: PayloadType.ADVERT,
publicKey: buffer.readBytes(32),
timestamp: new Date(buffer.readUint32LE()),
signature: buffer.readBytes(64),
publicKey: publicKey,
timestamp: timestampValue === 0 ? undefined : new Date(timestampValue),
signature: signature,
appdata: {
flags: buffer.readUint8(),
}

View File

@@ -197,7 +197,7 @@ export interface AdvertisementAppData {
export interface AdvertPayload {
readonly payloadType: typeof PayloadType.ADVERT;
publicKey: Uint8Array; // 32 bytes Ed25519 public key
timestamp: Date; // Unix timestamp (4 bytes, LE)
timestamp?: Date; // Unix timestamp (4 bytes, LE), undefined if timestamp is 0
signature: Uint8Array; // 64 bytes Ed25519 signature
appdata: AdvertisementAppData;
}

View File

@@ -15,6 +15,7 @@ export interface FetchedAPRSPacket {
comment?: string;
latitude?: number | { Float64?: number; Valid?: boolean } | null;
longitude?: number | { Float64?: number; Valid?: boolean } | null;
symbol?: string;
raw?: string;
payload?: string;
received_at?: string;

View File

@@ -54,6 +54,17 @@ export class MeshCoreServiceImpl {
}));
}
/**
* Fetch all MeshCore packets
* @param limit Maximum number of packets to fetch (default: 200)
* @returns Array of raw packet data
*/
public async fetchPackets(limit = 200): Promise<FetchedGroupPacket[]> {
const endpoint = '/meshcore/packets';
const params = { limit };
return this.api.fetch<FetchedGroupPacket[]>(endpoint, { params });
}
/**
* Fetch GROUP_TEXT packets for a specific channel hash
* @param channelHash The channel hash to fetch packets for

View File

@@ -47,6 +47,8 @@ export class MeshCoreStream extends BaseStream {
decodedPayload = undefined;
}
console.log('parsed packet', parsed, { decodedPayload });
// Extract radio name from topic: meshcore/packet/<base64-encoded-radio-name>
const radioName = this.extractRadioNameFromTopic(topic);

View File

@@ -8,4 +8,5 @@
@import './theme/tags';
@import './theme/forms';
@import './theme/code';
@import './theme/tables';
@import './theme/utilities';

View File

@@ -78,3 +78,15 @@
border-color: var(--app-status-success);
}
}
.btn-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
.icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}

View File

@@ -0,0 +1,73 @@
/* ============================================
TABLE STYLES - Reusable Data Table Theme
============================================ */
.data-table-card {
background: rgba(8, 24, 56, 0.5);
border: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
> .card-body {
background: var(--app-bg-elevated);
}
}
.data-table-header {
background: rgba(27, 56, 108, 0.45);
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
font-weight: 600;
}
.data-table-body {
background: var(--app-bg-elevated);
height: 100%;
min-height: 0;
}
.data-table-scroll {
height: 100%;
max-height: 100%;
overflow-y: auto;
}
.data-table {
color: var(--app-text);
thead th {
position: sticky;
top: 0;
z-index: 2;
background: rgba(13, 36, 82, 0.95);
border-color: rgba(173, 205, 255, 0.18);
color: var(--app-text);
}
td {
border-color: rgba(173, 205, 255, 0.12);
vertical-align: middle;
cursor: pointer;
}
tr.is-selected td {
background: rgba(102, 157, 255, 0.34);
color: #f2f7ff;
}
tr.is-selected a,
tr.is-selected a:hover {
color: var(--app-accent-yellow);
}
tr:hover td {
background: rgba(102, 157, 255, 0.08);
color: var(--app-text);
}
tr:hover .callsign {
color: var(--app-accent-yellow);
background-color: var(--app-blue-dark);
border-color: var(--app-accent-yellow);
}
}

View File

@@ -54,3 +54,26 @@
.border-active {
border-color: var(--app-border-color-active);
}
.callsign {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border: 1px solid var(--app-accent-blue);
border-radius: 999px;
background-color: var(--app-blue-dark);
font-family: var(--bs-font-monospace);
font-weight: 700;
font-size: 0.875rem;
line-height: 1.2;
letter-spacing: 0.015em;
white-space: nowrap;
color: var(--app-accent-yellow);
}
.callsign--plain {
padding: 0;
border: none;
border-radius: 0;
background-color: transparent;
}

View File

@@ -20,7 +20,7 @@ export interface FullProps {
className?: string;
}
export type VerticalSplitRatio = '50/50' | '75/25' | '25/70';
export type VerticalSplitRatio = '1:1' | '3:1' | '2:1' | '25/70';
export interface VerticalSplitProps {
left?: React.ReactNode;

View File

@@ -1,15 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import type { IncomingMessage } from 'http'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://127.0.0.1:8073',
target: 'http://localhost:8073',
changeOrigin: true,
},
router: (req: IncomingMessage) => {
const host = req.headers.host?.split(':')[0] || 'localhost';
return `http://${host}:8073`;
},
} as any,
'/broker': {
target: 'ws://10.42.23.73:8083',
ws: true,