mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-02 06:53:09 +02:00
Add zero-hop impulse advert. Closes #83.
This commit is contained in:
+3
-1
@@ -15,6 +15,7 @@ import type {
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
@@ -95,9 +96,10 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ private_key: privateKey }),
|
||||
}),
|
||||
sendAdvertisement: () =>
|
||||
sendAdvertisement: (mode: RadioAdvertMode = 'flood') =>
|
||||
fetchJson<{ status: string }>('/radio/advertise', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
discoverMesh: (target: RadioDiscoveryTarget) =>
|
||||
fetchJson<RadioDiscoveryResponse>('/radio/discover', {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
@@ -35,7 +36,7 @@ interface SettingsModalBaseProps {
|
||||
onReboot: () => Promise<void>;
|
||||
onDisconnect: () => Promise<void>;
|
||||
onReconnect: () => Promise<void>;
|
||||
onAdvertise: () => Promise<void>;
|
||||
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
|
||||
meshDiscovery: RadioDiscoveryResponse | null;
|
||||
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
||||
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
@@ -45,7 +46,7 @@ export function SettingsRadioSection({
|
||||
onReboot: () => Promise<void>;
|
||||
onDisconnect: () => Promise<void>;
|
||||
onReconnect: () => Promise<void>;
|
||||
onAdvertise: () => Promise<void>;
|
||||
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
|
||||
meshDiscovery: RadioDiscoveryResponse | null;
|
||||
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
||||
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||
@@ -82,7 +83,7 @@ export function SettingsRadioSection({
|
||||
const [floodError, setFloodError] = useState<string | null>(null);
|
||||
|
||||
// Advertise state
|
||||
const [advertising, setAdvertising] = useState(false);
|
||||
const [advertisingMode, setAdvertisingMode] = useState<RadioAdvertMode | null>(null);
|
||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||
const [connectionBusy, setConnectionBusy] = useState(false);
|
||||
|
||||
@@ -295,12 +296,12 @@ export function SettingsRadioSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvertise = async () => {
|
||||
setAdvertising(true);
|
||||
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
||||
setAdvertisingMode(mode);
|
||||
try {
|
||||
await onAdvertise();
|
||||
await onAdvertise(mode);
|
||||
} finally {
|
||||
setAdvertising(false);
|
||||
setAdvertisingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -742,15 +743,25 @@ export function SettingsRadioSection({
|
||||
<div className="space-y-2">
|
||||
<Label>Send Advertisement</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a flood advertisement to announce your presence on the mesh network.
|
||||
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
|
||||
airtime.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleAdvertise}
|
||||
disabled={advertising || !health?.radio_connected}
|
||||
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
|
||||
>
|
||||
{advertising ? 'Sending...' : 'Send Advertisement'}
|
||||
</Button>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
onClick={() => handleAdvertise('flood')}
|
||||
disabled={advertisingMode !== null || !health?.radio_connected}
|
||||
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
|
||||
>
|
||||
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleAdvertise('zero_hop')}
|
||||
disabled={advertisingMode !== null || !health?.radio_connected}
|
||||
className="w-full"
|
||||
>
|
||||
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
|
||||
</Button>
|
||||
</div>
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type {
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
@@ -93,13 +94,14 @@ export function useRadioControl() {
|
||||
}
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleAdvertise = useCallback(async () => {
|
||||
const handleAdvertise = useCallback(async (mode: RadioAdvertMode = 'flood') => {
|
||||
try {
|
||||
await api.sendAdvertisement();
|
||||
toast.success('Advertisement sent');
|
||||
await api.sendAdvertisement(mode);
|
||||
toast.success(mode === 'zero_hop' ? 'Zero-hop advertisement sent' : 'Advertisement sent');
|
||||
} catch (err) {
|
||||
console.error('Failed to send advertisement:', err);
|
||||
toast.error('Failed to send advertisement', {
|
||||
const label = mode === 'zero_hop' ? 'zero-hop advertisement' : 'advertisement';
|
||||
console.error(`Failed to send ${label}:`, err);
|
||||
toast.error(`Failed to send ${label}`, {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
expect(options.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('sends POST without body for sendAdvertisement', async () => {
|
||||
it('sends POST with flood mode for sendAdvertisement', async () => {
|
||||
installMockFetch();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -317,7 +317,22 @@ describe('fetchJson (via api methods)', () => {
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBeUndefined();
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
|
||||
});
|
||||
|
||||
it('sends POST with zero-hop mode for sendAdvertisement', async () => {
|
||||
installMockFetch();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'ok' }),
|
||||
});
|
||||
|
||||
await api.sendAdvertisement('zero_hop');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
@@ -74,6 +75,7 @@ function renderModal(overrides?: {
|
||||
onReboot?: () => Promise<void>;
|
||||
onDisconnect?: () => Promise<void>;
|
||||
onReconnect?: () => Promise<void>;
|
||||
onAdvertise?: (mode: RadioAdvertMode) => Promise<void>;
|
||||
meshDiscovery?: RadioDiscoveryResponse | null;
|
||||
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
||||
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||
@@ -93,6 +95,7 @@ function renderModal(overrides?: {
|
||||
const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
|
||||
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
|
||||
const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {});
|
||||
const onAdvertise = overrides?.onAdvertise ?? vi.fn(async (_mode: RadioAdvertMode) => {});
|
||||
const onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {});
|
||||
|
||||
const commonProps = {
|
||||
@@ -108,7 +111,7 @@ function renderModal(overrides?: {
|
||||
onReboot,
|
||||
onDisconnect,
|
||||
onReconnect,
|
||||
onAdvertise: vi.fn(async () => {}),
|
||||
onAdvertise,
|
||||
meshDiscovery: overrides?.meshDiscovery ?? null,
|
||||
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
|
||||
onDiscoverMesh,
|
||||
@@ -135,6 +138,7 @@ function renderModal(overrides?: {
|
||||
onReboot,
|
||||
onDisconnect,
|
||||
onReconnect,
|
||||
onAdvertise,
|
||||
onDiscoverMesh,
|
||||
view,
|
||||
};
|
||||
@@ -206,6 +210,22 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders flood and zero-hop advert buttons and passes the selected mode', async () => {
|
||||
const onAdvertise = vi.fn(async (_mode: RadioAdvertMode) => {});
|
||||
renderModal({ onAdvertise });
|
||||
openRadioSection();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Flood Advertisement' }));
|
||||
await waitFor(() => {
|
||||
expect(onAdvertise).toHaveBeenCalledWith('flood');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Zero-Hop Advertisement' }));
|
||||
await waitFor(() => {
|
||||
expect(onAdvertise).toHaveBeenCalledWith('zero_hop');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows radio-unavailable message when config is null', () => {
|
||||
renderModal({ config: null });
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface RadioDiscoveryResponse {
|
||||
results: RadioDiscoveryResult[];
|
||||
}
|
||||
|
||||
export type RadioAdvertMode = 'flood' | 'zero_hop';
|
||||
|
||||
export interface FanoutStatusEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
|
||||
Reference in New Issue
Block a user