Add radio model and stats display. Closes #64

This commit is contained in:
Jack Kingsman
2026-03-16 15:29:21 -07:00
parent 58b34a6a2f
commit ea5ba3b2a3
43 changed files with 337 additions and 22 deletions
+10
View File
@@ -131,6 +131,11 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self.device_info_loaded: bool = False
self.max_contacts: int | None = None
self.device_model: str | None = None
self.firmware_build: str | None = None
self.firmware_version: str | None = None
self.max_channels: int = 40
self.path_hash_mode: int = 0
self.path_hash_mode_supported: bool = False
@@ -488,6 +493,11 @@ class RadioManager:
await self._disable_meshcore_auto_reconnect(mc)
self._meshcore = None
self._setup_complete = False
self.device_info_loaded = False
self.max_contacts = None
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
+30 -10
View File
@@ -11,18 +11,34 @@ from app.services.radio_runtime import radio_runtime as radio_manager
router = APIRouter(tags=["health"])
class RadioDeviceInfoResponse(BaseModel):
model: str | None = None
firmware_build: str | None = None
firmware_version: str | None = None
max_contacts: int | None = None
max_channels: int | None = None
class HealthResponse(BaseModel):
status: str
radio_connected: bool
radio_initializing: bool = False
radio_state: str = "disconnected"
connection_info: str | None
radio_device_info: RadioDeviceInfoResponse | None = None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False
def _clean_optional_str(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
db_size_mb = 0.0
@@ -48,22 +64,12 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
pass
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
if not isinstance(setup_in_progress, bool):
setup_in_progress = False
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
if not isinstance(setup_complete, bool):
setup_complete = radio_connected
if not radio_connected:
setup_complete = False
connection_desired = getattr(radio_manager, "connection_desired", True)
if not isinstance(connection_desired, bool):
connection_desired = True
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
if not isinstance(is_reconnecting, bool):
is_reconnecting = False
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
if not connection_desired:
@@ -77,12 +83,26 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
else:
radio_state = "disconnected"
radio_device_info = None
device_info_loaded = getattr(radio_manager, "device_info_loaded", False)
if radio_connected and device_info_loaded:
radio_device_info = {
"model": _clean_optional_str(getattr(radio_manager, "device_model", None)),
"firmware_build": _clean_optional_str(getattr(radio_manager, "firmware_build", None)),
"firmware_version": _clean_optional_str(
getattr(radio_manager, "firmware_version", None)
),
"max_contacts": getattr(radio_manager, "max_contacts", None),
"max_channels": getattr(radio_manager, "max_channels", None),
}
return {
"status": "ok" if radio_connected and not radio_initializing else "degraded",
"radio_connected": radio_connected,
"radio_initializing": radio_initializing,
"radio_state": radio_state,
"connection_info": connection_info,
"radio_device_info": radio_device_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"fanout_statuses": fanout_statuses,
+78 -12
View File
@@ -7,6 +7,21 @@ POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
def _clean_device_string(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
def _decode_fixed_string(raw: bytes, start: int, length: int) -> str | None:
if len(raw) < start:
return None
return _clean_device_string(
raw[start : start + length].decode("utf-8", "ignore").replace("\0", "")
)
async def run_post_connect_setup(radio_manager) -> None:
"""Run shared radio initialization after a transport connection succeeds."""
from app.event_handlers import register_event_handlers
@@ -78,26 +93,66 @@ async def run_post_connect_setup(radio_manager) -> None:
return await _original_handle_rx(data)
reader.handle_rx = _capture_handle_rx
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
try:
device_query = await mc.commands.send_device_query()
if device_query and "max_channels" in device_query.payload:
radio_manager.max_channels = max(
1, int(device_query.payload["max_channels"])
)
if device_query and "path_hash_mode" in device_query.payload:
radio_manager.path_hash_mode = device_query.payload["path_hash_mode"]
payload = (
device_query.payload
if device_query is not None and isinstance(device_query.payload, dict)
else {}
)
payload_max_contacts = payload.get("max_contacts")
if isinstance(payload_max_contacts, int):
radio_manager.max_contacts = max(1, payload_max_contacts)
payload_max_channels = payload.get("max_channels")
if isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, payload_max_channels)
radio_manager.device_model = _clean_device_string(payload.get("model"))
radio_manager.firmware_build = _clean_device_string(payload.get("fw_build"))
radio_manager.firmware_version = _clean_device_string(payload.get("ver"))
fw_ver = payload.get("fw ver")
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
if payload_reports_device_info:
radio_manager.device_info_loaded = True
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
radio_manager.path_hash_mode = payload["path_hash_mode"]
radio_manager.path_hash_mode_supported = True
elif _captured_frame:
# Raw-frame fallback:
# byte 1 = fw_ver, byte 3 = max_channels, byte 81 = path_hash_mode
if _captured_frame:
# Raw-frame fallback / completion:
# byte 1 = fw_ver, byte 2 = max_contacts/2, byte 3 = max_channels,
# bytes 8:20 = fw_build, 20:60 = model, 60:80 = ver, byte 81 = path_hash_mode
raw = _captured_frame[-1]
fw_ver = raw[1] if len(raw) > 1 else 0
if fw_ver >= 3 and len(raw) >= 4:
radio_manager.max_channels = max(1, raw[3])
if fw_ver >= 10 and len(raw) >= 82:
if fw_ver >= 3:
radio_manager.device_info_loaded = True
if radio_manager.max_contacts is None and len(raw) >= 3:
radio_manager.max_contacts = max(1, raw[2] * 2)
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, raw[3])
if radio_manager.firmware_build is None:
radio_manager.firmware_build = _decode_fixed_string(raw, 8, 12)
if radio_manager.device_model is None:
radio_manager.device_model = _decode_fixed_string(raw, 20, 40)
if radio_manager.firmware_version is None:
radio_manager.firmware_version = _decode_fixed_string(raw, 60, 20)
if (
not radio_manager.path_hash_mode_supported
and fw_ver >= 10
and len(raw) >= 82
):
radio_manager.path_hash_mode = raw[81]
radio_manager.path_hash_mode_supported = True
logger.warning(
@@ -114,6 +169,17 @@ async def run_post_connect_setup(radio_manager) -> None:
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
else:
logger.debug("Firmware does not report path_hash_mode")
if radio_manager.device_info_loaded:
logger.info(
"Radio device info: model=%s build=%s version=%s max_contacts=%s max_channels=%d",
radio_manager.device_model or "unknown",
radio_manager.firmware_build or "unknown",
radio_manager.firmware_version or "unknown",
radio_manager.max_contacts
if radio_manager.max_contacts is not None
else "unknown",
radio_manager.max_channels,
)
logger.info("Max channel slots: %d", radio_manager.max_channels)
except Exception as exc:
logger.debug("Failed to query device info capabilities: %s", exc)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -333,6 +333,35 @@ export function SettingsRadioSection({
? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}`
: 'Not connected';
const deviceInfoLabel = useMemo(() => {
const info = health?.radio_device_info;
if (!info) {
return null;
}
const model = info.model?.trim() || null;
const firmwareParts = [info.firmware_build?.trim(), info.firmware_version?.trim()].filter(
(value): value is string => Boolean(value)
);
const capacityParts = [
typeof info.max_contacts === 'number' ? `${info.max_contacts} contacts` : null,
typeof info.max_channels === 'number' ? `${info.max_channels} channels` : null,
].filter((value): value is string => value !== null);
if (!model && firmwareParts.length === 0 && capacityParts.length === 0) {
return null;
}
let label = model ?? 'Radio';
if (firmwareParts.length > 0) {
label += ` running ${firmwareParts.join('/')}`;
}
if (capacityParts.length > 0) {
label += ` (max: ${capacityParts.join(', ')})`;
}
return label;
}, [health?.radio_device_info]);
const handleConnectionAction = async () => {
setConnectionBusy(true);
try {
@@ -377,6 +406,7 @@ export function SettingsRadioSection({
{connectionStatusLabel}
</span>
</div>
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
<Button
type="button"
variant="outline"
+20
View File
@@ -205,6 +205,26 @@ describe('SettingsModal', () => {
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
});
it('shows cached radio firmware and capacity info under the connection status', () => {
renderModal({
health: {
...baseHealth,
radio_device_info: {
model: 'T-Echo',
firmware_build: '2025-02-01',
firmware_version: '1.2.3',
max_contacts: 350,
max_channels: 64,
},
},
});
openRadioSection();
expect(
screen.getByText('T-Echo running 2025-02-01/1.2.3 (max: 350 contacts, 64 channels)')
).toBeInTheDocument();
});
it('shows reconnect action when radio connection is paused', () => {
renderModal({
health: { ...baseHealth, radio_state: 'paused' },
+7
View File
@@ -57,6 +57,13 @@ export interface HealthStatus {
radio_initializing: boolean;
radio_state?: 'connected' | 'initializing' | 'connecting' | 'disconnected' | 'paused';
connection_info: string | null;
radio_device_info?: {
model: string | null;
firmware_build: string | null;
firmware_version: string | null;
max_contacts: number | null;
max_channels: number | null;
} | null;
database_size_mb: number;
oldest_undecrypted_timestamp: number | null;
fanout_statuses: Record<string, FanoutStatusEntry>;
+30
View File
@@ -78,6 +78,11 @@ class TestHealthEndpoint:
with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
from app.main import app
@@ -97,6 +102,11 @@ class TestHealthEndpoint:
with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.connection_info = None
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = False
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
from app.main import app
@@ -1118,6 +1128,11 @@ class TestHealthEndpointDatabaseSize:
):
mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.return_value = 10 * 1024 * 1024 # 10 MB
from app.main import app
@@ -1148,6 +1163,11 @@ class TestHealthEndpointOldestUndecrypted:
):
mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.return_value = 5 * 1024 * 1024 # 5 MB
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=1700000000)
@@ -1175,6 +1195,11 @@ class TestHealthEndpointOldestUndecrypted:
):
mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.return_value = 1 * 1024 * 1024 # 1 MB
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
@@ -1202,6 +1227,11 @@ class TestHealthEndpointOldestUndecrypted:
):
mock_rm.is_connected = False
mock_rm.connection_info = None
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = False
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.side_effect = OSError("File not found")
mock_repo.get_oldest_undecrypted = AsyncMock(side_effect=RuntimeError("No DB"))
+29
View File
@@ -59,6 +59,35 @@ class TestHealthFanoutStatus:
assert data["radio_state"] == "connected"
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
@pytest.mark.asyncio
async def test_health_includes_cached_radio_device_info(self, test_db):
"""Health includes device metadata captured during post-connect setup."""
with (
patch(
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
),
patch("app.routers.health.radio_manager") as mock_rm,
):
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = True
mock_rm.device_model = "T-Echo"
mock_rm.firmware_build = "2025-02-01"
mock_rm.firmware_version = "1.2.3"
mock_rm.max_contacts = 350
mock_rm.max_channels = 64
data = await build_health_data(True, "Serial: /dev/ttyUSB0")
assert data["radio_device_info"] == {
"model": "T-Echo",
"firmware_build": "2025-02-01",
"firmware_version": "1.2.3",
"max_contacts": 350,
"max_channels": 64,
}
@pytest.mark.asyncio
async def test_health_status_degraded_when_disconnected(self, test_db):
"""Health status is 'degraded' when radio is disconnected."""
+10
View File
@@ -476,6 +476,11 @@ class TestManualDisconnectCleanup:
mock_mc.connection_manager = connection_manager
rm._meshcore = mock_mc
rm._setup_complete = True
rm.device_info_loaded = True
rm.max_contacts = 350
rm.device_model = "T-Echo"
rm.firmware_build = "2025-02-01"
rm.firmware_version = "1.2.3"
rm.max_channels = 8
rm.path_hash_mode = 2
rm.path_hash_mode_supported = True
@@ -491,6 +496,11 @@ class TestManualDisconnectCleanup:
assert reconnect_task is not None and reconnect_task.cancelled()
assert rm.meshcore is None
assert rm.is_setup_complete is False
assert rm.device_info_loaded is False
assert rm.max_contacts is None
assert rm.device_model is None
assert rm.firmware_build is None
assert rm.firmware_version is None
assert rm.max_channels == 40
assert rm.path_hash_mode == 0
assert rm.path_hash_mode_supported is False
+68
View File
@@ -112,6 +112,11 @@ class TestRunPostConnectSetup:
radio_manager._setup_lock = None
radio_manager._setup_in_progress = False
radio_manager._setup_complete = False
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
@@ -145,3 +150,66 @@ class TestRunPostConnectSetup:
replacement_mc.start_auto_message_fetching.assert_awaited_once()
initial_mc.start_auto_message_fetching.assert_not_called()
assert radio_manager.max_channels == 8
@pytest.mark.asyncio
async def test_caches_device_info_metadata_from_device_query(self):
mc = MagicMock()
mc.commands.send_device_query = AsyncMock(
return_value=MagicMock(
payload={
"fw ver": 10,
"max_contacts": 350,
"max_channels": 64,
"model": "T-Echo",
"fw_build": "2025-02-01",
"ver": "1.2.3",
"path_hash_mode": 2,
}
)
)
mc.commands.set_flood_scope = AsyncMock(return_value=None)
mc._reader = MagicMock()
mc._reader.handle_rx = AsyncMock()
mc.start_auto_message_fetching = AsyncMock()
radio_manager = MagicMock()
radio_manager.meshcore = mc
radio_manager._setup_lock = None
radio_manager._setup_in_progress = False
radio_manager._setup_complete = False
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
radio_manager._acquire_operation_lock = AsyncMock()
radio_manager._release_operation_lock = MagicMock()
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new=AsyncMock()),
patch("app.radio_sync.sync_radio_time", new=AsyncMock()),
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=MagicMock(flood_scope=None)),
),
patch("app.radio_sync.sync_and_offload_all", new=AsyncMock(return_value={"synced": 0})),
patch("app.radio_sync.send_advertisement", new=AsyncMock(return_value=False)),
patch("app.radio_sync.drain_pending_messages", new=AsyncMock(return_value=0)),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.start_message_polling"),
):
await run_post_connect_setup(radio_manager)
assert radio_manager.device_info_loaded is True
assert radio_manager.max_contacts == 350
assert radio_manager.max_channels == 64
assert radio_manager.device_model == "T-Echo"
assert radio_manager.firmware_build == "2025-02-01"
assert radio_manager.firmware_version == "1.2.3"
assert radio_manager.path_hash_mode == 2
assert radio_manager.path_hash_mode_supported is True
+25
View File
@@ -41,6 +41,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_connected = True
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False
@@ -75,6 +80,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = None
mock_health_rm.is_connected = False
mock_health_rm.connection_info = None
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = False
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False
@@ -105,6 +115,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "TCP: 192.168.1.1:4000"
mock_health_rm.is_connected = True
mock_health_rm.connection_info = "TCP: 192.168.1.1:4000"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False
@@ -136,6 +151,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_connected = True
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False
@@ -167,6 +187,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_connected = True
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False