mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-01 23:41:27 +02:00
Rework more coverage in e2e tests and don't force radio restart + better startup error handling
This commit is contained in:
@@ -48,6 +48,40 @@ class Settings(BaseSettings):
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class _RepeatSquelch(logging.Filter):
|
||||
"""Suppress rapid-fire identical messages and emit a summary instead.
|
||||
|
||||
Attached to the ``meshcore`` library logger to catch its repeated
|
||||
"Serial Connection started" lines that flood the log when another
|
||||
process holds the serial port.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: int = 3) -> None:
|
||||
super().__init__()
|
||||
self._last_msg: str | None = None
|
||||
self._repeat_count: int = 0
|
||||
self._threshold = threshold
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
if msg == self._last_msg:
|
||||
self._repeat_count += 1
|
||||
if self._repeat_count == self._threshold:
|
||||
record.msg = (
|
||||
"%s (repeated %d times — possible serial port contention from another process)"
|
||||
)
|
||||
record.args = (msg, self._repeat_count)
|
||||
record.levelno = logging.WARNING
|
||||
record.levelname = "WARNING"
|
||||
return True
|
||||
# Suppress further repeats beyond the threshold
|
||||
return self._repeat_count < self._threshold
|
||||
else:
|
||||
self._last_msg = msg
|
||||
self._repeat_count = 1
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure logging for the application."""
|
||||
logging.basicConfig(
|
||||
@@ -55,3 +89,6 @@ def setup_logging() -> None:
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
|
||||
|
||||
+21
-2
@@ -470,6 +470,8 @@ class RadioManager:
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 5
|
||||
UNRESPONSIVE_THRESHOLD = 3
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -483,6 +485,7 @@ class RadioManager:
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, self._connection_info)
|
||||
self._last_connected = False
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
if not current_connected:
|
||||
# Attempt reconnection on every loop while disconnected
|
||||
@@ -492,6 +495,7 @@ class RadioManager:
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif not self._last_connected and current_connected:
|
||||
# Connection restored (might have reconnected automatically).
|
||||
@@ -500,19 +504,34 @@ class RadioManager:
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif current_connected and not self._setup_complete:
|
||||
# Transport connected but setup incomplete — retry
|
||||
logger.info("Retrying post-connect setup...")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Task is being cancelled, exit cleanly
|
||||
break
|
||||
except Exception as e:
|
||||
# Log error but continue monitoring - don't let the monitor die
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
consecutive_setup_failures += 1
|
||||
if consecutive_setup_failures == UNRESPONSIVE_THRESHOLD:
|
||||
logger.error(
|
||||
"Post-connect setup has failed %d times in a row. "
|
||||
"The radio port appears open but the radio is not "
|
||||
"responding to commands. Common causes: another "
|
||||
"process has the serial port open (check for other "
|
||||
"RemoteTerm instances, serial monitors, etc.), the "
|
||||
"firmware is in repeater mode (not client), or the "
|
||||
"radio needs a power cycle. Will keep retrying.",
|
||||
consecutive_setup_failures,
|
||||
)
|
||||
elif consecutive_setup_failures < UNRESPONSIVE_THRESHOLD:
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
# After the threshold, silently retry (avoid log spam)
|
||||
|
||||
self._reconnect_task = asyncio.create_task(monitor_loop())
|
||||
logger.info("Radio connection monitor started")
|
||||
|
||||
+22
-2
@@ -117,7 +117,16 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.error("Failed to get contacts from radio: %s", result)
|
||||
logger.error(
|
||||
"Failed to get contacts from radio: %s. "
|
||||
"If you see this repeatedly, the radio may be visible on the "
|
||||
"serial/TCP/BLE port but not responding to commands. Check for "
|
||||
"another process with the serial port open (other RemoteTerm "
|
||||
"instances, serial monitors, etc.), verify the firmware is "
|
||||
"up-to-date and in client mode (not repeater), or try a "
|
||||
"power cycle.",
|
||||
result,
|
||||
)
|
||||
return {"synced": 0, "removed": 0, "error": str(result)}
|
||||
|
||||
contacts = result.payload or {}
|
||||
@@ -662,8 +671,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
|
||||
else:
|
||||
failed += 1
|
||||
reason = result.payload
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to load contact %s: %s", contact.public_key[:12], result.payload
|
||||
"Failed to load contact %s: %s%s",
|
||||
contact.public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
|
||||
@@ -141,9 +141,7 @@ export function SettingsRadioSection({
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
|
||||
const buildUpdate = (): RadioConfigUpdate | null => {
|
||||
const parsedLat = parseFloat(lat);
|
||||
const parsedLon = parseFloat(lon);
|
||||
const parsedTxPower = parseInt(txPower, 10);
|
||||
@@ -158,24 +156,46 @@ export function SettingsRadioSection({
|
||||
)
|
||||
) {
|
||||
setError('All numeric fields must have valid values');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
return {
|
||||
name,
|
||||
lat: parsedLat,
|
||||
lon: parsedLon,
|
||||
tx_power: parsedTxPower,
|
||||
radio: {
|
||||
freq: parsedFreq,
|
||||
bw: parsedBw,
|
||||
sf: parsedSf,
|
||||
cr: parsedCr,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
const update = buildUpdate();
|
||||
if (!update) return;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSave(update);
|
||||
toast.success('Radio config saved');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndReboot = async () => {
|
||||
setError(null);
|
||||
const update = buildUpdate();
|
||||
if (!update) return;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const update: RadioConfigUpdate = {
|
||||
name,
|
||||
lat: parsedLat,
|
||||
lon: parsedLon,
|
||||
tx_power: parsedTxPower,
|
||||
radio: {
|
||||
freq: parsedFreq,
|
||||
bw: parsedBw,
|
||||
sf: parsedSf,
|
||||
cr: parsedCr,
|
||||
},
|
||||
};
|
||||
await onSave(update);
|
||||
toast.success('Radio config saved, rebooting...');
|
||||
setRebooting(true);
|
||||
@@ -413,9 +433,22 @@ export function SettingsRadioSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
|
||||
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={busy || rebooting}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{busy && !rebooting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={handleSaveAndReboot} disabled={busy || rebooting} className="flex-1">
|
||||
{rebooting ? 'Rebooting...' : 'Save & Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Some settings may require a reboot to take effect on some radios.
|
||||
</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ describe('SettingsModal', () => {
|
||||
});
|
||||
openRadioSection();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save & Reboot' }));
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onReboot).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
* When a @mesh-traffic-tagged test fails, an advisory annotation is added
|
||||
* to the HTML report and a console message is printed, letting the user
|
||||
* know the failure may be due to low mesh traffic rather than a real bug.
|
||||
*
|
||||
* Call `await nudgeEchoBot()` at the start of any @mesh-traffic test to
|
||||
* send a trigger message to an echo bot on #flightless. If the bot is in
|
||||
* radio range it will generate an incoming packet, potentially saving the
|
||||
* full 3-minute wait. The nudge is best-effort — tests still rely on the
|
||||
* long polling timeout for environments without the bot.
|
||||
*/
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { ensureFlightlessChannel, sendChannelMessage } from './api';
|
||||
|
||||
export { expect };
|
||||
|
||||
@@ -18,6 +25,21 @@ const TRAFFIC_ADVISORY =
|
||||
'This test depends on receiving messages from other nodes on the mesh ' +
|
||||
'network. Failure may indicate insufficient mesh traffic rather than a bug.';
|
||||
|
||||
/**
|
||||
* Best-effort: send a message to #flightless that triggers a remote echo
|
||||
* bot. If the bot is within radio range it will reply, generating the
|
||||
* incoming traffic the test needs. Failures are silently ignored — the
|
||||
* test will fall back to waiting for organic mesh traffic.
|
||||
*/
|
||||
export async function nudgeEchoBot(): Promise<void> {
|
||||
try {
|
||||
const channel = await ensureFlightlessChannel();
|
||||
await sendChannelMessage(channel.key, '!echo please give incoming message');
|
||||
} catch {
|
||||
// Best-effort — bot may not be reachable
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<{ _meshTrafficAdvisory: void }>({
|
||||
_meshTrafficAdvisory: [
|
||||
async ({}, use, testInfo) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../helpers/meshTrafficTest';
|
||||
import { test, expect, nudgeEchoBot } from '../helpers/meshTrafficTest';
|
||||
import { createChannel, getChannels, getMessages } from '../helpers/api';
|
||||
|
||||
/**
|
||||
@@ -55,6 +55,9 @@ test.describe('Incoming mesh messages', () => {
|
||||
});
|
||||
|
||||
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
||||
await nudgeEchoBot();
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
@@ -103,6 +106,9 @@ test.describe('Incoming mesh messages', () => {
|
||||
});
|
||||
|
||||
test('incoming message with path shows hop badge and path modal', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
||||
await nudgeEchoBot();
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../helpers/meshTrafficTest';
|
||||
import { test, expect, nudgeEchoBot } from '../helpers/meshTrafficTest';
|
||||
|
||||
test.describe('Packet Feed page', () => {
|
||||
test('packet feed page loads and shows header', async ({ page }) => {
|
||||
@@ -11,6 +11,9 @@ test.describe('Packet Feed page', () => {
|
||||
// This test waits for real RF traffic — needs 180s timeout
|
||||
test.setTimeout(180_000);
|
||||
|
||||
// Nudge echo bot on #flightless — may generate a packet quickly
|
||||
await nudgeEchoBot();
|
||||
|
||||
await page.goto('/#raw');
|
||||
await expect(page.getByText('Raw Packet Feed')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import http from 'http';
|
||||
import {
|
||||
createFanoutConfig,
|
||||
deleteFanoutConfig,
|
||||
ensureFlightlessChannel,
|
||||
sendChannelMessage,
|
||||
} from '../helpers/api';
|
||||
|
||||
/**
|
||||
* Spin up a local HTTP server that captures incoming webhook requests.
|
||||
* Returns the server, its URL, and a promise-based helper to wait for
|
||||
* the next request body.
|
||||
*/
|
||||
function createWebhookReceiver() {
|
||||
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
|
||||
let resolve: (() => void) | null = null;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
requests.push({ body, headers: req.headers });
|
||||
resolve?.();
|
||||
resolve = null;
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
requests,
|
||||
/** Wait until at least `count` requests have been received. */
|
||||
waitForRequests(count: number, timeoutMs = 30_000): Promise<void> {
|
||||
if (requests.length >= count) return Promise.resolve();
|
||||
return new Promise<void>((res, rej) => {
|
||||
const timer = setTimeout(
|
||||
() => rej(new Error(`Timed out waiting for ${count} webhook request(s), got ${requests.length}`)),
|
||||
timeoutMs
|
||||
);
|
||||
const check = () => {
|
||||
if (requests.length >= count) {
|
||||
clearTimeout(timer);
|
||||
res();
|
||||
} else {
|
||||
resolve = check;
|
||||
}
|
||||
};
|
||||
resolve = check;
|
||||
});
|
||||
},
|
||||
/** Start listening on a random port and return the URL. */
|
||||
async listen(): Promise<string> {
|
||||
return new Promise((res) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (typeof addr === 'object' && addr) {
|
||||
res(`http://127.0.0.1:${addr.port}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Webhook delivery', () => {
|
||||
let webhookId: string | null = null;
|
||||
let receiver: ReturnType<typeof createWebhookReceiver>;
|
||||
let webhookUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureFlightlessChannel();
|
||||
receiver = createWebhookReceiver();
|
||||
webhookUrl = await receiver.listen();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
receiver.server.close();
|
||||
if (webhookId) {
|
||||
try {
|
||||
await deleteFanoutConfig(webhookId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('webhook receives message payload when a channel message is sent', async () => {
|
||||
// Create an enabled webhook pointing at our local receiver
|
||||
const webhook = await createFanoutConfig({
|
||||
type: 'webhook',
|
||||
name: 'E2E Delivery Test',
|
||||
config: { url: webhookUrl, method: 'POST', headers: {} },
|
||||
enabled: true,
|
||||
});
|
||||
webhookId = webhook.id;
|
||||
|
||||
// Send a message via API — this triggers broadcast_event → fanout → webhook
|
||||
const channel = await ensureFlightlessChannel();
|
||||
const testText = `webhook-delivery-${Date.now()}`;
|
||||
await sendChannelMessage(channel.key, testText);
|
||||
|
||||
// Wait for the webhook to receive the request
|
||||
await receiver.waitForRequests(1);
|
||||
|
||||
const req = receiver.requests[0];
|
||||
expect(req.headers['content-type']).toBe('application/json');
|
||||
expect(req.headers['x-webhook-event']).toBe('message');
|
||||
|
||||
const payload = JSON.parse(req.body);
|
||||
expect(payload.text).toContain(testText);
|
||||
expect(payload.type).toBe('CHAN');
|
||||
expect(payload.conversation_key).toBe(channel.key);
|
||||
});
|
||||
|
||||
test('webhook respects HMAC signing when configured', async () => {
|
||||
// Clean up previous webhook
|
||||
if (webhookId) {
|
||||
await deleteFanoutConfig(webhookId);
|
||||
}
|
||||
|
||||
const hmacSecret = 'e2e-test-secret';
|
||||
const webhook = await createFanoutConfig({
|
||||
type: 'webhook',
|
||||
name: 'E2E HMAC Test',
|
||||
config: {
|
||||
url: webhookUrl,
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
hmac_secret: hmacSecret,
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
webhookId = webhook.id;
|
||||
|
||||
// Clear previous requests
|
||||
const baselineCount = receiver.requests.length;
|
||||
|
||||
const channel = await ensureFlightlessChannel();
|
||||
const testText = `hmac-test-${Date.now()}`;
|
||||
await sendChannelMessage(channel.key, testText);
|
||||
|
||||
await receiver.waitForRequests(baselineCount + 1);
|
||||
|
||||
const req = receiver.requests[baselineCount];
|
||||
const signature = req.headers['x-webhook-signature'];
|
||||
expect(signature).toBeDefined();
|
||||
expect(typeof signature).toBe('string');
|
||||
expect((signature as string).startsWith('sha256=')).toBe(true);
|
||||
|
||||
// Verify the HMAC is valid
|
||||
const crypto = await import('crypto');
|
||||
const expectedSig = crypto
|
||||
.createHmac('sha256', hmacSecret)
|
||||
.update(req.body)
|
||||
.digest('hex');
|
||||
expect(signature).toBe(`sha256=${expectedSig}`);
|
||||
});
|
||||
});
|
||||
@@ -115,10 +115,9 @@ test.describe('Webhook integration settings', () => {
|
||||
const row = page.getByText('Scope Webhook').locator('..');
|
||||
await row.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
// Verify scope selector is visible with all four modes
|
||||
// Verify scope selector is visible with the three webhook-applicable modes
|
||||
await expect(page.getByText('Message Scope')).toBeVisible();
|
||||
await expect(page.getByText('All messages')).toBeVisible();
|
||||
await expect(page.getByText('No messages')).toBeVisible();
|
||||
await expect(page.getByText('Only listed channels/contacts')).toBeVisible();
|
||||
await expect(page.getByText('All except listed channels/contacts')).toBeVisible();
|
||||
|
||||
|
||||
@@ -23,16 +23,17 @@ test.describe('Radio settings', () => {
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(testName);
|
||||
|
||||
await page.getByRole('button', { name: 'Save Radio Config & Reboot' }).click();
|
||||
await expect(page.getByText('Radio config saved, rebooting...')).toBeVisible({ timeout: 10_000 });
|
||||
// Use "Save" (no reboot) — name changes apply immediately
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Radio config saved')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// --- Step 2: Verify via API (send_appstart refreshes cached info) ---
|
||||
const config = await getRadioConfig();
|
||||
expect(config.name).toBe(testName);
|
||||
|
||||
// Exit settings page mode
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
|
||||
// --- Step 2: Verify via API (now returns fresh data after send_appstart fix) ---
|
||||
const config = await getRadioConfig();
|
||||
expect(config.name).toBe(testName);
|
||||
|
||||
// --- Step 3: Verify persistence across page reload ---
|
||||
await page.reload();
|
||||
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
Reference in New Issue
Block a user