mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-10 18:53:03 +02:00
Add zero-hop impulse advert. Closes #83.
This commit is contained in:
@@ -297,7 +297,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
|
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
|
||||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
|
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
|
||||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||||
| POST | `/api/radio/advertise` | Send advertisement |
|
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||||
|
|||||||
+1
-1
@@ -168,7 +168,7 @@ app/
|
|||||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
|
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
|
||||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise`
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
- `POST /radio/disconnect`
|
- `POST /radio/disconnect`
|
||||||
- `POST /radio/reboot`
|
- `POST /radio/reboot`
|
||||||
|
|||||||
+23
-10
@@ -14,6 +14,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from meshcore import EventType, MeshCore
|
from meshcore import EventType, MeshCore
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
DEFAULT_MAX_CHANNELS = 40
|
DEFAULT_MAX_CHANNELS = 40
|
||||||
|
|
||||||
|
AdvertMode = Literal["flood", "zero_hop"]
|
||||||
|
|
||||||
|
|
||||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||||
"""Return key contact fields for sync failure diagnostics."""
|
"""Return key contact fields for sync failure diagnostics."""
|
||||||
@@ -686,7 +689,12 @@ async def stop_message_polling():
|
|||||||
logger.info("Stopped periodic message polling")
|
logger.info("Stopped periodic message polling")
|
||||||
|
|
||||||
|
|
||||||
async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
async def send_advertisement(
|
||||||
|
mc: MeshCore,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
mode: AdvertMode = "flood",
|
||||||
|
) -> bool:
|
||||||
"""Send an advertisement to announce presence on the mesh.
|
"""Send an advertisement to announce presence on the mesh.
|
||||||
|
|
||||||
Respects the configured advert_interval - won't send if not enough time
|
Respects the configured advert_interval - won't send if not enough time
|
||||||
@@ -695,11 +703,15 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
|||||||
Args:
|
Args:
|
||||||
mc: The MeshCore instance to use for the advertisement.
|
mc: The MeshCore instance to use for the advertisement.
|
||||||
force: If True, send immediately regardless of interval.
|
force: If True, send immediately regardless of interval.
|
||||||
|
mode: Advertisement mode. Flood adverts use the persisted flood-advert
|
||||||
|
throttle state; zero-hop adverts currently send immediately.
|
||||||
|
|
||||||
Returns True if successful, False otherwise (including if throttled).
|
Returns True if successful, False otherwise (including if throttled).
|
||||||
"""
|
"""
|
||||||
# Check if enough time has elapsed (unless forced)
|
use_flood = mode == "flood"
|
||||||
if not force:
|
|
||||||
|
# Only flood adverts currently participate in persisted throttle state.
|
||||||
|
if use_flood and not force:
|
||||||
settings = await AppSettingsRepository.get()
|
settings = await AppSettingsRepository.get()
|
||||||
interval = settings.advert_interval
|
interval = settings.advert_interval
|
||||||
last_time = settings.last_advert_time
|
last_time = settings.last_advert_time
|
||||||
@@ -726,18 +738,19 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await mc.commands.send_advert(flood=True)
|
result = await mc.commands.send_advert(flood=use_flood)
|
||||||
if result.type == EventType.OK:
|
if result.type == EventType.OK:
|
||||||
# Update last_advert_time in database
|
if use_flood:
|
||||||
now = int(time.time())
|
# Track flood advert timing for periodic/startup throttling.
|
||||||
await AppSettingsRepository.update(last_advert_time=now)
|
now = int(time.time())
|
||||||
logger.info("Advertisement sent successfully")
|
await AppSettingsRepository.update(last_advert_time=now)
|
||||||
|
logger.info("%s advertisement sent successfully", mode.replace("_", "-"))
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning("Failed to send advertisement: %s", result.payload)
|
logger.warning("Failed to send %s advertisement: %s", mode, result.payload)
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error sending advertisement: %s", e, exc_info=True)
|
logger.warning("Error sending %s advertisement: %s", mode, e, exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+17
-8
@@ -32,6 +32,7 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||||
|
|
||||||
AdvertLocationSource = Literal["off", "current"]
|
AdvertLocationSource = Literal["off", "current"]
|
||||||
|
RadioAdvertMode = Literal["flood", "zero_hop"]
|
||||||
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
|
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
|
||||||
DISCOVERY_WINDOW_SECONDS = 8.0
|
DISCOVERY_WINDOW_SECONDS = 8.0
|
||||||
_DISCOVERY_TARGET_BITS = {
|
_DISCOVERY_TARGET_BITS = {
|
||||||
@@ -104,6 +105,13 @@ class PrivateKeyUpdate(BaseModel):
|
|||||||
private_key: str = Field(description="Private key as hex string")
|
private_key: str = Field(description="Private key as hex string")
|
||||||
|
|
||||||
|
|
||||||
|
class RadioAdvertiseRequest(BaseModel):
|
||||||
|
mode: RadioAdvertMode = Field(
|
||||||
|
default="flood",
|
||||||
|
description="Advertisement mode: flood through repeaters or zero-hop local only",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _monotonic() -> float:
|
def _monotonic() -> float:
|
||||||
return time.monotonic()
|
return time.monotonic()
|
||||||
|
|
||||||
@@ -266,24 +274,25 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/advertise")
|
@router.post("/advertise")
|
||||||
async def send_advertisement() -> dict:
|
async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> dict:
|
||||||
"""Send a flood advertisement to announce presence on the mesh.
|
"""Send an advertisement to announce presence on the mesh.
|
||||||
|
|
||||||
Manual advertisement requests always send immediately, updating the
|
Manual advertisement requests always send immediately. Flood adverts update
|
||||||
last_advert_time which affects when the next periodic/startup advert
|
the shared flood-advert timing state used by periodic/startup advertising;
|
||||||
can occur.
|
zero-hop adverts currently do not.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
status: "ok" if sent successfully
|
status: "ok" if sent successfully
|
||||||
"""
|
"""
|
||||||
require_connected()
|
require_connected()
|
||||||
|
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
||||||
|
|
||||||
logger.info("Sending flood advertisement")
|
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
||||||
async with radio_manager.radio_operation("manual_advertisement") as mc:
|
async with radio_manager.radio_operation("manual_advertisement") as mc:
|
||||||
success = await do_send_advertisement(mc, force=True)
|
success = await do_send_advertisement(mc, force=True, mode=mode)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=500, detail="Failed to send advertisement")
|
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ High-level state is delegated to hooks:
|
|||||||
|
|
||||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||||
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
||||||
|
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
|
||||||
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
|
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
|
||||||
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
|
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -15,6 +15,7 @@ import type {
|
|||||||
MessagesAroundResponse,
|
MessagesAroundResponse,
|
||||||
MigratePreferencesRequest,
|
MigratePreferencesRequest,
|
||||||
MigratePreferencesResponse,
|
MigratePreferencesResponse,
|
||||||
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
@@ -95,9 +96,10 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ private_key: privateKey }),
|
body: JSON.stringify({ private_key: privateKey }),
|
||||||
}),
|
}),
|
||||||
sendAdvertisement: () =>
|
sendAdvertisement: (mode: RadioAdvertMode = 'flood') =>
|
||||||
fetchJson<{ status: string }>('/radio/advertise', {
|
fetchJson<{ status: string }>('/radio/advertise', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ mode }),
|
||||||
}),
|
}),
|
||||||
discoverMesh: (target: RadioDiscoveryTarget) =>
|
discoverMesh: (target: RadioDiscoveryTarget) =>
|
||||||
fetchJson<RadioDiscoveryResponse>('/radio/discover', {
|
fetchJson<RadioDiscoveryResponse>('/radio/discover', {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
@@ -35,7 +36,7 @@ interface SettingsModalBaseProps {
|
|||||||
onReboot: () => Promise<void>;
|
onReboot: () => Promise<void>;
|
||||||
onDisconnect: () => Promise<void>;
|
onDisconnect: () => Promise<void>;
|
||||||
onReconnect: () => Promise<void>;
|
onReconnect: () => Promise<void>;
|
||||||
onAdvertise: () => Promise<void>;
|
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
|
||||||
meshDiscovery: RadioDiscoveryResponse | null;
|
meshDiscovery: RadioDiscoveryResponse | null;
|
||||||
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
||||||
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
@@ -45,7 +46,7 @@ export function SettingsRadioSection({
|
|||||||
onReboot: () => Promise<void>;
|
onReboot: () => Promise<void>;
|
||||||
onDisconnect: () => Promise<void>;
|
onDisconnect: () => Promise<void>;
|
||||||
onReconnect: () => Promise<void>;
|
onReconnect: () => Promise<void>;
|
||||||
onAdvertise: () => Promise<void>;
|
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
|
||||||
meshDiscovery: RadioDiscoveryResponse | null;
|
meshDiscovery: RadioDiscoveryResponse | null;
|
||||||
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
||||||
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||||
@@ -82,7 +83,7 @@ export function SettingsRadioSection({
|
|||||||
const [floodError, setFloodError] = useState<string | null>(null);
|
const [floodError, setFloodError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Advertise state
|
// Advertise state
|
||||||
const [advertising, setAdvertising] = useState(false);
|
const [advertisingMode, setAdvertisingMode] = useState<RadioAdvertMode | null>(null);
|
||||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||||
const [connectionBusy, setConnectionBusy] = useState(false);
|
const [connectionBusy, setConnectionBusy] = useState(false);
|
||||||
|
|
||||||
@@ -295,12 +296,12 @@ export function SettingsRadioSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdvertise = async () => {
|
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
||||||
setAdvertising(true);
|
setAdvertisingMode(mode);
|
||||||
try {
|
try {
|
||||||
await onAdvertise();
|
await onAdvertise(mode);
|
||||||
} finally {
|
} finally {
|
||||||
setAdvertising(false);
|
setAdvertisingMode(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -742,15 +743,25 @@ export function SettingsRadioSection({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Send Advertisement</Label>
|
<Label>Send Advertisement</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
<Button
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
onClick={handleAdvertise}
|
<Button
|
||||||
disabled={advertising || !health?.radio_connected}
|
onClick={() => handleAdvertise('flood')}
|
||||||
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
|
disabled={advertisingMode !== null || !health?.radio_connected}
|
||||||
>
|
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
|
||||||
{advertising ? 'Sending...' : 'Send Advertisement'}
|
>
|
||||||
</Button>
|
{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 && (
|
{!health?.radio_connected && (
|
||||||
<p className="text-sm text-destructive">Radio not connected</p>
|
<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 { toast } from '../components/ui/sonner';
|
||||||
import type {
|
import type {
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
@@ -93,13 +94,14 @@ export function useRadioControl() {
|
|||||||
}
|
}
|
||||||
}, [fetchConfig]);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
const handleAdvertise = useCallback(async () => {
|
const handleAdvertise = useCallback(async (mode: RadioAdvertMode = 'flood') => {
|
||||||
try {
|
try {
|
||||||
await api.sendAdvertisement();
|
await api.sendAdvertisement(mode);
|
||||||
toast.success('Advertisement sent');
|
toast.success(mode === 'zero_hop' ? 'Zero-hop advertisement sent' : 'Advertisement sent');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to send advertisement:', err);
|
const label = mode === 'zero_hop' ? 'zero-hop advertisement' : 'advertisement';
|
||||||
toast.error('Failed to send advertisement', {
|
console.error(`Failed to send ${label}:`, err);
|
||||||
|
toast.error(`Failed to send ${label}`, {
|
||||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ describe('fetchJson (via api methods)', () => {
|
|||||||
expect(options.method).toBe('DELETE');
|
expect(options.method).toBe('DELETE');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends POST without body for sendAdvertisement', async () => {
|
it('sends POST with flood mode for sendAdvertisement', async () => {
|
||||||
installMockFetch();
|
installMockFetch();
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -317,7 +317,22 @@ describe('fetchJson (via api methods)', () => {
|
|||||||
const [url, options] = mockFetch.mock.calls[0];
|
const [url, options] = mockFetch.mock.calls[0];
|
||||||
expect(url).toBe('/api/radio/advertise');
|
expect(url).toBe('/api/radio/advertise');
|
||||||
expect(options.method).toBe('POST');
|
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,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
@@ -74,6 +75,7 @@ function renderModal(overrides?: {
|
|||||||
onReboot?: () => Promise<void>;
|
onReboot?: () => Promise<void>;
|
||||||
onDisconnect?: () => Promise<void>;
|
onDisconnect?: () => Promise<void>;
|
||||||
onReconnect?: () => Promise<void>;
|
onReconnect?: () => Promise<void>;
|
||||||
|
onAdvertise?: (mode: RadioAdvertMode) => Promise<void>;
|
||||||
meshDiscovery?: RadioDiscoveryResponse | null;
|
meshDiscovery?: RadioDiscoveryResponse | null;
|
||||||
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
||||||
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||||
@@ -93,6 +95,7 @@ function renderModal(overrides?: {
|
|||||||
const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
|
const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
|
||||||
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
|
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
|
||||||
const onReconnect = overrides?.onReconnect ?? 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 onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {});
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
@@ -108,7 +111,7 @@ function renderModal(overrides?: {
|
|||||||
onReboot,
|
onReboot,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
onAdvertise: vi.fn(async () => {}),
|
onAdvertise,
|
||||||
meshDiscovery: overrides?.meshDiscovery ?? null,
|
meshDiscovery: overrides?.meshDiscovery ?? null,
|
||||||
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
|
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
|
||||||
onDiscoverMesh,
|
onDiscoverMesh,
|
||||||
@@ -135,6 +138,7 @@ function renderModal(overrides?: {
|
|||||||
onReboot,
|
onReboot,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
|
onAdvertise,
|
||||||
onDiscoverMesh,
|
onDiscoverMesh,
|
||||||
view,
|
view,
|
||||||
};
|
};
|
||||||
@@ -206,6 +210,22 @@ describe('SettingsModal', () => {
|
|||||||
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
|
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', () => {
|
it('shows radio-unavailable message when config is null', () => {
|
||||||
renderModal({ config: null });
|
renderModal({ config: null });
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface RadioDiscoveryResponse {
|
|||||||
results: RadioDiscoveryResult[];
|
results: RadioDiscoveryResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RadioAdvertMode = 'flood' | 'zero_hop';
|
||||||
|
|
||||||
export interface FanoutStatusEntry {
|
export interface FanoutStatusEntry {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models import Contact
|
|||||||
from app.radio import RadioManager, radio_manager
|
from app.radio import RadioManager, radio_manager
|
||||||
from app.routers.radio import (
|
from app.routers.radio import (
|
||||||
PrivateKeyUpdate,
|
PrivateKeyUpdate,
|
||||||
|
RadioAdvertiseRequest,
|
||||||
RadioConfigResponse,
|
RadioConfigResponse,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryRequest,
|
RadioDiscoveryRequest,
|
||||||
@@ -598,14 +599,51 @@ class TestAdvertise:
|
|||||||
|
|
||||||
assert exc.value.status_code == 500
|
assert exc.value.status_code == 500
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_defaults_to_flood_mode(self):
|
||||||
|
radio_manager._meshcore = MagicMock()
|
||||||
|
with (
|
||||||
|
patch("app.routers.radio.require_connected"),
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.do_send_advertisement",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
|
) as mock_send,
|
||||||
|
):
|
||||||
|
result = await send_advertisement()
|
||||||
|
|
||||||
|
assert result == {"status": "ok"}
|
||||||
|
mock_send.assert_awaited_once()
|
||||||
|
assert mock_send.await_args.kwargs["force"] is True
|
||||||
|
assert mock_send.await_args.kwargs["mode"] == "flood"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accepts_zero_hop_mode(self):
|
||||||
|
radio_manager._meshcore = MagicMock()
|
||||||
|
with (
|
||||||
|
patch("app.routers.radio.require_connected"),
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.do_send_advertisement",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
|
) as mock_send,
|
||||||
|
):
|
||||||
|
result = await send_advertisement(RadioAdvertiseRequest(mode="zero_hop"))
|
||||||
|
|
||||||
|
assert result == {"status": "ok"}
|
||||||
|
mock_send.assert_awaited_once()
|
||||||
|
assert mock_send.await_args.kwargs["force"] is True
|
||||||
|
assert mock_send.await_args.kwargs["mode"] == "zero_hop"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_concurrent_advertise_calls_are_serialized(self):
|
async def test_concurrent_advertise_calls_are_serialized(self):
|
||||||
active = 0
|
active = 0
|
||||||
max_active = 0
|
max_active = 0
|
||||||
|
|
||||||
async def fake_send(mc, *, force: bool):
|
async def fake_send(mc, *, force: bool, mode: str):
|
||||||
nonlocal active, max_active
|
nonlocal active, max_active
|
||||||
assert force is True
|
assert force is True
|
||||||
|
assert mode == "flood"
|
||||||
active += 1
|
active += 1
|
||||||
max_active = max(max_active, active)
|
max_active = max(max_active, active)
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|||||||
Reference in New Issue
Block a user