Misc. doc, test, and qol improvements

This commit is contained in:
Jack Kingsman
2026-02-27 15:17:29 -08:00
parent c40603a36f
commit 6a3510ce2e
12 changed files with 277 additions and 22 deletions
+4 -1
View File
@@ -150,8 +150,10 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
. .
├── app/ # FastAPI backend ├── app/ # FastAPI backend
│ ├── AGENTS.md # Backend documentation │ ├── AGENTS.md # Backend documentation
│ ├── bot.py # Bot execution and outbound bot sends
│ ├── main.py # App entry, lifespan │ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints │ ├── routers/ # API endpoints
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
│ ├── repository.py # Database CRUD │ ├── repository.py # Database CRUD
│ ├── event_handlers.py # Radio events │ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption │ ├── decoder.py # Packet decryption
@@ -167,7 +169,6 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ ├── MapView.tsx # Leaflet map showing node locations │ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ... │ │ └── ...
│ └── vite.config.ts │ └── vite.config.ts
├── references/meshcore_py/ # MeshCore Python library
├── tests/ # Backend tests (pytest) ├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime) ├── data/ # SQLite database (runtime)
└── pyproject.toml # Python dependencies └── pyproject.toml # Python dependencies
@@ -389,6 +390,8 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) | | `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) | | `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code | | `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location | | `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints). **Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
+1 -1
View File
@@ -8,7 +8,7 @@ Keep it aligned with `app/` source files and router behavior.
- FastAPI - FastAPI
- aiosqlite - aiosqlite
- Pydantic - Pydantic
- MeshCore Python library (`references/meshcore_py`) - MeshCore Python library (`meshcore` from PyPI)
- PyCryptodome - PyCryptodome
## Backend Map ## Backend Map
+4 -9
View File
@@ -443,6 +443,8 @@ async def _periodic_advert_loop():
""" """
while True: while True:
try: try:
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
# Try to send - send_advertisement() handles all checks # Try to send - send_advertisement() handles all checks
# (disabled, throttled, not connected) # (disabled, throttled, not connected)
if radio_manager.is_connected: if radio_manager.is_connected:
@@ -455,9 +457,6 @@ async def _periodic_advert_loop():
except RadioOperationBusyError: except RadioOperationBusyError:
logger.debug("Skipping periodic advertisement: radio busy") logger.debug("Skipping periodic advertisement: radio busy")
# Sleep before next check
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Periodic advertisement task cancelled") logger.info("Periodic advertisement task cancelled")
break break
@@ -595,9 +594,7 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
break break
if len(selected_contacts) < max_contacts: if len(selected_contacts) < max_contacts:
recent_contacts = await ContactRepository.get_recent_non_repeaters( recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
limit=max_contacts
)
for contact in recent_contacts: for contact in recent_contacts:
key = contact.public_key.lower() key = contact.public_key.lower()
if key in selected_keys: if key in selected_keys:
@@ -658,9 +655,7 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
} }
async def sync_recent_contacts_to_radio( async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None = None) -> dict:
force: bool = False, mc: MeshCore | None = None
) -> dict:
""" """
Load contacts to the radio for DM ACK support. Load contacts to the radio for DM ACK support.
+3 -1
View File
@@ -539,7 +539,9 @@ async def create_contact(
"last_contacted": existing.last_contacted, "last_contacted": existing.last_contacted,
} }
) )
existing.name = request.name refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
# Trigger historical decryption if requested (even for existing contacts) # Trigger historical decryption if requested (even for existing contacts)
if request.try_historical: if request.try_historical:
+3
View File
@@ -26,4 +26,7 @@ services:
# TCP # TCP
# MESHCORE_TCP_HOST: 192.168.1.100 # MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 4000 # MESHCORE_TCP_PORT: 4000
# Logging
# MESHCORE_LOG_LEVEL: INFO
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -24,7 +24,6 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@types/three": "^0.182.0",
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -48,6 +47,7 @@
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@types/d3-force": "^3.0.10", "@types/d3-force": "^3.0.10",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/three": "^0.182.0",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
+1 -1
View File
@@ -52,7 +52,7 @@ Types, constants, and pure functions shared across the codebase:
- Types: `NodeType`, `PacketLabel`, `Particle`, `ObservedPath`, `PendingPacket`, `ParsedPacket`, `TrafficObservation`, `RepeaterTrafficData`, `RepeaterSplitAnalysis` - Types: `NodeType`, `PacketLabel`, `Particle`, `ObservedPath`, `PendingPacket`, `ParsedPacket`, `TrafficObservation`, `RepeaterTrafficData`, `RepeaterSplitAnalysis`
- Constants: `COLORS`, `PARTICLE_COLOR_MAP`, `PARTICLE_SPEED`, `DEFAULT_OBSERVATION_WINDOW_SEC`, traffic thresholds, `PACKET_LEGEND_ITEMS` - Constants: `COLORS`, `PARTICLE_COLOR_MAP`, `PARTICLE_SPEED`, `DEFAULT_OBSERVATION_WINDOW_SEC`, traffic thresholds, `PACKET_LEGEND_ITEMS`
- Functions: `simpleHash`, `parsePacket`, `getPacketLabel`, `generatePacketKey`, `getLinkId`, `getNodeType`, `dedupeConsecutive`, `analyzeRepeaterTraffic`, `recordTrafficObservation` - Functions: `hashString` (from `utils/contactAvatar.ts`), `parsePacket`, `getPacketLabel`, `generatePacketKey`, `getLinkId`, `getNodeType`, `dedupeConsecutive`, `analyzeRepeaterTraffic`, `recordTrafficObservation`
`GraphNode` and `GraphLink` are defined locally in the component — they extend `SimulationNodeDatum3D` and `SimulationLinkDatum` from `d3-force-3d`. `GraphNode` and `GraphLink` are defined locally in the component — they extend `SimulationNodeDatum3D` and `SimulationLinkDatum` from `d3-force-3d`.
+1
View File
@@ -1,6 +1,7 @@
export { useUnreadCounts } from './useUnreadCounts'; export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages'; export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useRadioControl } from './useRadioControl'; export { useRadioControl } from './useRadioControl';
export { useRepeaterDashboard } from './useRepeaterDashboard';
export { useAppSettings } from './useAppSettings'; export { useAppSettings } from './useAppSettings';
export { useConversationRouter } from './useConversationRouter'; export { useConversationRouter } from './useConversationRouter';
export { useContactsAndChannels } from './useContactsAndChannels'; export { useContactsAndChannels } from './useContactsAndChannels';
@@ -177,6 +177,32 @@ describe('useWebSocket dispatch', () => {
Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled()); Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled());
}); });
it('routes raw_packet event to onRawPacket with observation_id', () => {
const onRawPacket = vi.fn();
renderHook(() => useWebSocket({ onRawPacket }));
const rawPacketData = {
id: 5,
observation_id: 42,
timestamp: 1700000000,
data: 'aabbccdd',
payload_type: 'GROUP_TEXT',
snr: 7.5,
rssi: -85,
decrypted: true,
decrypted_info: {
channel_name: '#general',
sender: 'Alice',
},
};
fireMessage({ type: 'raw_packet', data: rawPacketData });
expect(onRawPacket).toHaveBeenCalledOnce();
expect(onRawPacket).toHaveBeenCalledWith(rawPacketData);
expect(onRawPacket.mock.calls[0][0]).toHaveProperty('observation_id', 42);
expect(onRawPacket.mock.calls[0][0]).toHaveProperty('id', 5);
});
it('malformed JSON calls no handlers (catch branch)', () => { it('malformed JSON calls no handlers (catch branch)', () => {
const handlers = { const handlers = {
onHealth: vi.fn(), onHealth: vi.fn(),
+130
View File
@@ -2075,3 +2075,133 @@ class TestRunHistoricalDmDecryption:
assert len(success_calls) == 1 assert len(success_calls) == 1
assert "2 messages" in success_calls[0]["details"] assert "2 messages" in success_calls[0]["details"]
class TestHistoricalDMDirectionDetection:
"""Test direction detection in run_historical_dm_decryption.
Verifies the BUG-2 fix: when first public key bytes of our key and the
contact's key collide (1/256 chance), the function must default to
outgoing=False rather than mis-classifying the message.
"""
# Our key: first byte is 0xAA
OUR_PUB_HEX = "AA" + "00" * 31
OUR_PUB = bytes.fromhex(OUR_PUB_HEX)
OUR_PRIV = b"\x01" * 64 # Dummy, won't be used (try_decrypt_dm is mocked)
# Contact key: first byte differs (0xBB) — normal case
CONTACT_DIFF_PUB_HEX = "bb" + "11" * 31
CONTACT_DIFF_PUB = bytes.fromhex(CONTACT_DIFF_PUB_HEX)
# Contact key: first byte same as ours (0xAA) — the 1/256 collision case
CONTACT_SAME_PUB_HEX = "aa" + "22" * 31
CONTACT_SAME_PUB = bytes.fromhex(CONTACT_SAME_PUB_HEX)
def _make_text_message_bytes(self, unique_suffix: bytes = b"") -> bytes:
"""Build a minimal raw packet with TEXT_MESSAGE payload type."""
header = 0x09 # route_type=FLOOD(0x01), payload_type=TEXT_MESSAGE(0x02)
path_length = 0
payload = bytes([0xAA, 0xBB]) + b"\x00\x00" + b"\xab" * 16 + unique_suffix
return bytes([header, path_length]) + payload
@pytest.mark.asyncio
async def test_incoming_dm_marked_as_incoming(self, test_db, captured_broadcasts):
"""Normal case: src_hash differs from our first byte -> outgoing=False (incoming)."""
from app.packet_processor import run_historical_dm_decryption
raw = self._make_text_message_bytes(b"\x60")
await RawPacketRepository.create(raw, 7000)
mock_packet_info = PacketInfo(
route_type=1,
payload_type=PayloadType.TEXT_MESSAGE,
payload_version=0,
path_length=0,
path=b"",
payload=b"\xaa\xbb" + b"\x00" * 18,
)
# src_hash="bb" (contact), dest_hash="aa" (us) -> incoming
mock_decrypted = DecryptedDirectMessage(
timestamp=7000,
flags=0,
message="Hello from contact",
dest_hash="aa",
src_hash="bb",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
with patch("app.packet_processor.try_decrypt_dm", return_value=mock_decrypted):
with patch("app.packet_processor.parse_packet", return_value=mock_packet_info):
with patch("app.packet_processor.derive_public_key", return_value=self.OUR_PUB):
with patch(
"app.packet_processor.create_dm_message_from_decrypted",
new_callable=AsyncMock,
return_value=100,
) as mock_create:
with patch("app.websocket.broadcast_success"):
await run_historical_dm_decryption(
self.OUR_PRIV,
self.CONTACT_DIFF_PUB,
self.CONTACT_DIFF_PUB_HEX,
)
mock_create.assert_awaited_once()
call_kwargs = mock_create.call_args[1]
assert call_kwargs["outgoing"] is False
@pytest.mark.asyncio
async def test_ambiguous_first_bytes_defaults_to_incoming(self, test_db, captured_broadcasts):
"""1/256 case: our_public_key_bytes[0] == contact_public_key_bytes[0].
Both src_hash and dest_hash match our first byte. The function must
default to outgoing=False (incoming) because outgoing DMs are stored
by the send endpoint, so historical decryption only recovers incoming.
"""
from app.packet_processor import run_historical_dm_decryption
raw = self._make_text_message_bytes(b"\x61")
await RawPacketRepository.create(raw, 7100)
mock_packet_info = PacketInfo(
route_type=1,
payload_type=PayloadType.TEXT_MESSAGE,
payload_version=0,
path_length=0,
path=b"",
payload=b"\xaa\xaa" + b"\x00" * 18,
)
# Both hashes are "aa" — matches our first byte (0xAA)
mock_decrypted = DecryptedDirectMessage(
timestamp=7100,
flags=0,
message="Ambiguous direction msg",
dest_hash="aa",
src_hash="aa",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
with patch("app.packet_processor.try_decrypt_dm", return_value=mock_decrypted):
with patch("app.packet_processor.parse_packet", return_value=mock_packet_info):
with patch("app.packet_processor.derive_public_key", return_value=self.OUR_PUB):
with patch(
"app.packet_processor.create_dm_message_from_decrypted",
new_callable=AsyncMock,
return_value=101,
) as mock_create:
with patch("app.websocket.broadcast_success"):
await run_historical_dm_decryption(
self.OUR_PRIV,
self.CONTACT_SAME_PUB,
self.CONTACT_SAME_PUB_HEX,
)
mock_create.assert_awaited_once()
call_kwargs = mock_create.call_args[1]
assert call_kwargs["outgoing"] is False
+52 -1
View File
@@ -1,7 +1,7 @@
"""Tests for shared radio operation locking behavior.""" """Tests for shared radio operation locking behavior."""
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -169,3 +169,54 @@ class TestRadioOperationYield:
async with radio_manager.radio_operation("test_swap") as yielded: async with radio_manager.radio_operation("test_swap") as yielded:
assert yielded is new_mc assert yielded is new_mc
assert yielded is not old_mc assert yielded is not old_mc
class TestRequireConnected:
"""Test the require_connected() FastAPI dependency."""
def test_raises_503_when_setup_in_progress(self):
"""HTTPException 503 is raised when radio is connected but setup is still in progress."""
from fastapi import HTTPException
from app.dependencies import require_connected
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = MagicMock()
mock_rm.is_setup_in_progress = True
with pytest.raises(HTTPException) as exc_info:
require_connected()
assert exc_info.value.status_code == 503
assert "initializing" in exc_info.value.detail.lower()
def test_raises_503_when_not_connected(self):
"""HTTPException 503 is raised when radio is not connected."""
from fastapi import HTTPException
from app.dependencies import require_connected
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_setup_in_progress = False
mock_rm.is_connected = False
mock_rm.meshcore = None
with pytest.raises(HTTPException) as exc_info:
require_connected()
assert exc_info.value.status_code == 503
def test_returns_meshcore_when_connected_and_setup_complete(self):
"""Returns meshcore instance when radio is connected and setup is complete."""
from app.dependencies import require_connected
mock_mc = MagicMock()
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_setup_in_progress = False
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = require_connected()
assert result is mock_mc
+51 -7
View File
@@ -364,6 +364,48 @@ class TestSyncRecentContactsToRadio:
assert result["loaded"] == 0 assert result["loaded"] == 0
assert result["failed"] == 1 assert result["failed"] == 1
@pytest.mark.asyncio
async def test_mc_param_bypasses_lock_acquisition(self, test_db):
"""When mc is passed, the function uses it directly without acquiring radio_operation.
This tests the BUG-1 fix: sync_and_offload_all already holds the lock,
so it passes mc directly to avoid deadlock (asyncio.Lock is not reentrant).
"""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.type = EventType.OK
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
# Make radio_operation raise if called — it should NOT be called
# when mc is provided
def radio_operation_should_not_be_called(*args, **kwargs):
raise AssertionError("radio_operation should not be called when mc is passed")
with patch.object(
radio_manager, "radio_operation", side_effect=radio_operation_should_not_be_called
):
result = await sync_recent_contacts_to_radio(mc=mock_mc)
assert result["loaded"] == 1
mock_mc.commands.add_contact.assert_called_once()
@pytest.mark.asyncio
async def test_mc_param_still_respects_throttle(self):
"""When mc is passed but throttle is active (not forced), it should still return throttled."""
mock_mc = MagicMock()
# First call to set _last_contact_sync
radio_manager._meshcore = mock_mc
await sync_recent_contacts_to_radio()
# Second call with mc= but no force — should still be throttled
result = await sync_recent_contacts_to_radio(mc=mock_mc)
assert result["throttled"] is True
assert result["loaded"] == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_post_lock_meshcore_after_swap(self, test_db): async def test_uses_post_lock_meshcore_after_swap(self, test_db):
"""If _meshcore is swapped between pre-check and lock acquisition, """If _meshcore is swapped between pre-check and lock acquisition,
@@ -1032,9 +1074,10 @@ class TestPeriodicAdvertLoopRaces:
is caught by the outer except loop survives and continues.""" is caught by the outer except loop survives and continues."""
rm, _mc = _make_connected_manager() rm, _mc = _make_connected_manager()
_disconnect_on_acquire(rm) _disconnect_on_acquire(rm)
# Advert loop: work first, then sleep. On error the except-handler # Advert loop: sleep first, then work. Sleep 1 (loop top) passes,
# sleeps too, so we need 2 sleeps before cancel. # work hits RadioDisconnectedError, error handler does sleep 2 (passes),
mock_sleep, sleep_calls = _sleep_controller(cancel_after=2) # next iteration sleep 3 cancels cleanly via except CancelledError.
mock_sleep, sleep_calls = _sleep_controller(cancel_after=3)
with ( with (
patch("app.radio_sync.radio_manager", rm), patch("app.radio_sync.radio_manager", rm),
@@ -1044,15 +1087,15 @@ class TestPeriodicAdvertLoopRaces:
await _periodic_advert_loop() await _periodic_advert_loop()
mock_advert.assert_not_called() mock_advert.assert_not_called()
assert len(sleep_calls) == 2 assert len(sleep_calls) == 3
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_busy_lock_skips_iteration(self): async def test_busy_lock_skips_iteration(self):
"""RadioOperationBusyError is caught and send_advertisement is not called.""" """RadioOperationBusyError is caught and send_advertisement is not called."""
rm, _mc = _make_connected_manager() rm, _mc = _make_connected_manager()
lock = await _pre_hold_lock(rm) lock = await _pre_hold_lock(rm)
# Busy path falls through to normal sleep (only 1 needed) # Sleep 1 (loop top) passes, work hits busy error, sleep 2 cancels.
mock_sleep, _ = _sleep_controller(cancel_after=1) mock_sleep, _ = _sleep_controller(cancel_after=2)
try: try:
with ( with (
@@ -1071,7 +1114,8 @@ class TestPeriodicAdvertLoopRaces:
"""The mc yielded by radio_operation() is forwarded to """The mc yielded by radio_operation() is forwarded to
send_advertisement not a stale radio_manager.meshcore read.""" send_advertisement not a stale radio_manager.meshcore read."""
rm, mock_mc = _make_connected_manager() rm, mock_mc = _make_connected_manager()
mock_sleep, _ = _sleep_controller(cancel_after=1) # Sleep 1 (loop top) passes through, work runs, sleep 2 cancels.
mock_sleep, _ = _sleep_controller(cancel_after=2)
with ( with (
patch("app.radio_sync.radio_manager", rm), patch("app.radio_sync.radio_manager", rm),