mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 20:06:13 +02:00
Compare commits
20 Commits
bugbash-v7
...
3.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c33eb469ac | |||
| 0fe6584e7a | |||
| 557d79d437 | |||
| daff3dcb4a | |||
| 77db7287d6 | |||
| 67873e8dd9 | |||
| e2ddf5f79f | |||
| 4a93641f04 | |||
| d5922a214b | |||
| 7ad1ee26a4 | |||
| 08238aa464 | |||
| 1046baf741 | |||
| 42e1b7b5d9 | |||
| 3ca4f7edf7 | |||
| 55081d4a2d | |||
| be2b2604df | |||
| 35981d8f8b | |||
| 8e998c03ba | |||
| d802dd4212 | |||
| 7557eb1fa6 |
@@ -347,13 +347,13 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||
| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||
@@ -402,6 +402,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||
- Custom channels: User-provided or generated
|
||||
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
||||
- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default.
|
||||
|
||||
### Message Types
|
||||
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
## [3.8.0] - 2026-04-03
|
||||
|
||||
* Feature: Per-channel hop width override
|
||||
* Feature: Intervalized repeater telemetry collection
|
||||
* Feature: Auto-resend option for byte-perfect resends on no repeater echo
|
||||
* Feature: Attach RSSI/SNR to received packets
|
||||
* Feature: Add motion packet display to map
|
||||
* Feature: Map dark mode
|
||||
* Bugfix: Make DB indices more useful around capitalization
|
||||
* Misc: Bump required Python to 3.11
|
||||
* Misc: Performance, documentation, and test improvements
|
||||
* Misc: More yields during long radio operations
|
||||
* Misc: Dead code & crufty test removal
|
||||
* Misc: Remove all but stub frontend favorites migration for very very old versions
|
||||
|
||||
## [3.7.1] - 2026-04-02
|
||||
|
||||
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
|
||||
|
||||
+2
-1
@@ -218,6 +218,7 @@ app/
|
||||
- `POST /channels/bulk-hashtag`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/path-hash-mode-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
@@ -280,7 +281,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
|
||||
+4
-1
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT,
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER
|
||||
);
|
||||
|
||||
@@ -103,7 +104,9 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
||||
flood_scope TEXT DEFAULT '',
|
||||
blocked_keys TEXT DEFAULT '[]',
|
||||
blocked_names TEXT DEFAULT '[]',
|
||||
discovery_blocked_types TEXT DEFAULT '[]'
|
||||
discovery_blocked_types TEXT DEFAULT '[]',
|
||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||
auto_resend_channel INTEGER DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Shared dependencies for FastAPI routers."""
|
||||
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
|
||||
def require_connected():
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance."""
|
||||
return radio_manager.require_connected()
|
||||
@@ -52,19 +52,6 @@ class ToastPayload(TypedDict):
|
||||
details: NotRequired[str]
|
||||
|
||||
|
||||
WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| ContactResolvedPayload
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
| RawPacketBroadcast
|
||||
| MessageAckedPayload
|
||||
| ToastPayload
|
||||
)
|
||||
|
||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
stop_periodic_sync,
|
||||
stop_telemetry_collect,
|
||||
)
|
||||
from app.routers import (
|
||||
channels,
|
||||
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
|
||||
await stop_noise_floor_sampling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
await stop_telemetry_collect()
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||
await radio_manager.disconnect()
|
||||
|
||||
@@ -395,6 +395,24 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 51)
|
||||
applied += 1
|
||||
|
||||
if version < 52:
|
||||
logger.info("Applying migration 52: add path_hash_mode_override to channels")
|
||||
await _migrate_052_add_channel_path_hash_mode_override(conn)
|
||||
await set_version(conn, 52)
|
||||
applied += 1
|
||||
|
||||
if version < 53:
|
||||
logger.info("Applying migration 53: add tracked_telemetry_repeaters to app_settings")
|
||||
await _migrate_053_tracked_telemetry_repeaters(conn)
|
||||
await set_version(conn, 53)
|
||||
applied += 1
|
||||
|
||||
if version < 54:
|
||||
logger.info("Applying migration 54: add auto_resend_channel to app_settings")
|
||||
await _migrate_054_auto_resend_channel(conn)
|
||||
await set_version(conn, 54)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -3149,3 +3167,49 @@ async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> No
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable per-channel path hash mode override column."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "channels" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
try:
|
||||
await conn.execute("ALTER TABLE channels ADD COLUMN path_hash_mode_override INTEGER")
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
if "duplicate column" in str(e).lower():
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None:
|
||||
"""Add tracked_telemetry_repeaters JSON list column to app_settings."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "tracked_telemetry_repeaters" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
|
||||
"""Add auto_resend_channel boolean column to app_settings."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "auto_resend_channel" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
+30
-36
@@ -196,15 +196,6 @@ class Contact(BaseModel):
|
||||
"""Convert the stored contact to the repository's write contract."""
|
||||
return ContactUpsert.from_contact(self, **changes)
|
||||
|
||||
@staticmethod
|
||||
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
|
||||
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
|
||||
return ContactUpsert.from_radio_dict(
|
||||
public_key,
|
||||
radio_data,
|
||||
on_radio=on_radio,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
"""Request to create a new contact."""
|
||||
@@ -330,6 +321,10 @@ class Channel(BaseModel):
|
||||
default=None,
|
||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
||||
)
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
|
||||
|
||||
@@ -351,6 +346,18 @@ class ChannelTopSender(BaseModel):
|
||||
message_count: int
|
||||
|
||||
|
||||
class PathHashWidthStats(BaseModel):
|
||||
"""Hop byte width distribution for parsed raw packets."""
|
||||
|
||||
total_packets: int = 0
|
||||
single_byte: int = 0
|
||||
double_byte: int = 0
|
||||
triple_byte: int = 0
|
||||
single_byte_pct: float = 0.0
|
||||
double_byte_pct: float = 0.0
|
||||
triple_byte_pct: float = 0.0
|
||||
|
||||
|
||||
class ChannelDetail(BaseModel):
|
||||
"""Comprehensive channel profile data."""
|
||||
|
||||
@@ -359,6 +366,7 @@ class ChannelDetail(BaseModel):
|
||||
first_message_at: int | None = None
|
||||
unique_sender_count: int = 0
|
||||
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
||||
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
|
||||
|
||||
|
||||
class MessagePath(BaseModel):
|
||||
@@ -370,6 +378,8 @@ class MessagePath(BaseModel):
|
||||
default=None,
|
||||
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
||||
)
|
||||
rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm")
|
||||
snr: float | None = Field(default=None, description="Last-hop SNR in dB")
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -791,10 +801,6 @@ class AppSettings(BaseModel):
|
||||
default_factory=dict,
|
||||
description="Map of conversation state keys to last message timestamps",
|
||||
)
|
||||
preferences_migrated: bool = Field(
|
||||
default=False,
|
||||
description="Whether preferences have been migrated from localStorage",
|
||||
)
|
||||
advert_interval: int = Field(
|
||||
default=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
@@ -822,19 +828,17 @@ class AppSettings(BaseModel):
|
||||
"advertisements should not create new contacts; existing contacts are still updated"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FanoutConfig(BaseModel):
|
||||
"""Configuration for a single fanout integration."""
|
||||
|
||||
id: str
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
|
||||
name: str
|
||||
enabled: bool
|
||||
config: dict
|
||||
scope: dict
|
||||
sort_order: int = 0
|
||||
created_at: int = 0
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||
)
|
||||
auto_resend_channel: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When enabled, outgoing channel messages that receive no echo within 2 seconds "
|
||||
"are automatically byte-perfect resent once (within the 30-second dedup window)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
@@ -849,16 +853,6 @@ class ContactActivityCounts(BaseModel):
|
||||
last_week: int
|
||||
|
||||
|
||||
class PathHashWidthStats(BaseModel):
|
||||
total_packets: int
|
||||
single_byte: int
|
||||
double_byte: int
|
||||
triple_byte: int
|
||||
single_byte_pct: float
|
||||
double_byte_pct: float
|
||||
triple_byte_pct: float
|
||||
|
||||
|
||||
class NoiseFloorSample(BaseModel):
|
||||
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
|
||||
+22
-2
@@ -68,6 +68,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -81,6 +83,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
channel_name=channel_name,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
@@ -95,6 +99,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -107,6 +113,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
@@ -319,7 +327,9 @@ async def process_raw_packet(
|
||||
# deduplication in create_message_from_decrypted handles adding paths to existing messages.
|
||||
# This is more reliable than trying to look up the message via raw packet linking.
|
||||
if payload_type == PayloadType.GROUP_TEXT:
|
||||
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_group_text(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -330,7 +340,9 @@ async def process_raw_packet(
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# Try to decrypt direct messages using stored private key and known contacts
|
||||
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_direct_message(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -367,6 +379,8 @@ async def _process_group_text(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a GroupText (channel message) packet.
|
||||
@@ -403,6 +417,8 @@ async def _process_group_text(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -544,6 +560,8 @@ async def _process_direct_message(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a TEXT_MESSAGE (direct message) packet.
|
||||
@@ -644,6 +662,8 @@ async def _process_direct_message(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
|
||||
@@ -244,3 +244,51 @@ def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||
|
||||
return "".join(hops), len(hops), hash_size - 1
|
||||
|
||||
|
||||
async def bucket_path_hash_widths(cursor, *, batch_size: int = 500) -> dict[str, int | float]:
|
||||
"""Bucket raw packet rows by hop hash width and return counts + percentages.
|
||||
|
||||
*cursor* must be an already-executed async cursor whose rows have a ``data``
|
||||
column containing raw packet bytes.
|
||||
"""
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
|
||||
total = single_byte + double_byte + triple_byte
|
||||
if total == 0:
|
||||
return {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
"double_byte": 0,
|
||||
"triple_byte": 0,
|
||||
"single_byte_pct": 0.0,
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_packets": total,
|
||||
"single_byte": single_byte,
|
||||
"double_byte": double_byte,
|
||||
"triple_byte": triple_byte,
|
||||
"single_byte_pct": (single_byte / total) * 100,
|
||||
"double_byte_pct": (double_byte / total) * 100,
|
||||
"triple_byte_pct": (triple_byte / total) * 100,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
RepeaterTelemetryRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
@@ -155,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
|
||||
# more frequently than this.
|
||||
MIN_ADVERT_INTERVAL = 3600
|
||||
|
||||
# Periodic telemetry collection task handle
|
||||
_telemetry_collect_task: asyncio.Task | None = None
|
||||
|
||||
# Telemetry collection interval (8 hours)
|
||||
TELEMETRY_COLLECT_INTERVAL = 8 * 3600
|
||||
|
||||
# Initial delay before the first telemetry collection cycle (let radio settle)
|
||||
TELEMETRY_COLLECT_INITIAL_DELAY = 60
|
||||
|
||||
# Counter to pause polling during repeater operations (supports nested pauses)
|
||||
_polling_pause_count: int = 0
|
||||
|
||||
@@ -1524,3 +1534,158 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Periodic repeater telemetry collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
"""Fetch status telemetry from a single repeater and record it.
|
||||
|
||||
Returns True on success, False on failure (logged, not raised).
|
||||
"""
|
||||
try:
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Telemetry collect: radio command failed for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
if status is None:
|
||||
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
|
||||
return False
|
||||
|
||||
# Map to the same field names as the manual repeater status endpoint
|
||||
data = {
|
||||
"battery_volts": status.get("bat", 0) / 1000.0,
|
||||
"tx_queue_len": status.get("tx_queue_len", 0),
|
||||
"noise_floor_dbm": status.get("noise_floor", 0),
|
||||
"last_rssi_dbm": status.get("last_rssi", 0),
|
||||
"last_snr_db": status.get("last_snr", 0.0),
|
||||
"packets_received": status.get("nb_recv", 0),
|
||||
"packets_sent": status.get("nb_sent", 0),
|
||||
"airtime_seconds": status.get("airtime", 0),
|
||||
"rx_airtime_seconds": status.get("rx_airtime", 0),
|
||||
"uptime_seconds": status.get("uptime", 0),
|
||||
"sent_flood": status.get("sent_flood", 0),
|
||||
"sent_direct": status.get("sent_direct", 0),
|
||||
"recv_flood": status.get("recv_flood", 0),
|
||||
"recv_direct": status.get("recv_direct", 0),
|
||||
"flood_dups": status.get("flood_dups", 0),
|
||||
"direct_dups": status.get("direct_dups", 0),
|
||||
"full_events": status.get("full_evts", 0),
|
||||
}
|
||||
|
||||
try:
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=int(time.time()),
|
||||
data=data,
|
||||
)
|
||||
logger.info(
|
||||
"Telemetry collect: recorded snapshot for %s (%s)",
|
||||
contact.name or contact.public_key[:12],
|
||||
contact.public_key[:12],
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Telemetry collect: failed to record for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _telemetry_collect_loop() -> None:
|
||||
"""Background task that collects telemetry from tracked repeaters every 8 hours.
|
||||
|
||||
Runs a first cycle after a short initial delay (so newly tracked repeaters
|
||||
get a sample promptly), then sleeps the full interval between subsequent cycles.
|
||||
|
||||
Acquires the radio lock per-repeater (non-blocking) so manual operations can
|
||||
interleave. Failures are logged and skipped.
|
||||
"""
|
||||
first_run = True
|
||||
while True:
|
||||
try:
|
||||
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
|
||||
await asyncio.sleep(delay)
|
||||
first_run = False
|
||||
|
||||
if not radio_manager.is_connected:
|
||||
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
||||
continue
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
tracked = app_settings.tracked_telemetry_repeaters
|
||||
if not tracked:
|
||||
continue
|
||||
|
||||
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
|
||||
collected = 0
|
||||
|
||||
for pub_key in tracked:
|
||||
contact = await ContactRepository.get_by_key(pub_key)
|
||||
if not contact or contact.type != 2:
|
||||
logger.debug(
|
||||
"Telemetry collect: skipping %s (not found or not repeater)",
|
||||
pub_key[:12],
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"telemetry_collect",
|
||||
blocking=False,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
if await _collect_repeater_telemetry(mc, contact):
|
||||
collected += 1
|
||||
except RadioOperationBusyError:
|
||||
logger.debug(
|
||||
"Telemetry collect: radio busy, skipping %s",
|
||||
pub_key[:12],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Telemetry collect: cycle complete, %d/%d successful",
|
||||
collected,
|
||||
len(tracked),
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Telemetry collect task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_telemetry_collect() -> None:
|
||||
"""Start the periodic telemetry collection background task."""
|
||||
global _telemetry_collect_task
|
||||
if _telemetry_collect_task is None or _telemetry_collect_task.done():
|
||||
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
|
||||
logger.info(
|
||||
"Started periodic telemetry collection (interval: %ds)",
|
||||
TELEMETRY_COLLECT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_telemetry_collect() -> None:
|
||||
"""Stop the periodic telemetry collection background task."""
|
||||
global _telemetry_collect_task
|
||||
if _telemetry_collect_task and not _telemetry_collect_task.done():
|
||||
_telemetry_collect_task.cancel()
|
||||
try:
|
||||
await _telemetry_collect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_telemetry_collect_task = None
|
||||
logger.info("Stopped periodic telemetry collection")
|
||||
|
||||
+14
-26
@@ -26,7 +26,7 @@ class ChannelRepository:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
@@ -40,6 +40,7 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
return None
|
||||
@@ -48,7 +49,7 @@ class ChannelRepository:
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
@@ -61,30 +62,7 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_on_radio() -> list[Channel]:
|
||||
"""Return channels currently marked as resident on the radio in the database."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
WHERE on_radio = 1
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
@@ -123,6 +101,16 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
|
||||
"""Set or clear a channel's path hash mode override."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
|
||||
(path_hash_mode_override, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
|
||||
@@ -57,6 +57,8 @@ class MessageRepository:
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
@@ -78,6 +80,10 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": received_at}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
if rssi is not None:
|
||||
entry["rssi"] = rssi
|
||||
if snr is not None:
|
||||
entry["snr"] = snr
|
||||
paths_json = json.dumps([entry])
|
||||
|
||||
# Normalize sender_key to lowercase so queries can match without LOWER().
|
||||
@@ -116,6 +122,8 @@ class MessageRepository:
|
||||
path: str,
|
||||
received_at: int | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
@@ -129,6 +137,10 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": ts}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
if rssi is not None:
|
||||
entry["rssi"] = rssi
|
||||
if snr is not None:
|
||||
entry["snr"] = snr
|
||||
new_entry = json.dumps(entry)
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
@@ -786,12 +798,14 @@ class MessageRepository:
|
||||
|
||||
@staticmethod
|
||||
async def get_channel_stats(conversation_key: str) -> dict:
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders, path hash widths.
|
||||
|
||||
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
|
||||
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h, path_hash_width_24h.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
|
||||
now = int(_time.time())
|
||||
t_1h = now - 3600
|
||||
t_24h = now - 86400
|
||||
@@ -843,11 +857,24 @@ class MessageRepository:
|
||||
for r in top_rows
|
||||
]
|
||||
|
||||
# Path hash width distribution for last 24h (in-Python parse of raw packet envelopes)
|
||||
cursor3 = await db.conn.execute(
|
||||
"""
|
||||
SELECT rp.data FROM raw_packets rp
|
||||
JOIN messages m ON rp.message_id = m.id
|
||||
WHERE m.type = 'CHAN' AND m.conversation_key = ?
|
||||
AND rp.timestamp >= ?
|
||||
""",
|
||||
(conversation_key, t_24h),
|
||||
)
|
||||
path_hash_width_24h = await bucket_path_hash_widths(cursor3)
|
||||
|
||||
return {
|
||||
"message_counts": message_counts,
|
||||
"first_message_at": row["first_message_at"],
|
||||
"unique_sender_count": row["unique_sender_count"] or 0,
|
||||
"top_senders_24h": top_senders,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -172,12 +172,3 @@ class RawPacketRepository:
|
||||
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
||||
|
||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||
These are direct messages that can be decrypted with contact ECDH keys.
|
||||
"""
|
||||
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
|
||||
|
||||
+32
-83
@@ -5,7 +5,7 @@ from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, Favorite
|
||||
from app.path_utils import parse_packet_envelope
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,9 +27,10 @@ class AppSettingsRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
last_message_times, preferences_migrated,
|
||||
last_message_times,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names, discovery_blocked_types
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
tracked_telemetry_repeaters, auto_resend_channel
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -89,18 +90,34 @@ class AppSettingsRepository:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
discovery_blocked_types = []
|
||||
|
||||
# Parse tracked_telemetry_repeaters JSON
|
||||
tracked_telemetry_repeaters: list[str] = []
|
||||
try:
|
||||
raw_tracked = row["tracked_telemetry_repeaters"]
|
||||
if raw_tracked:
|
||||
tracked_telemetry_repeaters = json.loads(raw_tracked)
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
tracked_telemetry_repeaters = []
|
||||
|
||||
# Parse auto_resend_channel boolean
|
||||
try:
|
||||
auto_resend_channel = bool(row["auto_resend_channel"])
|
||||
except (KeyError, TypeError):
|
||||
auto_resend_channel = False
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||
auto_resend_channel=auto_resend_channel,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -109,13 +126,14 @@ class AppSettingsRepository:
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
flood_scope: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
discovery_blocked_types: list[int] | None = None,
|
||||
tracked_telemetry_repeaters: list[str] | None = None,
|
||||
auto_resend_channel: bool | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -138,10 +156,6 @@ class AppSettingsRepository:
|
||||
updates.append("last_message_times = ?")
|
||||
params.append(json.dumps(last_message_times))
|
||||
|
||||
if preferences_migrated is not None:
|
||||
updates.append("preferences_migrated = ?")
|
||||
params.append(1 if preferences_migrated else 0)
|
||||
|
||||
if advert_interval is not None:
|
||||
updates.append("advert_interval = ?")
|
||||
params.append(advert_interval)
|
||||
@@ -166,6 +180,14 @@ class AppSettingsRepository:
|
||||
updates.append("discovery_blocked_types = ?")
|
||||
params.append(json.dumps(discovery_blocked_types))
|
||||
|
||||
if tracked_telemetry_repeaters is not None:
|
||||
updates.append("tracked_telemetry_repeaters = ?")
|
||||
params.append(json.dumps(tracked_telemetry_repeaters))
|
||||
|
||||
if auto_resend_channel is not None:
|
||||
updates.append("auto_resend_channel = ?")
|
||||
params.append(1 if auto_resend_channel else 0)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
@@ -215,38 +237,6 @@ class AppSettingsRepository:
|
||||
new_names = settings.blocked_names + [name]
|
||||
return await AppSettingsRepository.update(blocked_names=new_names)
|
||||
|
||||
@staticmethod
|
||||
async def migrate_preferences_from_frontend(
|
||||
favorites: list[dict],
|
||||
sort_order: str,
|
||||
last_message_times: dict[str, int],
|
||||
) -> tuple[AppSettings, bool]:
|
||||
"""Migrate all preferences from frontend localStorage.
|
||||
|
||||
This is a one-time migration. If already migrated, returns current settings
|
||||
without overwriting. Returns (settings, did_migrate) tuple.
|
||||
"""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
if settings.preferences_migrated:
|
||||
# Already migrated, don't overwrite
|
||||
return settings, False
|
||||
|
||||
# Convert frontend favorites format to Favorite objects
|
||||
new_favorites = []
|
||||
for f in favorites:
|
||||
if f.get("type") in ("channel", "contact") and f.get("id"):
|
||||
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
|
||||
|
||||
# Update with migrated preferences and mark as migrated
|
||||
settings = await AppSettingsRepository.update(
|
||||
favorites=new_favorites,
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=True,
|
||||
)
|
||||
|
||||
return settings, True
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
@@ -334,48 +324,7 @@ class StatisticsRepository:
|
||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
while True:
|
||||
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
|
||||
total_packets = single_byte + double_byte + triple_byte
|
||||
if total_packets == 0:
|
||||
return {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
"double_byte": 0,
|
||||
"triple_byte": 0,
|
||||
"single_byte_pct": 0.0,
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_packets": total_packets,
|
||||
"single_byte": single_byte,
|
||||
"double_byte": double_byte,
|
||||
"triple_byte": triple_byte,
|
||||
"single_byte_pct": (single_byte / total_packets) * 100,
|
||||
"double_byte_pct": (double_byte / total_packets) * 100,
|
||||
"triple_byte_pct": (triple_byte / total_packets) * 100,
|
||||
}
|
||||
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
|
||||
@@ -60,6 +60,15 @@ class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChannelPathHashModeOverrideRequest(BaseModel):
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=2,
|
||||
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
|
||||
|
||||
def _derive_channel_identity(
|
||||
requested_name: str,
|
||||
request_key: str | None = None,
|
||||
@@ -206,6 +215,7 @@ async def get_channel_detail(key: str) -> ChannelDetail:
|
||||
first_message_at=stats["first_message_at"],
|
||||
unique_sender_count=stats["unique_sender_count"],
|
||||
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
||||
path_hash_width_24h=stats["path_hash_width_24h"],
|
||||
)
|
||||
|
||||
|
||||
@@ -348,6 +358,29 @@ async def set_channel_flood_scope_override(
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
|
||||
async def set_channel_path_hash_mode_override(
|
||||
key: str, request: ChannelPathHashModeOverrideRequest
|
||||
) -> Channel:
|
||||
"""Set or clear a per-channel path hash mode override."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
updated = await ChannelRepository.update_path_hash_mode_override(
|
||||
channel.key, request.path_hash_mode_override
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
@@ -428,7 +427,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||
than the radio's normal path_hash_mode setting.
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
@@ -487,7 +486,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
|
||||
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
"""Discover the current forward and return paths to a known contact."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
pubkey_prefix = contact.public_key[:12]
|
||||
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
from app.models import (
|
||||
Message,
|
||||
@@ -89,7 +88,7 @@ async def list_messages(
|
||||
@router.post("/direct", response_model=Message)
|
||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
"""Send a direct message to a contact."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# First check our database for the contact
|
||||
from app.repository import ContactRepository
|
||||
@@ -136,7 +135,7 @@ TEMP_RADIO_SLOT = 0
|
||||
@router.post("/channel", response_model=Message)
|
||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
"""Send a message to a channel."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.repository import ChannelRepository
|
||||
@@ -189,7 +188,7 @@ async def resend_channel_message(
|
||||
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
|
||||
new packet. Creates a new message row in the database. No time window restriction.
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
|
||||
+7
-11
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
ContactUpsert,
|
||||
@@ -24,6 +23,7 @@ from app.models import (
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.server_control import _monotonic
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
@@ -136,10 +136,6 @@ class RadioAdvertiseRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _better_signal(first: float | None, second: float | None) -> float | None:
|
||||
if first is None:
|
||||
return second
|
||||
@@ -338,7 +334,7 @@ async def _resolve_trace_hops(
|
||||
@router.get("/config", response_model=RadioConfigResponse)
|
||||
async def get_radio_config() -> RadioConfigResponse:
|
||||
"""Get the current radio configuration."""
|
||||
mc = require_connected()
|
||||
mc = radio_manager.require_connected()
|
||||
|
||||
info = mc.self_info
|
||||
if not info:
|
||||
@@ -370,7 +366,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
@router.patch("/config", response_model=RadioConfigResponse)
|
||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
"""Update radio configuration. Only provided fields will be updated."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||
try:
|
||||
@@ -392,7 +388,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
@router.put("/private-key")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(update.private_key)
|
||||
@@ -426,7 +422,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
||||
Returns:
|
||||
status: "ok" if sent successfully
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
||||
|
||||
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
||||
@@ -442,7 +438,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
||||
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
||||
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
||||
"""Run a short node-discovery sweep from the local radio."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
@@ -509,7 +505,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
||||
@router.post("/trace", response_model=RadioTraceResponse)
|
||||
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
|
||||
+10
-16
@@ -3,7 +3,6 @@ import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
AclEntry,
|
||||
@@ -28,7 +27,6 @@ from app.repository import ContactRepository, RepeaterTelemetryRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
batch_cli_fetch,
|
||||
extract_response_text,
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
send_contact_cli_command,
|
||||
@@ -48,10 +46,6 @@ router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _extract_response_text(event) -> str:
|
||||
return extract_response_text(event)
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
@@ -80,7 +74,7 @@ def _require_repeater(contact: Contact) -> None:
|
||||
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
||||
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt repeater login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -95,7 +89,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
|
||||
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
||||
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -170,7 +164,7 @@ async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEn
|
||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -199,7 +193,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
|
||||
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
||||
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -233,7 +227,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
||||
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -274,7 +268,7 @@ async def _batch_cli_fetch(
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -294,7 +288,7 @@ async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -318,7 +312,7 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
)
|
||||
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -336,7 +330,7 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR
|
||||
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
"""Fetch owner info and guest password from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -354,7 +348,7 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater or room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
require_server_capable_contact(contact)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_ROOM,
|
||||
AclEntry,
|
||||
@@ -28,7 +27,7 @@ def _require_room(contact) -> None:
|
||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt room-server login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
@@ -48,7 +47,7 @@ async def room_login(public_key: str, request: RepeaterLoginRequest) -> Repeater
|
||||
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
@@ -85,7 +84,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
@@ -114,7 +113,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL entries from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
|
||||
+68
-50
@@ -2,16 +2,18 @@ import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
@@ -51,6 +53,10 @@ class AppSettingsUpdate(BaseModel):
|
||||
"advertisements should not create new contacts"
|
||||
),
|
||||
)
|
||||
auto_resend_channel: bool | None = Field(
|
||||
default=None,
|
||||
description="Auto-resend channel messages once if no echo heard within 2 seconds",
|
||||
)
|
||||
|
||||
|
||||
class BlockKeyRequest(BaseModel):
|
||||
@@ -66,24 +72,17 @@ class FavoriteRequest(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class MigratePreferencesRequest(BaseModel):
|
||||
favorites: list[FavoriteRequest] = Field(
|
||||
default_factory=list,
|
||||
description="List of favorites from localStorage",
|
||||
)
|
||||
sort_order: str = Field(
|
||||
default="recent",
|
||||
description="Sort order preference from localStorage",
|
||||
)
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of conversation state keys to timestamps from localStorage",
|
||||
)
|
||||
class TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
|
||||
class MigratePreferencesResponse(BaseModel):
|
||||
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
|
||||
settings: AppSettings = Field(description="Current settings after migration attempt")
|
||||
class TrackedTelemetryResponse(BaseModel):
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
description="Current list of tracked repeater public keys"
|
||||
)
|
||||
names: dict[str, str] = Field(
|
||||
description="Map of public key to display name for tracked repeaters"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
@@ -127,6 +126,10 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
|
||||
kwargs["discovery_blocked_types"] = sorted(set(valid))
|
||||
|
||||
# Auto-resend channel
|
||||
if update.auto_resend_channel is not None:
|
||||
kwargs["auto_resend_channel"] = update.auto_resend_channel
|
||||
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
@@ -191,41 +194,56 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
|
||||
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
||||
"""Migrate all preferences from frontend localStorage to database.
|
||||
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
|
||||
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
|
||||
"""Toggle periodic telemetry collection for a repeater.
|
||||
|
||||
This is a one-time migration. If preferences have already been migrated,
|
||||
this endpoint will not overwrite them and will return migrated=false.
|
||||
|
||||
Call this on frontend startup to ensure preferences are moved to the database.
|
||||
After successful migration, the frontend should clear localStorage preferences.
|
||||
|
||||
Migrates:
|
||||
- favorites (remoteterm-favorites)
|
||||
- sort_order (remoteterm-sortOrder)
|
||||
- last_message_times (remoteterm-lastMessageTime)
|
||||
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
|
||||
the requested repeater is not already tracked.
|
||||
"""
|
||||
# Convert to dict format for the repository method
|
||||
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
|
||||
key = request.public_key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
current = settings.tracked_telemetry_repeaters
|
||||
|
||||
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
|
||||
favorites=frontend_favorites,
|
||||
sort_order=request.sort_order,
|
||||
last_message_times=request.last_message_times,
|
||||
)
|
||||
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||
names: dict[str, str] = {}
|
||||
for k in keys:
|
||||
contact = await ContactRepository.get_by_key(k)
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
if did_migrate:
|
||||
logger.info(
|
||||
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
|
||||
len(frontend_favorites),
|
||||
request.sort_order,
|
||||
len(request.last_message_times),
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
logger.info("Removing repeater %s from tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
else:
|
||||
logger.debug("Preferences already migrated, skipping")
|
||||
|
||||
return MigratePreferencesResponse(
|
||||
migrated=did_migrate,
|
||||
settings=settings,
|
||||
# Validate it's a repeater
|
||||
contact = await ContactRepository.get_by_key(key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(status_code=400, detail="Contact is not a repeater")
|
||||
|
||||
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
|
||||
names = await _resolve_names(current)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
|
||||
"tracked_telemetry_repeaters": current,
|
||||
"names": names,
|
||||
},
|
||||
)
|
||||
|
||||
new_list = current + [key]
|
||||
logger.info("Adding repeater %s to tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"""Shared direct-message ACK application logic."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import increment_ack_and_broadcast
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
from app.services.messages import BroadcastFn, increment_ack_and_broadcast
|
||||
|
||||
|
||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||
from app.repository import (
|
||||
@@ -14,6 +13,7 @@ from app.repository import (
|
||||
)
|
||||
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
|
||||
from app.services.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_message_model,
|
||||
build_message_paths,
|
||||
@@ -27,8 +27,6 @@ if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
_decrypted_dm_store_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@@ -144,6 +142,8 @@ async def _store_direct_message(
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
@@ -170,6 +170,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -189,6 +191,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -201,6 +205,8 @@ async def _store_direct_message(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
outgoing=outgoing,
|
||||
@@ -218,6 +224,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -232,7 +240,7 @@ async def _store_direct_message(
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr),
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
@@ -261,6 +269,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -311,6 +321,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time as _time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -9,10 +10,17 @@ from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import ResendChannelMessageResponse
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
||||
from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_stored_outgoing_channel_message,
|
||||
create_outgoing_channel_message,
|
||||
@@ -26,13 +34,20 @@ NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
|
||||
"Send command was issued to the radio, but no response was heard back. "
|
||||
"The message may or may not have sent successfully."
|
||||
)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
TrackAckFn = Callable[[str, int, int], bool]
|
||||
NowFn = Callable[[], float]
|
||||
OutgoingReservationKey = tuple[str, str, str]
|
||||
RetryTaskScheduler = Callable[[Any], Any]
|
||||
|
||||
# Channel echo watchdog: delay before checking for echoes
|
||||
ECHO_WATCHDOG_DELAY_SECONDS = 2.0
|
||||
|
||||
# Byte-perfect resend window (must match router's RESEND_WINDOW_SECONDS)
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
# Temp radio slot used by the router for channel sends
|
||||
WATCHDOG_TEMP_RADIO_SLOT = 0
|
||||
|
||||
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
|
||||
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
||||
|
||||
@@ -122,7 +137,7 @@ async def send_channel_message_with_effective_scope(
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
||||
"""Send a channel message, temporarily overriding flood scope and/or path hash mode."""
|
||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||
baseline_scope = ""
|
||||
|
||||
@@ -151,6 +166,36 @@ async def send_channel_message_with_effective_scope(
|
||||
),
|
||||
)
|
||||
|
||||
# Path hash mode per-channel override
|
||||
override_phm = channel.path_hash_mode_override
|
||||
baseline_phm = radio_manager.path_hash_mode
|
||||
apply_phm = (
|
||||
override_phm is not None
|
||||
and radio_manager.path_hash_mode_supported
|
||||
and override_phm != baseline_phm
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
logger.info(
|
||||
"Temporarily applying channel path_hash_mode override for %s: %d",
|
||||
channel.name,
|
||||
override_phm,
|
||||
)
|
||||
phm_result = await mc.commands.set_path_hash_mode(override_phm)
|
||||
if phm_result is not None and phm_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to apply channel path_hash_mode override for %s: %s",
|
||||
channel.name,
|
||||
phm_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to apply path hash mode override before {action_label}: "
|
||||
f"{phm_result.payload}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||
channel_key,
|
||||
@@ -254,6 +299,46 @@ async def send_channel_message_with_effective_scope(
|
||||
),
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
restored = False
|
||||
for attempt in range(3):
|
||||
try:
|
||||
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
|
||||
if restore_phm is not None and restore_phm.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Attempt %d/3: failed to restore path_hash_mode after sending to %s: %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
restore_phm.payload,
|
||||
)
|
||||
else:
|
||||
radio_manager.path_hash_mode = baseline_phm
|
||||
logger.debug(
|
||||
"Restored baseline path_hash_mode after channel send: %d",
|
||||
baseline_phm,
|
||||
)
|
||||
restored = True
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Attempt %d/3: exception restoring path_hash_mode after sending to %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
)
|
||||
if not restored:
|
||||
logger.error(
|
||||
"All 3 attempts to restore path_hash_mode failed for %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Path hash mode restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring path hash mode failed "
|
||||
f"after 3 attempts. The radio is still using a non-default hop "
|
||||
f"width. Set it back manually in Radio settings."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _extract_expected_ack_code(result: Any) -> str | None:
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
@@ -550,6 +635,85 @@ async def send_direct_message_to_contact(
|
||||
return message
|
||||
|
||||
|
||||
async def _channel_echo_watchdog(
|
||||
message_id: int,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""One-shot watchdog: if no echo heard after delay, attempt one byte-perfect resend.
|
||||
|
||||
Spawned as a fire-and-forget task after a channel send when auto_resend_channel is enabled.
|
||||
Uses non-blocking radio lock so it never stalls user actions.
|
||||
"""
|
||||
try:
|
||||
await asyncio.sleep(ECHO_WATCHDOG_DELAY_SECONDS)
|
||||
|
||||
msg = await MessageRepository.get_by_id(message_id)
|
||||
if not msg:
|
||||
return
|
||||
if msg.acked > 0:
|
||||
logger.debug(
|
||||
"Echo watchdog: message %d already has %d echo(s), skipping", message_id, msg.acked
|
||||
)
|
||||
return
|
||||
if msg.sender_timestamp is None:
|
||||
return
|
||||
|
||||
elapsed = int(_time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
logger.debug(
|
||||
"Echo watchdog: message %d outside resend window (%ds)", message_id, elapsed
|
||||
)
|
||||
return
|
||||
|
||||
channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Echo watchdog: no echo for message %d after %.0fs, attempting byte-perfect resend",
|
||||
message_id,
|
||||
ECHO_WATCHDOG_DELAY_SECONDS,
|
||||
)
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
# Strip sender name prefix to get the raw text for the radio
|
||||
async with radio_manager.radio_operation("echo_watchdog_resend", blocking=False) as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_to_send = msg.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=msg.conversation_key,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="echo watchdog resend",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=WATCHDOG_TEMP_RADIO_SLOT,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result is not None and result.type != EventType.ERROR:
|
||||
logger.info("Echo watchdog: resent message %d successfully", message_id)
|
||||
else:
|
||||
logger.debug("Echo watchdog: resend got no/error result for message %d", message_id)
|
||||
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Echo watchdog: radio busy, skipping resend for message %d", message_id)
|
||||
except Exception:
|
||||
logger.debug("Echo watchdog: resend failed for message %d", message_id, exc_info=True)
|
||||
|
||||
|
||||
async def send_channel_message_to_channel(
|
||||
*,
|
||||
channel,
|
||||
@@ -658,6 +822,22 @@ async def send_channel_message_to_channel(
|
||||
message_repository=message_repository,
|
||||
)
|
||||
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
||||
|
||||
# Spawn echo watchdog if auto-resend is enabled
|
||||
try:
|
||||
settings = await AppSettingsRepository.get()
|
||||
if settings.auto_resend_channel:
|
||||
asyncio.create_task(
|
||||
_channel_echo_watchdog(
|
||||
message_id=outgoing_message.id,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_fn,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass # Never let watchdog setup failure break the send
|
||||
|
||||
return outgoing_message
|
||||
|
||||
|
||||
|
||||
@@ -37,10 +37,16 @@ def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath] | None:
|
||||
"""Build the single-path list used by message payloads."""
|
||||
return (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
|
||||
[
|
||||
MessagePath(
|
||||
path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
@@ -166,6 +172,8 @@ async def reconcile_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
@@ -177,7 +185,9 @@ async def reconcile_duplicate_message(
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
|
||||
paths = await MessageRepository.add_path(
|
||||
existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
@@ -214,6 +224,8 @@ async def handle_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||
@@ -239,6 +251,8 @@ async def handle_duplicate_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -253,6 +267,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -276,6 +292,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
@@ -291,6 +309,8 @@ async def create_message_from_decrypted(
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -312,7 +332,7 @@ async def create_message_from_decrypted(
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr),
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
@@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
|
||||
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
start_telemetry_collect,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
@@ -241,6 +242,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
start_telemetry_collect()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
finally:
|
||||
|
||||
@@ -434,6 +434,18 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
|
||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
||||
Do not rely on old class-only layout assumptions.
|
||||
|
||||
### Canonical style reference
|
||||
|
||||
`SettingsLocalSection.tsx` contains a **ThemePreview** component with a collapsible "Canonical style reference" section. This is the authoritative catalog of text sizes, button variants, badge patterns, and interactive elements used throughout the app. **When adding or modifying UI, match the patterns shown there rather than inventing new ones.**
|
||||
|
||||
Key conventions documented in the reference:
|
||||
|
||||
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
|
||||
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
|
||||
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
|
||||
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
|
||||
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
- No authentication UI.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.7.1",
|
||||
"version": "3.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -156,6 +156,7 @@ export function App() {
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
} = useAppSettings();
|
||||
|
||||
// Keep user's name in ref for mention detection in WebSocket callback
|
||||
@@ -397,6 +398,7 @@ export function App() {
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSetChannelPathHashModeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handlePathDiscovery,
|
||||
@@ -527,6 +529,7 @@ export function App() {
|
||||
onDeleteContact: handleDeleteContact,
|
||||
onDeleteChannel: handleDeleteChannel,
|
||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo: handleOpenContactInfo,
|
||||
onOpenChannelInfo: handleOpenChannelInfo,
|
||||
onSenderClick: handleSenderClick,
|
||||
@@ -553,6 +556,8 @@ export function App() {
|
||||
);
|
||||
}
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -586,6 +591,8 @@ export function App() {
|
||||
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
|
||||
+14
-9
@@ -14,8 +14,6 @@ import type {
|
||||
MaintenanceResult,
|
||||
Message,
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
RawPacket,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -36,6 +34,7 @@ import type {
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
TrackedTelemetryResponse,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
@@ -210,6 +209,12 @@ export const api = {
|
||||
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
||||
}),
|
||||
|
||||
setChannelPathHashModeOverride: (key: string, pathHashModeOverride: number | null) =>
|
||||
fetchJson<Channel>(`/channels/${key}/path-hash-mode-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path_hash_mode_override: pathHashModeOverride }),
|
||||
}),
|
||||
|
||||
// Messages
|
||||
getMessages: (
|
||||
params?: {
|
||||
@@ -321,6 +326,13 @@ export const api = {
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// Tracked telemetry
|
||||
toggleTrackedTelemetry: (publicKey: string) =>
|
||||
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ public_key: publicKey }),
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||
@@ -328,13 +340,6 @@ export const api = {
|
||||
body: JSON.stringify({ type, id }),
|
||||
}),
|
||||
|
||||
// Preferences migration (one-time, from localStorage to database)
|
||||
migratePreferences: (request: MigratePreferencesRequest) =>
|
||||
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
|
||||
// Fanout
|
||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||
createFanoutConfig: (config: {
|
||||
|
||||
@@ -135,7 +135,7 @@ export function AppShell({
|
||||
aria-label="Settings"
|
||||
>
|
||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<h2 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
@@ -158,7 +158,7 @@ export function AppShell({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'w-full px-3 py-2 text-left text-[0.8125rem] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!disabled && 'hover:bg-accent',
|
||||
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
||||
)}
|
||||
|
||||
@@ -49,11 +49,13 @@ export function BulkAddChannelResultModal({
|
||||
{result && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Created
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{createdChannels.length}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Already Present
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
@@ -6,7 +7,7 @@ import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
||||
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
|
||||
|
||||
interface ChannelInfoPaneProps {
|
||||
channelKey: string | null;
|
||||
@@ -106,11 +107,11 @@ export function ChannelInfoPane({
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
|
||||
</span>
|
||||
{channel.on_radio && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
)}
|
||||
@@ -179,6 +180,14 @@ export function ChannelInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hop Byte Widths (24h) */}
|
||||
{detail && detail.path_hash_width_24h.total_packets > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Hop Byte Widths (24h)</SectionLabel>
|
||||
<HopWidthChart stats={detail.path_hash_width_24h} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Senders 24h */}
|
||||
{detail && detail.top_senders_24h.length > 0 && (
|
||||
<div className="px-5 py-3">
|
||||
@@ -212,7 +221,7 @@ export function ChannelInfoPane({
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
@@ -226,3 +235,82 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HOP_WIDTH_SEGMENTS = [
|
||||
{ key: 'single_byte', label: '1-byte', color: '#22c55e' },
|
||||
{ key: 'double_byte', label: '2-byte', color: '#0ea5e9' },
|
||||
{ key: 'triple_byte', label: '3-byte', color: '#8b5cf6' },
|
||||
] as const;
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function HopWidthChart({ stats }: { stats: PathHashWidthStats }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({
|
||||
name: label,
|
||||
value: stats[key] as number,
|
||||
color,
|
||||
})).filter((d) => d.value > 0),
|
||||
[stats]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={22}
|
||||
outerRadius={40}
|
||||
strokeWidth={1.5}
|
||||
stroke="hsl(var(--background))"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.name} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, name: any) => {
|
||||
const v = typeof value === 'number' ? value : Number(value);
|
||||
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
{data.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: d.color }}
|
||||
/>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex-1">{d.name}</span>
|
||||
<span className="text-[0.6875rem] font-medium tabular-nums">
|
||||
{d.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[0.625rem] text-muted-foreground pt-0.5">
|
||||
{stats.total_packets.toLocaleString()} total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
const PATH_HASH_MODE_LABELS: Record<number, string> = {
|
||||
0: '1-byte',
|
||||
1: '2-byte',
|
||||
2: '3-byte',
|
||||
};
|
||||
|
||||
interface ChannelPathHashModeOverrideModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
channelName: string;
|
||||
currentOverride: number | null;
|
||||
radioDefault: number;
|
||||
onSetOverride: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export function ChannelPathHashModeOverrideModal({
|
||||
open,
|
||||
onClose,
|
||||
channelName,
|
||||
currentOverride,
|
||||
radioDefault,
|
||||
onSetOverride,
|
||||
}: ChannelPathHashModeOverrideModalProps) {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(currentOverride);
|
||||
}
|
||||
}, [currentOverride, open]);
|
||||
|
||||
const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`;
|
||||
|
||||
const options: { value: number | null; label: string; description: string }[] = [
|
||||
{
|
||||
value: null,
|
||||
label: `Radio default (${radioDefaultLabel})`,
|
||||
description: 'Use the radio-wide path hash mode setting',
|
||||
},
|
||||
{
|
||||
value: 0,
|
||||
label: '1-byte hop identifiers',
|
||||
description: 'Shortest paths, least repeater disambiguation',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '2-byte hop identifiers',
|
||||
description: 'Better repeater disambiguation',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '3-byte hop identifiers',
|
||||
description: 'Best repeater disambiguation, longest paths',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Path Hop Width Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Override the path hash mode for this channel. Wider hop identifiers improve repeater
|
||||
disambiguation but extend send time and will prevent users on old (<1.14) firmware
|
||||
from receiving the message.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<div className="font-medium">{channelName}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Current override:{' '}
|
||||
{currentOverride != null
|
||||
? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`)
|
||||
: `none (using radio default: ${radioDefaultLabel})`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hop width for this channel</Label>
|
||||
<div className="space-y-1.5">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected === opt.value
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setSelected(opt.value)}
|
||||
>
|
||||
<div className="font-medium">{opt.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{opt.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:block sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSetOverride(selected);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{selected == null
|
||||
? `Use radio default for ${channelName}`
|
||||
: `Use ${PATH_HASH_MODE_LABELS[selected]} hops for ${channelName}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
@@ -36,6 +37,7 @@ interface ChatHeaderProps {
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
@@ -56,6 +58,7 @@ export function ChatHeader({
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onDeleteChannel,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
@@ -64,11 +67,13 @@ export function ChatHeader({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
setPathDiscoveryOpen(false);
|
||||
setChannelOverrideOpen(false);
|
||||
setPathHashModeOverrideOpen(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
const activeChannel =
|
||||
@@ -81,6 +86,12 @@ export function ChatHeader({
|
||||
? stripRegionScopePrefix(activeFloodScopeOverride)
|
||||
: null;
|
||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||
const activePathHashModeOverride =
|
||||
conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null;
|
||||
const showPathHashModeOverride =
|
||||
conversation.type === 'channel' &&
|
||||
onSetChannelPathHashModeOverride &&
|
||||
config?.path_hash_mode_supported;
|
||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||
const activeContact =
|
||||
conversation.type === 'contact'
|
||||
@@ -108,6 +119,11 @@ export function ChatHeader({
|
||||
setChannelOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPathHashModeOverride = () => {
|
||||
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
|
||||
setPathHashModeOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenConversationInfo = () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
@@ -182,7 +198,7 @@ export function ChatHeader({
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
className="min-w-0 flex-shrink text-[0.6875rem] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
@@ -193,7 +209,7 @@ export function ChatHeader({
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
@@ -228,7 +244,7 @@ export function ChatHeader({
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||
<span className="min-w-0 truncate text-[0.6875rem] font-medium text-[hsl(var(--region-override))]">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
</button>
|
||||
@@ -237,7 +253,7 @@ export function ChatHeader({
|
||||
</span>
|
||||
</span>
|
||||
{conversation.type === 'contact' && activeContact && (
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<ContactStatusInfo
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
@@ -299,7 +315,7 @@ export function ChatHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
@@ -317,12 +333,25 @@ export function ChatHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{activeFloodScopeDisplay && (
|
||||
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
|
||||
<span className="hidden text-[0.6875rem] font-medium text-[hsl(var(--region-override))] sm:inline">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{showPathHashModeOverride && (
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={handleEditPathHashModeOverride}
|
||||
title="Set path hop width override"
|
||||
aria-label="Set path hop width override"
|
||||
>
|
||||
<ChevronsLeftRight
|
||||
className={`h-4 w-4 ${activePathHashModeOverride != null ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@@ -379,6 +408,16 @@ export function ChatHeader({
|
||||
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
|
||||
/>
|
||||
)}
|
||||
{showPathHashModeOverride && (
|
||||
<ChannelPathHashModeOverrideModal
|
||||
open={pathHashModeOverrideOpen}
|
||||
onClose={() => setPathHashModeOverrideOpen(false)}
|
||||
channelName={conversation.name}
|
||||
currentOverride={activePathHashModeOverride}
|
||||
radioDefault={config?.path_hash_mode ?? 0}
|
||||
onSetOverride={(value) => onSetChannelPathHashModeOverride(conversation.id, value)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ export function ContactInfoPane({
|
||||
{contact.public_key}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -568,7 +568,7 @@ export function ContactInfoPane({
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
@@ -729,7 +729,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
|
||||
slots.
|
||||
{!analytics.includes_direct_messages &&
|
||||
@@ -821,7 +821,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
|
||||
{legendItems && (
|
||||
<Legend
|
||||
content={() => (
|
||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
|
||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[0.6875rem] text-muted-foreground">
|
||||
{legendItems.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
|
||||
@@ -74,12 +74,12 @@ function RouteCard({
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold">{label}</h4>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
<span className="text-[0.6875rem] text-muted-foreground">
|
||||
{formatRouteLabel(route.path_len, true)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">{chain}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] text-muted-foreground">
|
||||
<span>Raw: {rawPath}</span>
|
||||
<span>{formatPathHashMode(route.path_hash_mode)}</span>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,10 @@ interface ConversationPaneProps {
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||
onSetChannelPathHashModeOverride?: (
|
||||
channelKey: string,
|
||||
pathHashModeOverride: number | null
|
||||
) => Promise<void>;
|
||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
onOpenChannelInfo: (channelKey: string) => void;
|
||||
onSenderClick: (sender: string) => void;
|
||||
@@ -75,6 +79,8 @@ interface ConversationPaneProps {
|
||||
onDismissUnreadMarker: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -131,6 +137,7 @@ export function ConversationPane({
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
onSenderClick,
|
||||
@@ -143,6 +150,8 @@ export function ConversationPane({
|
||||
onDismissUnreadMarker,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -182,7 +191,12 @@ export function ConversationPane({
|
||||
</h2>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
||||
<MapView
|
||||
contacts={contacts}
|
||||
focusedKey={activeConversation.mapFocusKey}
|
||||
rawPackets={rawPackets}
|
||||
config={config}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
@@ -236,6 +250,8 @@ export function ConversationPane({
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -259,6 +275,7 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, Polyline } from 'react-leaflet';
|
||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Contact } from '../types';
|
||||
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import {
|
||||
parsePacket,
|
||||
getPacketLabel,
|
||||
PARTICLE_COLOR_MAP,
|
||||
dedupeConsecutive,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
|
||||
interface MapViewProps {
|
||||
contacts: Contact[];
|
||||
/** Public key of contact to focus on and open popup */
|
||||
focusedKey?: string | null;
|
||||
rawPackets?: RawPacket[];
|
||||
config?: RadioConfig | null;
|
||||
}
|
||||
|
||||
// --- Tile layer presets ---
|
||||
const TILE_LIGHT = {
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
background: '#1a1a2e',
|
||||
};
|
||||
const TILE_DARK = {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
background: '#0d0d0d',
|
||||
};
|
||||
|
||||
function getSavedDarkMap(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const MAP_RECENCY_COLORS = {
|
||||
@@ -22,7 +53,16 @@ const MAP_RECENCY_COLORS = {
|
||||
const MAP_MARKER_STROKE = '#0f172a';
|
||||
const MAP_REPEATER_RING = '#f8fafc';
|
||||
|
||||
// Calculate marker color based on how recently the contact was heard
|
||||
// --- Packet visualization constants ---
|
||||
const THREE_DAYS_SEC = 3 * 24 * 60 * 60;
|
||||
const PARTICLE_LIFETIME_MS = 3000;
|
||||
const PARTICLE_TAIL_LENGTH = 0.25; // fraction of progress to trail behind
|
||||
const PARTICLE_RADIUS = 8;
|
||||
const PARTICLE_TAIL_WIDTH = 5;
|
||||
const MAX_MAP_PARTICLES = 200;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
||||
const now = Date.now() / 1000;
|
||||
@@ -36,7 +76,85 @@ function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||
return MAP_RECENCY_COLORS.old;
|
||||
}
|
||||
|
||||
// Component to handle map bounds fitting
|
||||
/** Resolve a hop token to a single contact with GPS, or null. */
|
||||
function resolveHopToGps(hopToken: string, prefixIndex: Map<string, Contact[]>): Contact | null {
|
||||
const matches = prefixIndex.get(hopToken.toLowerCase());
|
||||
if (!matches || matches.length !== 1) return null;
|
||||
const c = matches[0];
|
||||
return isValidLocation(c.lat, c.lon) ? c : null;
|
||||
}
|
||||
|
||||
/** Resolve a contact by display name (for GroupText senders). */
|
||||
function resolveNameToGps(name: string, nameIndex: Map<string, Contact>): Contact | null {
|
||||
const c = nameIndex.get(name);
|
||||
if (!c) return null;
|
||||
return isValidLocation(c.lat, c.lon) ? c : null;
|
||||
}
|
||||
|
||||
/** Collect public keys of all unambiguously resolved GPS-bearing contacts from a parsed packet. */
|
||||
function resolvePacketContacts(
|
||||
parsed: ReturnType<typeof parsePacket>,
|
||||
prefixIndex: Map<string, Contact[]>,
|
||||
nameIndex: Map<string, Contact>,
|
||||
myLatLon: [number, number] | null,
|
||||
config?: RadioConfig | null
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
if (!parsed) return keys;
|
||||
|
||||
// Source by pubkey prefix
|
||||
const sourcePrefixes = parsed.advertPubkey
|
||||
? [parsed.advertPubkey.slice(0, 12).toLowerCase()]
|
||||
: parsed.srcHash
|
||||
? [parsed.srcHash.toLowerCase()]
|
||||
: [];
|
||||
for (const prefix of sourcePrefixes) {
|
||||
const matches = prefixIndex.get(prefix);
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
keys.add(matches[0].public_key);
|
||||
}
|
||||
}
|
||||
|
||||
// Source by name (GroupText sender)
|
||||
if (parsed.groupTextSender) {
|
||||
const c = resolveNameToGps(parsed.groupTextSender, nameIndex);
|
||||
if (c) keys.add(c.public_key);
|
||||
}
|
||||
|
||||
// Intermediate hops
|
||||
for (const hop of parsed.pathBytes) {
|
||||
if (hop.length < 4) continue;
|
||||
const matches = prefixIndex.get(hop.toLowerCase());
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
keys.add(matches[0].public_key);
|
||||
}
|
||||
}
|
||||
|
||||
// Self
|
||||
if (myLatLon && config?.public_key) {
|
||||
keys.add(config.public_key.toLowerCase());
|
||||
}
|
||||
|
||||
// Destination
|
||||
if (parsed.dstHash) {
|
||||
const matches = prefixIndex.get(parsed.dstHash.toLowerCase());
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
keys.add(matches[0].public_key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
interface MapParticle {
|
||||
id: number;
|
||||
path: [number, number][]; // lat/lon waypoints
|
||||
color: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
// --- Map bounds handler ---
|
||||
|
||||
function MapBoundsHandler({
|
||||
contacts,
|
||||
focusedContact,
|
||||
@@ -48,7 +166,6 @@ function MapBoundsHandler({
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If we have a focused contact, center on it immediately (even if already initialized)
|
||||
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
|
||||
map.setView([focusedContact.lat, focusedContact.lon], 12);
|
||||
setHasInitialized(true);
|
||||
@@ -59,20 +176,17 @@ function MapBoundsHandler({
|
||||
|
||||
const fitToContacts = () => {
|
||||
if (contacts.length === 0) {
|
||||
// No contacts with location - show world view
|
||||
map.setView([20, 0], 2);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts.length === 1) {
|
||||
// Single contact - center on it
|
||||
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple contacts - fit bounds
|
||||
const bounds: LatLngBoundsExpression = contacts.map(
|
||||
(c) => [c.lat!, c.lon!] as [number, number]
|
||||
);
|
||||
@@ -80,22 +194,18 @@ function MapBoundsHandler({
|
||||
setHasInitialized(true);
|
||||
};
|
||||
|
||||
// Try geolocation first
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
// Success - center on user location with reasonable zoom
|
||||
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
||||
setHasInitialized(true);
|
||||
},
|
||||
() => {
|
||||
// Geolocation denied/failed - fit to contacts
|
||||
fitToContacts();
|
||||
},
|
||||
{ timeout: 5000, maximumAge: 300000 }
|
||||
);
|
||||
} else {
|
||||
// No geolocation support - fit to contacts
|
||||
fitToContacts();
|
||||
}
|
||||
}, [map, contacts, hasInitialized, focusedContact]);
|
||||
@@ -103,18 +213,404 @@ function MapBoundsHandler({
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
||||
// --- Canvas particle overlay ---
|
||||
|
||||
// Filter to contacts with GPS coordinates, heard within the last 7 days.
|
||||
// Always include the focused contact so "view on map" links work for older nodes.
|
||||
function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
|
||||
const map = useMap();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '450'; // above tiles, below popups
|
||||
container.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
const resize = () => {
|
||||
const size = map.getSize();
|
||||
canvas.width = size.x * window.devicePixelRatio;
|
||||
canvas.height = size.y * window.devicePixelRatio;
|
||||
canvas.style.width = `${size.x}px`;
|
||||
canvas.style.height = `${size.y}px`;
|
||||
};
|
||||
resize();
|
||||
map.on('resize', resize);
|
||||
map.on('zoom', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
map.off('resize', resize);
|
||||
map.off('zoom', resize);
|
||||
container.removeChild(canvas);
|
||||
canvasRef.current = null;
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const draw = () => {
|
||||
const now = Date.now();
|
||||
const dpr = window.devicePixelRatio;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
for (const particle of particles) {
|
||||
const elapsed = now - particle.startedAt;
|
||||
if (elapsed < 0 || elapsed > PARTICLE_LIFETIME_MS) continue;
|
||||
const progress = elapsed / PARTICLE_LIFETIME_MS;
|
||||
const path = particle.path;
|
||||
if (path.length < 2) continue;
|
||||
|
||||
// Calculate total path length in pixels for even speed
|
||||
const pixelPath = path.map((ll) => map.latLngToContainerPoint(L.latLng(ll[0], ll[1])));
|
||||
const segLengths: number[] = [];
|
||||
let totalLen = 0;
|
||||
for (let i = 1; i < pixelPath.length; i++) {
|
||||
const dx = pixelPath[i].x - pixelPath[i - 1].x;
|
||||
const dy = pixelPath[i].y - pixelPath[i - 1].y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
segLengths.push(len);
|
||||
totalLen += len;
|
||||
}
|
||||
if (totalLen === 0) continue;
|
||||
|
||||
// Interpolate head position
|
||||
const headDist = progress * totalLen;
|
||||
const tailDist = Math.max(0, headDist - PARTICLE_TAIL_LENGTH * totalLen);
|
||||
|
||||
const pointAtDist = (d: number): { x: number; y: number } => {
|
||||
let accum = 0;
|
||||
for (let i = 0; i < segLengths.length; i++) {
|
||||
if (accum + segLengths[i] >= d) {
|
||||
const t = segLengths[i] > 0 ? (d - accum) / segLengths[i] : 0;
|
||||
return {
|
||||
x: pixelPath[i].x + (pixelPath[i + 1].x - pixelPath[i].x) * t,
|
||||
y: pixelPath[i].y + (pixelPath[i + 1].y - pixelPath[i].y) * t,
|
||||
};
|
||||
}
|
||||
accum += segLengths[i];
|
||||
}
|
||||
const last = pixelPath[pixelPath.length - 1];
|
||||
return { x: last.x, y: last.y };
|
||||
};
|
||||
|
||||
const head = pointAtDist(headDist);
|
||||
const tail = pointAtDist(tailDist);
|
||||
|
||||
// Draw tail as a gradient line from transparent to opaque
|
||||
const grad = ctx.createLinearGradient(tail.x, tail.y, head.x, head.y);
|
||||
grad.addColorStop(0, particle.color + '00');
|
||||
grad.addColorStop(1, particle.color + 'cc');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tail.x, tail.y);
|
||||
|
||||
// Sample intermediate points along the tail for curved paths
|
||||
const steps = 8;
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const d = tailDist + ((headDist - tailDist) * s) / steps;
|
||||
const pt = pointAtDist(d);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = PARTICLE_TAIL_WIDTH;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// Draw head blob with glow
|
||||
const fade = progress > 0.8 ? 1 - (progress - 0.8) / 0.2 : 1;
|
||||
const alpha = Math.round(fade * 230)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
// Outer glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, PARTICLE_RADIUS + 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle =
|
||||
particle.color +
|
||||
Math.round(fade * 40)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
ctx.fill();
|
||||
// Core blob
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, PARTICLE_RADIUS, 0, Math.PI * 2);
|
||||
ctx.fillStyle = particle.color + alpha;
|
||||
ctx.shadowColor = particle.color;
|
||||
ctx.shadowBlur = 12 * fade;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
// Bright center
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, PARTICLE_RADIUS * 0.4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffffff' + alpha;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [map, particles]);
|
||||
|
||||
// Redraw on map move/zoom
|
||||
useEffect(() => {
|
||||
const redraw = () => {}; // Animation loop already redraws every frame
|
||||
map.on('move', redraw);
|
||||
map.on('zoom', redraw);
|
||||
return () => {
|
||||
map.off('move', redraw);
|
||||
map.off('zoom', redraw);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewProps) {
|
||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
||||
const [darkMap, setDarkMap] = useState(getSavedDarkMap);
|
||||
const tile = darkMap ? TILE_DARK : TILE_LIGHT;
|
||||
|
||||
// Sync with settings changes from other components
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === 'remoteterm-dark-map') setDarkMap(e.newValue === 'true');
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const [showPackets, setShowPackets] = useState(false);
|
||||
const [discoveryMode, setDiscoveryMode] = useState(false);
|
||||
const [discoveredKeys, setDiscoveredKeys] = useState<Set<string>>(new Set());
|
||||
const [particles, setParticles] = useState<MapParticle[]>([]);
|
||||
const particleIdRef = useRef(0);
|
||||
const seenObservationsRef = useRef(new Set<string>());
|
||||
|
||||
// Build prefix index and name index for hop resolution
|
||||
const { prefixIndex, nameIndex } = useMemo(() => {
|
||||
const prefix = new Map<string, Contact[]>();
|
||||
const name = new Map<string, Contact>();
|
||||
for (const c of contacts) {
|
||||
const pubkey = c.public_key.toLowerCase();
|
||||
for (let len = 1; len <= 12 && len <= pubkey.length; len++) {
|
||||
const p = pubkey.slice(0, len);
|
||||
const arr = prefix.get(p);
|
||||
if (arr) arr.push(c);
|
||||
else prefix.set(p, [c]);
|
||||
}
|
||||
if (c.name && !name.has(c.name)) name.set(c.name, c);
|
||||
}
|
||||
return { prefixIndex: prefix, nameIndex: name };
|
||||
}, [contacts]);
|
||||
|
||||
// Self GPS
|
||||
const myLatLon = useMemo<[number, number] | null>(() => {
|
||||
if (!config || !isValidLocation(config.lat, config.lon)) return null;
|
||||
return [config.lat, config.lon];
|
||||
}, [config]);
|
||||
|
||||
// Determine time window for packet visualization
|
||||
const threeDaysAgoSec = useMemo(() => Date.now() / 1000 - THREE_DAYS_SEC, []);
|
||||
|
||||
// Filter contacts for map display
|
||||
const mappableContacts = useMemo(() => {
|
||||
if (showPackets && discoveryMode) {
|
||||
// Discovery mode: only show nodes that have appeared in resolved packets
|
||||
return contacts.filter(
|
||||
(c) => isValidLocation(c.lat, c.lon) && discoveredKeys.has(c.public_key)
|
||||
);
|
||||
}
|
||||
if (showPackets) {
|
||||
// Packet mode: show only last 3 days
|
||||
return contacts.filter(
|
||||
(c) =>
|
||||
isValidLocation(c.lat, c.lon) &&
|
||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > threeDaysAgoSec))
|
||||
);
|
||||
}
|
||||
return contacts.filter(
|
||||
(c) =>
|
||||
isValidLocation(c.lat, c.lon) &&
|
||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
|
||||
);
|
||||
}, [contacts, focusedKey, sevenDaysAgo]);
|
||||
}, [
|
||||
contacts,
|
||||
focusedKey,
|
||||
sevenDaysAgo,
|
||||
threeDaysAgoSec,
|
||||
showPackets,
|
||||
discoveryMode,
|
||||
discoveredKeys,
|
||||
]);
|
||||
|
||||
// Resolve a path of hop tokens to geographic waypoints (only unambiguous + has GPS)
|
||||
const resolvePacketPath = useCallback(
|
||||
(parsed: ReturnType<typeof parsePacket>): [number, number][] | null => {
|
||||
if (!parsed) return null;
|
||||
|
||||
const waypoints: [number, number][] = [];
|
||||
|
||||
// Source: advertPubkey, srcHash, or groupTextSender resolved by name
|
||||
let sourceContact: Contact | null = null;
|
||||
if (parsed.advertPubkey) {
|
||||
const prefix = parsed.advertPubkey.slice(0, 12).toLowerCase();
|
||||
const matches = prefixIndex.get(prefix);
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
sourceContact = matches[0];
|
||||
}
|
||||
} else if (parsed.srcHash) {
|
||||
sourceContact = resolveHopToGps(parsed.srcHash, prefixIndex);
|
||||
} else if (parsed.groupTextSender) {
|
||||
sourceContact = resolveNameToGps(parsed.groupTextSender, nameIndex);
|
||||
}
|
||||
|
||||
if (sourceContact) {
|
||||
waypoints.push([sourceContact.lat!, sourceContact.lon!]);
|
||||
}
|
||||
|
||||
// Intermediate hops (path bytes)
|
||||
for (const hop of parsed.pathBytes) {
|
||||
// Only resolve 2+ byte hops (4+ hex chars) to avoid ambiguous 1-byte hops
|
||||
if (hop.length < 4) continue;
|
||||
const contact = resolveHopToGps(hop, prefixIndex);
|
||||
if (contact) {
|
||||
waypoints.push([contact.lat!, contact.lon!]);
|
||||
}
|
||||
}
|
||||
|
||||
// Destination: self (our radio), or dstHash
|
||||
if (myLatLon) {
|
||||
waypoints.push(myLatLon);
|
||||
} else if (parsed.dstHash) {
|
||||
const dest = resolveHopToGps(parsed.dstHash, prefixIndex);
|
||||
if (dest) {
|
||||
waypoints.push([dest.lat!, dest.lon!]);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe consecutive identical waypoints
|
||||
const deduped = dedupeConsecutive(waypoints.map((w) => `${w[0]},${w[1]}`));
|
||||
if (deduped.length < 2) return null;
|
||||
|
||||
return deduped.map((s) => {
|
||||
const [lat, lon] = s.split(',').map(Number);
|
||||
return [lat, lon] as [number, number];
|
||||
});
|
||||
},
|
||||
[prefixIndex, nameIndex, myLatLon]
|
||||
);
|
||||
|
||||
// Process new packets into particles and track discovered contacts
|
||||
useEffect(() => {
|
||||
if (!showPackets || !rawPackets?.length) return;
|
||||
|
||||
const now = Date.now();
|
||||
const newParticles: MapParticle[] = [];
|
||||
const newDiscovered = new Set<string>();
|
||||
|
||||
for (const pkt of rawPackets) {
|
||||
// Skip old packets
|
||||
if (pkt.timestamp < threeDaysAgoSec) continue;
|
||||
|
||||
// Deduplicate by observation
|
||||
const obsKey = getRawPacketObservationKey(pkt);
|
||||
if (seenObservationsRef.current.has(obsKey)) continue;
|
||||
|
||||
const parsed = parsePacket(pkt.data);
|
||||
if (!parsed) continue;
|
||||
|
||||
// Discover contacts from this packet regardless of whether a full path resolves
|
||||
const resolvedContacts = resolvePacketContacts(
|
||||
parsed,
|
||||
prefixIndex,
|
||||
nameIndex,
|
||||
myLatLon,
|
||||
config
|
||||
);
|
||||
const path = resolvePacketPath(parsed);
|
||||
|
||||
// Only mark as seen if we got something useful; otherwise a later run
|
||||
// with updated contacts/config can retry this observation.
|
||||
if (resolvedContacts.size === 0 && !path) continue;
|
||||
seenObservationsRef.current.add(obsKey);
|
||||
|
||||
for (const key of resolvedContacts) newDiscovered.add(key);
|
||||
|
||||
if (path) {
|
||||
newParticles.push({
|
||||
id: particleIdRef.current++,
|
||||
path,
|
||||
color: PARTICLE_COLOR_MAP[getPacketLabel(parsed.payloadType)],
|
||||
startedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newDiscovered.size > 0) {
|
||||
setDiscoveredKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const k of newDiscovered) next.add(k);
|
||||
return next.size !== prev.size ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (newParticles.length === 0) return;
|
||||
|
||||
setParticles((prev) => {
|
||||
const combined = [...prev, ...newParticles];
|
||||
// Prune expired and cap total
|
||||
const alive = combined.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS);
|
||||
return alive.slice(-MAX_MAP_PARTICLES);
|
||||
});
|
||||
}, [
|
||||
rawPackets,
|
||||
showPackets,
|
||||
resolvePacketPath,
|
||||
threeDaysAgoSec,
|
||||
prefixIndex,
|
||||
nameIndex,
|
||||
myLatLon,
|
||||
config,
|
||||
]);
|
||||
|
||||
// Prune expired particles periodically
|
||||
useEffect(() => {
|
||||
if (!showPackets) return;
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setParticles((prev) => prev.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [showPackets]);
|
||||
|
||||
// Reset discovered set when exiting discovery mode
|
||||
useEffect(() => {
|
||||
if (!discoveryMode) setDiscoveredKeys(new Set());
|
||||
}, [discoveryMode]);
|
||||
|
||||
// Clear state when toggling off
|
||||
useEffect(() => {
|
||||
if (!showPackets) {
|
||||
setParticles([]);
|
||||
setDiscoveredKeys(new Set());
|
||||
setDiscoveryMode(false);
|
||||
seenObservationsRef.current.clear();
|
||||
}
|
||||
}, [showPackets]);
|
||||
|
||||
// Find the focused contact by key
|
||||
const focusedContact = useMemo(() => {
|
||||
@@ -124,18 +620,17 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
|
||||
const includesFocusedOutsideWindow =
|
||||
focusedContact != null &&
|
||||
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
|
||||
(focusedContact.last_seen == null ||
|
||||
focusedContact.last_seen <= (showPackets ? threeDaysAgoSec : sevenDaysAgo));
|
||||
|
||||
// Track marker refs to open popup programmatically
|
||||
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
||||
|
||||
// Store ref for a marker
|
||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||
if (ref === null) {
|
||||
delete markerRefs.current[key];
|
||||
return;
|
||||
}
|
||||
|
||||
markerRefs.current[key] = ref;
|
||||
}, []);
|
||||
|
||||
@@ -148,10 +643,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
}
|
||||
}, [mappableContacts]);
|
||||
|
||||
// Open popup for focused contact after map is ready
|
||||
useEffect(() => {
|
||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||
// Small delay to ensure map has finished rendering
|
||||
const timer = setTimeout(() => {
|
||||
markerRefs.current[focusedContact.public_key]?.openPopup();
|
||||
}, 100);
|
||||
@@ -159,48 +652,104 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
}
|
||||
}, [focusedContact]);
|
||||
|
||||
// Gather unique link paths for static route lines when packet viz is on
|
||||
const routeLines = useMemo(() => {
|
||||
if (!showPackets) return [];
|
||||
const seen = new Set<string>();
|
||||
const lines: { path: [number, number][]; color: string }[] = [];
|
||||
for (const p of particles) {
|
||||
const key = p.path.map((w) => `${w[0]},${w[1]}`).join('|');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
lines.push({ path: p.path, color: p.color });
|
||||
}
|
||||
return lines;
|
||||
}, [showPackets, particles]);
|
||||
|
||||
const timeWindowLabel = showPackets ? '3 days' : '7 days';
|
||||
const infoLabel =
|
||||
showPackets && discoveryMode
|
||||
? `${mappableContacts.length} node${mappableContacts.length !== 1 ? 's' : ''} discovered from live traffic`
|
||||
: `Showing ${mappableContacts.length} contact${mappableContacts.length !== 1 ? 's' : ''} heard in the last ${timeWindowLabel}${includesFocusedOutsideWindow ? ' plus the focused contact' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Info bar */}
|
||||
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
|
||||
in the last 7 days
|
||||
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
|
||||
</span>
|
||||
<span>{infoLabel}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
{!showPackets && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{showPackets && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['AD'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Ad
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['GT'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Ch
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['DM'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
DM
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['ACK'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
ACK
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border-2"
|
||||
@@ -209,10 +758,30 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
/>{' '}
|
||||
repeater
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer ml-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPackets}
|
||||
onChange={(e) => setShowPackets(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-[0.6875rem]">Visualize packets</span>
|
||||
</label>
|
||||
{showPackets && (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={discoveryMode}
|
||||
onChange={(e) => setDiscoveryMode(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-[0.6875rem]">Discover nodes</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map - z-index constrained to stay below modals/sheets */}
|
||||
{/* Map */}
|
||||
<div
|
||||
className="flex-1 relative"
|
||||
style={{ zIndex: 0 }}
|
||||
@@ -223,14 +792,21 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
className="h-full w-full"
|
||||
style={{ background: '#1a1a2e' }}
|
||||
style={{ background: tile.background }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} />
|
||||
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
|
||||
|
||||
{/* Faint route lines for active packet paths */}
|
||||
{showPackets &&
|
||||
routeLines.map((line, i) => (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={line.path}
|
||||
pathOptions={{ color: line.color, weight: 1, opacity: 0.15, dashArray: '4 6' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{mappableContacts.map((contact) => {
|
||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||
const color = getMarkerColor(contact.last_seen);
|
||||
@@ -275,6 +851,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{showPackets && <ParticleOverlay particles={particles} />}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,8 +220,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
|
||||
const className =
|
||||
variant === 'header'
|
||||
? 'font-normal text-muted-foreground ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
|
||||
: 'text-[10px] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||
? 'font-normal text-muted-foreground ml-1 text-[0.6875rem] cursor-pointer hover:text-primary hover:underline'
|
||||
: 'text-[0.625rem] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -300,6 +300,9 @@ export function MessageList({
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
||||
const packetSignalOverrideRef = useRef<{ rssi: number | null; snr: number | null } | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||
| { kind: 'packet'; packet: RawPacket }
|
||||
| { kind: 'loading'; message: string }
|
||||
@@ -325,6 +328,13 @@ export function MessageList({
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||
// Extract signal from the first path if available
|
||||
const firstPath = message.paths?.[0];
|
||||
packetSignalOverrideRef.current =
|
||||
firstPath && (firstPath.rssi != null || firstPath.snr != null)
|
||||
? { rssi: firstPath.rssi ?? null, snr: firstPath.snr ?? null }
|
||||
: undefined;
|
||||
|
||||
if (message.packet_id == null) {
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
@@ -965,7 +975,7 @@ export function MessageList({
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
||||
<div className="text-[0.8125rem] font-semibold text-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
@@ -980,7 +990,7 @@ export function MessageList({
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
@@ -1008,7 +1018,7 @@ export function MessageList({
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
<span className="text-[0.625rem] text-muted-foreground ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
@@ -1180,12 +1190,18 @@ export function MessageList({
|
||||
{packetInspectorSource && (
|
||||
<RawPacketInspectorDialog
|
||||
open={packetInspectorSource !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setPacketInspectorSource(null);
|
||||
packetSignalOverrideRef.current = undefined;
|
||||
}
|
||||
}}
|
||||
channels={channels}
|
||||
source={packetInspectorSource}
|
||||
title="Analyze Packet"
|
||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||
notice={ANALYZE_PACKET_NOTICE}
|
||||
signalOverride={packetSignalOverrideRef.current}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -103,14 +103,25 @@ export function PathModal({
|
||||
) : null}
|
||||
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
<div className="text-sm space-y-1">
|
||||
{paths.map((p, index) => {
|
||||
const hops = parsePathHops(p.path, p.path_len);
|
||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||
const hasSignal = p.rssi != null || p.snr != null;
|
||||
return (
|
||||
<div key={index}>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
<div>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
</div>
|
||||
{hasSignal && (
|
||||
<div className="text-[0.6875rem] text-muted-foreground ml-4">
|
||||
Last hop (as heard by you):{' '}
|
||||
{p.rssi != null && <span>{p.rssi} dBm RSSI</span>}
|
||||
{p.rssi != null && p.snr != null && <span> · </span>}
|
||||
{p.snr != null && <span>{p.snr.toFixed(1)} dB SNR</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -221,7 +232,7 @@ export function PathModal({
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<span>↻ Resend</span>
|
||||
<span className="text-[10px] font-normal opacity-80">
|
||||
<span className="text-[0.625rem] font-normal opacity-80">
|
||||
Only repeated by new repeaters
|
||||
</span>
|
||||
</span>
|
||||
@@ -237,7 +248,7 @@ export function PathModal({
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<span>↻ Resend as new</span>
|
||||
<span className="text-[10px] font-normal opacity-80">
|
||||
<span className="text-[0.625rem] font-normal opacity-80">
|
||||
Will appear as duplicate to receivers
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource =
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface SignalOverride {
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
}
|
||||
|
||||
interface RawPacketInspectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
notice?: ReactNode;
|
||||
signalOverride?: SignalOverride;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
signalOverride?: SignalOverride;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
@@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatSignal(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.rssi !== null) {
|
||||
parts.push(`${packet.rssi} dBm RSSI`);
|
||||
}
|
||||
if (packet.snr !== null) {
|
||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
||||
function formatSignal(
|
||||
packet: RawPacket,
|
||||
signalOverride?: SignalOverride
|
||||
): { lines: string[]; label: string } {
|
||||
const rssi = signalOverride?.rssi ?? packet.rssi;
|
||||
const snr = signalOverride?.snr ?? packet.snr;
|
||||
const lines: string[] = [];
|
||||
if (rssi !== null) lines.push(`${rssi} dBm RSSI`);
|
||||
if (snr !== null) lines.push(`${snr.toFixed(1)} dB SNR`);
|
||||
const isOverride =
|
||||
signalOverride != null && (signalOverride.rssi != null || signalOverride.snr != null);
|
||||
return {
|
||||
lines: lines.length > 0 ? lines : ['No signal sample'],
|
||||
label: isOverride ? 'Last Hop Signal' : 'Signal',
|
||||
};
|
||||
}
|
||||
|
||||
function formatByteRange(field: PacketByteField): string {
|
||||
@@ -312,7 +325,7 @@ function CompactMetaCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
||||
{secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
||||
@@ -340,7 +353,7 @@ function FullPacketHex({
|
||||
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-[15px] leading-7 text-foreground">
|
||||
<div className="font-mono text-[0.9375rem] leading-7 text-foreground">
|
||||
{byteRuns.map((run, index) => {
|
||||
const fieldId = run.fieldId;
|
||||
const palette = fieldId ? colorMap.get(fieldId) : null;
|
||||
@@ -446,7 +459,9 @@ function FieldBox({
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||
{formatByteRange(field)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -464,7 +479,7 @@ function FieldBox({
|
||||
|
||||
{field.decryptedMessage ? (
|
||||
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||
</div>
|
||||
<PlaintextContent text={field.decryptedMessage} />
|
||||
@@ -486,11 +501,13 @@ function FieldBox({
|
||||
<div className="text-sm font-medium leading-tight text-foreground">
|
||||
{part.field}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||
Bits {part.bits}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">{part.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -565,7 +582,11 @@ function FieldSection({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||
export function RawPacketInspectionPanel({
|
||||
packet,
|
||||
channels,
|
||||
signalOverride,
|
||||
}: RawPacketInspectionPanelProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
@@ -598,7 +619,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
@@ -611,7 +632,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
@@ -637,11 +658,24 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
{(() => {
|
||||
const sig = formatSignal(packet, signalOverride);
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{sig.label}
|
||||
</div>
|
||||
{sig.lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${i === 0 ? 'mt-1' : 'mt-0.5'} text-sm font-medium leading-tight text-foreground`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -711,6 +745,7 @@ export function RawPacketInspectorDialog({
|
||||
title,
|
||||
description,
|
||||
notice,
|
||||
signalOverride,
|
||||
}: RawPacketInspectorDialogProps) {
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
@@ -735,7 +770,13 @@ export function RawPacketInspectorDialog({
|
||||
|
||||
let body: ReactNode;
|
||||
if (source.kind === 'packet') {
|
||||
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
||||
body = (
|
||||
<RawPacketInspectionPanel
|
||||
packet={source.packet}
|
||||
channels={channels}
|
||||
signalOverride={signalOverride}
|
||||
/>
|
||||
);
|
||||
} else if (source.kind === 'paste') {
|
||||
body = (
|
||||
<>
|
||||
|
||||
@@ -211,7 +211,9 @@ function getCoverageMessage(
|
||||
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
@@ -329,7 +331,7 @@ function NeighborList({
|
||||
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
{!isNeighborIdentityResolvable(item, contacts) ? (
|
||||
<div className="text-[11px] text-warning">Identity not resolvable</div>
|
||||
<div className="text-[0.6875rem] text-warning">Identity not resolvable</div>
|
||||
) : null}
|
||||
</div>
|
||||
{mode !== 'signal' ? (
|
||||
@@ -363,7 +365,7 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground">
|
||||
{typeOrder.map((type, i) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<span
|
||||
@@ -513,7 +515,7 @@ export function RawPacketFeedView({
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Coverage
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -101,7 +101,7 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
className={`text-[0.625rem] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
title={decoded.routeType}
|
||||
>
|
||||
{getRouteTypeLabel(decoded.routeType)}
|
||||
@@ -117,26 +117,29 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
className={cn(
|
||||
'text-[0.8125rem]',
|
||||
packet.decrypted ? 'text-primary' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
|
||||
{formatTime(packet.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatSignalInfo(packet)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
<div className="font-mono text-[0.625rem] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
{packet.data.toUpperCase()}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -54,6 +54,8 @@ interface RepeaterDashboardProps {
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
@@ -72,6 +74,8 @@ export function RepeaterDashboard({
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -177,7 +181,7 @@ export function RepeaterDashboard({
|
||||
)}
|
||||
</h2>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
@@ -193,7 +197,7 @@ export function RepeaterDashboard({
|
||||
</span>
|
||||
</span>
|
||||
{contact && (
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||
</div>
|
||||
)}
|
||||
@@ -204,7 +208,7 @@ export function RepeaterDashboard({
|
||||
size="sm"
|
||||
onClick={loadAll}
|
||||
disabled={anyLoading}
|
||||
className="h-7 px-2 text-[11px] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
|
||||
className="h-7 px-2 text-[0.6875rem] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
|
||||
>
|
||||
{anyLoading ? 'Loading...' : 'Load All'}
|
||||
</Button>
|
||||
@@ -250,7 +254,7 @@ export function RepeaterDashboard({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
@@ -396,7 +400,13 @@ export function RepeaterDashboard({
|
||||
/>
|
||||
|
||||
{/* Telemetry history chart — full width, below console */}
|
||||
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||
<TelemetryHistoryPane
|
||||
entries={telemetryHistory}
|
||||
publicKey={conversation.id}
|
||||
contacts={contacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -290,7 +290,7 @@ export function SearchView({
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded',
|
||||
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded',
|
||||
result.type === 'CHAN'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-secondary text-secondary-foreground'
|
||||
@@ -298,12 +298,12 @@ export function SearchView({
|
||||
>
|
||||
{typeBadge}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
|
||||
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
|
||||
<span className="text-xs font-medium text-foreground truncate">{convName}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground ml-auto flex-shrink-0">
|
||||
{formatTime(result.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/80 line-clamp-2 break-words">
|
||||
<div className="text-[0.8125rem] text-foreground/80 line-clamp-2 break-words">
|
||||
{result.sender_name && !result.outgoing && (
|
||||
<span className="text-muted-foreground">{result.sender_name}: </span>
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,8 @@ interface SettingsModalBaseProps {
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -85,6 +87,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -246,6 +250,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -584,7 +584,7 @@ export function Sidebar({
|
||||
contactType={row.contact.type}
|
||||
/>
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
@@ -594,7 +594,7 @@ export function Sidebar({
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
@@ -626,7 +626,7 @@ export function Sidebar({
|
||||
key={key}
|
||||
data-active={active ? 'true' : undefined}
|
||||
className={cn(
|
||||
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-accent border-l-primary'
|
||||
)}
|
||||
role="button"
|
||||
@@ -735,7 +735,7 @@ export function Sidebar({
|
||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 text-[11px]',
|
||||
'ml-1 text-[0.6875rem]',
|
||||
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -763,7 +763,7 @@ export function Sidebar({
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||||
'flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||||
isSearching && 'cursor-default'
|
||||
)}
|
||||
aria-expanded={!effectiveCollapsed}
|
||||
@@ -783,7 +783,7 @@ export function Sidebar({
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{sortSection && sectionSortOrder && (
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[0.625rem] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => handleSortToggle(sortSection)}
|
||||
aria-label={
|
||||
sectionSortOrder === 'alpha'
|
||||
@@ -802,7 +802,7 @@ export function Sidebar({
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded-full',
|
||||
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded-full',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-secondary text-muted-foreground'
|
||||
@@ -831,7 +831,7 @@ export function Sidebar({
|
||||
onClick={onNewMessage}
|
||||
title="Add channel or contact"
|
||||
aria-label="Add channel or contact"
|
||||
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary"
|
||||
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[0.8125rem] text-primary hover:bg-primary/10 hover:text-primary"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
<span>Add Channel/Contact</span>
|
||||
@@ -848,7 +848,7 @@ export function Sidebar({
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
className={cn('h-7 text-[0.8125rem] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -874,7 +874,7 @@ export function Sidebar({
|
||||
{/* Mark All Read */}
|
||||
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
||||
<div
|
||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function StatusBar({
|
||||
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
||||
<span
|
||||
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
className="font-mono text-[0.6875rem] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
|
||||
@@ -118,7 +118,7 @@ function TraceNodeRow({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
|
||||
'flex h-9 w-9 items-center justify-center rounded-full border text-[0.6875rem] font-semibold uppercase tracking-wide',
|
||||
fixed
|
||||
? 'border-primary/30 bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted text-muted-foreground'
|
||||
@@ -129,12 +129,12 @@ function TraceNodeRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
|
||||
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
|
||||
{meta ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{meta}</div> : null}
|
||||
{note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null}
|
||||
</div>
|
||||
{snr ? (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[11px] text-muted-foreground">SNR</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground">SNR</div>
|
||||
<div className="font-mono text-sm">{snr}</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -370,7 +370,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
))}
|
||||
</div>
|
||||
{sortMode === 'distance' && !canSortByDistance ? (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
<p className="mt-2 text-[0.6875rem] text-muted-foreground">
|
||||
Distance sorting is using known repeater coordinates, but the local radio does not
|
||||
currently have a valid location.
|
||||
</p>
|
||||
@@ -421,12 +421,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
{getShortKey(contact.public_key)}
|
||||
</div>
|
||||
{sortMode === 'distance' && distanceKm !== null ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
|
||||
{distanceKm.toFixed(1)} km away
|
||||
</div>
|
||||
) : null}
|
||||
{selectedCount > 0 ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
|
||||
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TelemetryHistoryEntry } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { TelemetryHistoryEntry, Contact } from '../../types';
|
||||
|
||||
const MAX_TRACKED = 8;
|
||||
|
||||
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||
|
||||
@@ -47,8 +51,26 @@ function formatUptime(seconds: number): string {
|
||||
return `${(seconds / 86400).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
|
||||
interface TelemetryHistoryPaneProps {
|
||||
entries: TelemetryHistoryEntry[];
|
||||
publicKey: string;
|
||||
contacts: Contact[];
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TelemetryHistoryPane({
|
||||
entries,
|
||||
publicKey,
|
||||
contacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: TelemetryHistoryPaneProps) {
|
||||
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
|
||||
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
|
||||
|
||||
const config = METRIC_CONFIG[metric];
|
||||
|
||||
@@ -68,13 +90,87 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
|
||||
|
||||
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
await onToggleTrackedTelemetry(publicKey);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trackedNames = useMemo(() => {
|
||||
if (!slotsFull) return [];
|
||||
return trackedTelemetryRepeaters.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
return { key, name: contact?.name ?? key.slice(0, 12) };
|
||||
});
|
||||
}, [slotsFull, trackedTelemetryRepeaters, contacts]);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||
{entries.length > 0 && (
|
||||
<span className="text-[0.625rem] text-muted-foreground">{entries.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{/* Explanation + tracking toggle */}
|
||||
<div className="mb-3 space-y-3">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
|
||||
samples, whichever comes first). This telemetry is stored on normal interactive fetches
|
||||
via the repeater pane, API calls to the endpoint (
|
||||
<code className="text-[0.6875rem]">POST /api/contacts/<key>/repeater/status</code>
|
||||
), or when the repeater is opted into interval telemetry polling, in which case the
|
||||
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
|
||||
into this flow in the{' '}
|
||||
<a
|
||||
href="#settings/database"
|
||||
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Database & Messaging
|
||||
</a>{' '}
|
||||
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
|
||||
of keeping mesh congestion reasonable.
|
||||
</p>
|
||||
|
||||
{isTracked ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
|
||||
</Button>
|
||||
) : slotsFull ? (
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" disabled>
|
||||
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disable tracking on another repeater to free a slot:{' '}
|
||||
{trackedNames.map((t) => t.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||
>
|
||||
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="mb-3" />
|
||||
|
||||
{/* Metric selector */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||
@@ -83,7 +179,7 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={cn(
|
||||
'text-[11px] px-2 py-0.5 rounded transition-colors',
|
||||
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||
metric === m
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
@@ -149,10 +245,15 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
|
||||
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
|
||||
@@ -141,10 +141,10 @@ export function RepeaterPane({
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{headerNote && <p className="text-[11px] text-muted-foreground">{headerNote}</p>}
|
||||
{headerNote && <p className="text-[0.6875rem] text-muted-foreground">{headerNote}</p>}
|
||||
{fetchedAt && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
className="text-[0.6875rem] text-muted-foreground"
|
||||
title={new Date(fetchedAt).toLocaleString()}
|
||||
>
|
||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||
|
||||
@@ -20,6 +20,8 @@ export function SettingsDatabaseSection({
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -32,6 +34,8 @@ export function SettingsDatabaseSection({
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
@@ -223,6 +227,50 @@ export function SettingsDatabaseSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Tracked Repeater Telemetry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
|
||||
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
|
||||
</p>
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{trackedTelemetryRepeaters.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
const displayName = contact?.name ?? key.slice(0, 12);
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -537,7 +537,7 @@ function CreateIntegrationDialog({
|
||||
<div className="space-y-4">
|
||||
{sectionedOptions.map((group) => (
|
||||
<div key={group.section} className="space-y-1.5">
|
||||
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
<div className="px-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.section}
|
||||
</div>
|
||||
{group.options.map((option) => {
|
||||
@@ -577,7 +577,7 @@ function CreateIntegrationDialog({
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{selectedOption.section}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Logs, MessageSquare } from 'lucide-react';
|
||||
import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ContactAvatar } from '../ContactAvatar';
|
||||
import {
|
||||
captureLastViewedConversationFromHash,
|
||||
@@ -37,6 +39,13 @@ export function SettingsLocalSection({
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
const [darkMap, setDarkMap] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
@@ -233,11 +242,31 @@ export function SettingsLocalSection({
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkMap}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDarkMap(v);
|
||||
try {
|
||||
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Dark mode map tiles</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreview({ className }: { className?: string }) {
|
||||
const [showStyleRef, setShowStyleRef] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
@@ -271,7 +300,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
|
||||
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">Sidebar preview</p>
|
||||
<div className="space-y-1">
|
||||
<PreviewSidebarRow
|
||||
active
|
||||
@@ -289,7 +318,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
||||
label="Alice"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
|
||||
3
|
||||
</span>
|
||||
}
|
||||
@@ -298,13 +327,267 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
||||
label="Mesh Ops"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
|
||||
@2
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Style Reference (collapsible) ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowStyleRef((v) => !v)}
|
||||
className="mt-4 flex w-full items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('h-3.5 w-3.5 transition-transform', showStyleRef && 'rotate-90')}
|
||||
/>
|
||||
Canonical style reference
|
||||
</button>
|
||||
|
||||
{showStyleRef && (
|
||||
<>
|
||||
{/* ── Text Hierarchy ── */}
|
||||
<PreviewSection title="Text hierarchy">
|
||||
<div className="space-y-2">
|
||||
<PreviewTextRow
|
||||
classes="text-xl font-semibold"
|
||||
label="text-xl font-semibold"
|
||||
desc="Hero / large data"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-lg font-semibold"
|
||||
label="text-lg font-semibold"
|
||||
desc="Sheet / dialog title"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-base font-semibold"
|
||||
label="text-base font-semibold"
|
||||
desc="Section title"
|
||||
/>
|
||||
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
|
||||
<PreviewTextRow
|
||||
classes="text-xs text-muted-foreground"
|
||||
label="text-xs text-muted-foreground"
|
||||
desc="Helper text"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-[0.6875rem] text-muted-foreground"
|
||||
label="text-[0.6875rem] text-muted-foreground"
|
||||
desc="Metadata, timestamps"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Section Label
|
||||
</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
|
||||
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Mono Text ── */}
|
||||
<PreviewSection title="Mono text">
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
a1b2c3d4e5f6...7890abcdef01
|
||||
</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
text-xs font-mono — keys, identifiers
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.6875rem] font-mono">1h 23m 45s uptime</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
text-[0.6875rem] font-mono — metadata mono
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-mono">$ req_status_sync 0xA1B2...</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
text-sm font-mono — console / code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Badges ── */}
|
||||
<PreviewSection title="Badges and tags">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
Hashtag
|
||||
</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
Repeater
|
||||
</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
|
||||
3
|
||||
</span>
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
|
||||
@2
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||
Muted: bg-muted · Primary: bg-primary/10 · Unread/Mention: bg-badge-*
|
||||
</p>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Buttons ── */}
|
||||
<PreviewSection title="Buttons">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||
Standard variants (size sm)
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button size="sm">Default</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Outline
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary">
|
||||
Secondary
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive">
|
||||
Destructive
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
Ghost
|
||||
</Button>
|
||||
<Button size="icon" variant="outline">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||
Semantic outline variants
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
Danger
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
Warning
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||
>
|
||||
Success
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||
Metric selector pills
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{['Voltage', 'Noise Floor', 'Packets'].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={cn(
|
||||
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||
i === 0
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Clickable Text ── */}
|
||||
<PreviewSection title="Clickable text">
|
||||
<div className="space-y-1.5">
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block"
|
||||
>
|
||||
a1b2c3d4e5f6 (click to copy)
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="text-sm cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary transition-colors"
|
||||
>
|
||||
Underlined navigational link
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||
cursor-pointer hover:text-primary transition-colors — use role="button" +
|
||||
tabIndex
|
||||
</p>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Inline Alerts ── */}
|
||||
<PreviewSection title="Inline alerts">
|
||||
<div className="space-y-1.5">
|
||||
<div className="rounded-md border border-info/30 bg-info/10 px-3 py-2 text-xs text-info">
|
||||
Info: channel slot cache refreshed from radio.
|
||||
</div>
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Warning: radio clock skew detected.
|
||||
</div>
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Error: post-connect setup timed out. Reboot the radio and restart.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">{title}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewTextRow({
|
||||
classes,
|
||||
label,
|
||||
desc,
|
||||
}: {
|
||||
classes: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className={classes}>Sample text at this size</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
{label} — {desc}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -327,7 +610,7 @@ function PreviewMessage({
|
||||
return (
|
||||
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
<span className="mb-1 text-[11px] text-muted-foreground">{sender}</span>
|
||||
<span className="mb-1 text-[0.6875rem] text-muted-foreground">{sender}</span>
|
||||
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,7 +631,7 @@ function PreviewSidebarRow({
|
||||
return (
|
||||
<div
|
||||
data-active={active ? 'true' : undefined}
|
||||
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[0.8125rem] ${
|
||||
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -702,6 +702,26 @@ export function SettingsRadioSection({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="auto-resend-channel"
|
||||
checked={appSettings.auto_resend_channel}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveAppSettings({ auto_resend_channel: checked === true })
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="auto-resend-channel">Auto-Resend Unheard Channel Messages</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, outgoing channel messages that receive no echo within 2 seconds are
|
||||
automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters
|
||||
that already heard the original will ignore the duplicate. This functionality will NOT
|
||||
create double-sent/duplicate messages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -19,6 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground',
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ export function VisualizerControls({
|
||||
{PACKET_LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center text-[0.5rem] font-bold text-white"
|
||||
style={{ backgroundColor: item.color }}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -2,17 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { api } from '../api';
|
||||
import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import {
|
||||
initLastMessageTimes,
|
||||
loadLocalStorageLastMessageTimes,
|
||||
loadLocalStorageSortOrder,
|
||||
clearLocalStorageConversationState,
|
||||
} from '../utils/conversationState';
|
||||
import {
|
||||
isFavorite,
|
||||
loadLocalStorageFavorites,
|
||||
clearLocalStorageFavorites,
|
||||
} from '../utils/favorites';
|
||||
import { initLastMessageTimes } from '../utils/conversationState';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||
|
||||
export function useAppSettings() {
|
||||
@@ -120,59 +111,68 @@ export function useAppSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// One-time migration of localStorage preferences to server
|
||||
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
|
||||
const key = publicKey.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.tracked_telemetry_repeaters ?? [];
|
||||
const wasTracked = current.includes(key);
|
||||
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
|
||||
return { ...prev, tracked_telemetry_repeaters: optimistic };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await api.toggleTrackedTelemetry(publicKey);
|
||||
setAppSettings((prev) =>
|
||||
prev ? { ...prev, tracked_telemetry_repeaters: result.tracked_telemetry_repeaters } : prev
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle tracked telemetry:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const detail = (err as any)?.body?.detail;
|
||||
if (typeof detail === 'object' && detail?.message) {
|
||||
toast.error(detail.message);
|
||||
} else {
|
||||
toast.error('Failed to update tracked telemetry');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Legacy favorites migration: if pre-server-side favorites exist in
|
||||
// localStorage, toggle each one via the existing API and clear the key.
|
||||
useEffect(() => {
|
||||
if (!appSettings || hasMigratedRef.current) return;
|
||||
|
||||
if (appSettings.preferences_migrated) {
|
||||
clearLocalStorageFavorites();
|
||||
clearLocalStorageConversationState();
|
||||
hasMigratedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const localFavorites = loadLocalStorageFavorites();
|
||||
const localSortOrder = loadLocalStorageSortOrder();
|
||||
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
|
||||
|
||||
const hasLocalData =
|
||||
localFavorites.length > 0 ||
|
||||
localSortOrder !== 'recent' ||
|
||||
Object.keys(localLastMessageTimes).length > 0;
|
||||
|
||||
if (!hasLocalData) {
|
||||
hasMigratedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
hasMigratedRef.current = true;
|
||||
|
||||
const migratePreferences = async () => {
|
||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
||||
let localFavorites: Favorite[] = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
if (stored) localFavorites = JSON.parse(stored);
|
||||
} catch {
|
||||
// corrupt or unavailable
|
||||
}
|
||||
if (localFavorites.length === 0) return;
|
||||
|
||||
const migrate = async () => {
|
||||
try {
|
||||
const result = await api.migratePreferences({
|
||||
favorites: localFavorites,
|
||||
sort_order: localSortOrder,
|
||||
last_message_times: localLastMessageTimes,
|
||||
});
|
||||
|
||||
if (result.migrated) {
|
||||
toast.success('Preferences migrated', {
|
||||
description: `Migrated ${localFavorites.length} favorites to server`,
|
||||
});
|
||||
for (const f of localFavorites) {
|
||||
await api.toggleFavorite(f.type, f.id);
|
||||
}
|
||||
|
||||
setAppSettings(result.settings);
|
||||
initLastMessageTimes(result.settings.last_message_times ?? {});
|
||||
|
||||
clearLocalStorageFavorites();
|
||||
clearLocalStorageConversationState();
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
await fetchAppSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to migrate preferences:', err);
|
||||
console.error('Failed to migrate legacy favorites:', err);
|
||||
}
|
||||
};
|
||||
|
||||
migratePreferences();
|
||||
}, [appSettings]);
|
||||
migrate();
|
||||
}, [appSettings, fetchAppSettings]);
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
@@ -182,5 +182,6 @@ export function useAppSettings() {
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ interface UseConversationActionsResult {
|
||||
channelKey: string,
|
||||
floodScopeOverride: string
|
||||
) => Promise<void>;
|
||||
handleSetChannelPathHashModeOverride: (
|
||||
channelKey: string,
|
||||
pathHashModeOverride: number | null
|
||||
) => Promise<void>;
|
||||
handleSenderClick: (sender: string) => void;
|
||||
handleTrace: () => Promise<void>;
|
||||
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
@@ -106,6 +110,25 @@ export function useConversationActions({
|
||||
[mergeChannelIntoList]
|
||||
);
|
||||
|
||||
const handleSetChannelPathHashModeOverride = useCallback(
|
||||
async (channelKey: string, pathHashModeOverride: number | null) => {
|
||||
try {
|
||||
const updated = await api.setChannelPathHashModeOverride(channelKey, pathHashModeOverride);
|
||||
mergeChannelIntoList(updated);
|
||||
toast.success(
|
||||
updated.path_hash_mode_override != null
|
||||
? 'Path hop width override saved'
|
||||
: 'Path hop width override cleared'
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error('Failed to update path hop width override', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[mergeChannelIntoList]
|
||||
);
|
||||
|
||||
const handleSenderClick = useCallback(
|
||||
(sender: string) => {
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
@@ -143,6 +166,7 @@ export function useConversationActions({
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSetChannelPathHashModeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handlePathDiscovery,
|
||||
|
||||
@@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({
|
||||
requestTrace: vi.fn(),
|
||||
updateRadioConfig: vi.fn(),
|
||||
setPrivateKey: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
@@ -191,7 +190,7 @@ const baseSettings = {
|
||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
preferences_migrated: false,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
|
||||
@@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
useConversationMessagesCalls: vi.fn(),
|
||||
}));
|
||||
@@ -219,7 +218,7 @@ describe('App search jump target handling', () => {
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
preferences_migrated: true,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -170,7 +169,7 @@ describe('App startup hash resolution', () => {
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
preferences_migrated: true,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,15 @@ function makeDetail(channel: Channel): ChannelDetail {
|
||||
first_message_at: null,
|
||||
unique_sender_count: 0,
|
||||
top_senders_24h: [],
|
||||
path_hash_width_24h: {
|
||||
total_packets: 0,
|
||||
single_byte: 0,
|
||||
double_byte: 0,
|
||||
triple_byte: 0,
|
||||
single_byte_pct: 0,
|
||||
double_byte_pct: 0,
|
||||
triple_byte_pct: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onDismissUnreadMarker: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RADIO_PRESETS } from '../utils/radioPresets';
|
||||
|
||||
describe('Radio Presets', () => {
|
||||
describe('preset values are valid LoRa parameters', () => {
|
||||
it('all frequencies are in valid ISM bands', () => {
|
||||
for (const preset of RADIO_PRESETS) {
|
||||
// 433 MHz: 433.05-434.79, EU 868: 863-870, US/AU/NZ/VN 900: 902-928
|
||||
const valid433 = preset.freq >= 433 && preset.freq <= 435;
|
||||
const validEU = preset.freq >= 863 && preset.freq <= 870;
|
||||
const valid900 = preset.freq >= 902 && preset.freq <= 928;
|
||||
expect(valid433 || validEU || valid900).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('all spreading factors are valid (7-12)', () => {
|
||||
for (const preset of RADIO_PRESETS) {
|
||||
expect(preset.sf).toBeGreaterThanOrEqual(7);
|
||||
expect(preset.sf).toBeLessThanOrEqual(12);
|
||||
}
|
||||
});
|
||||
|
||||
it('all coding rates are valid (5-8 for 4/5 to 4/8)', () => {
|
||||
for (const preset of RADIO_PRESETS) {
|
||||
expect(preset.cr).toBeGreaterThanOrEqual(5);
|
||||
expect(preset.cr).toBeLessThanOrEqual(8);
|
||||
}
|
||||
});
|
||||
|
||||
it('all bandwidths are standard LoRa values', () => {
|
||||
const validBandwidths = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500];
|
||||
for (const preset of RADIO_PRESETS) {
|
||||
expect(validBandwidths).toContain(preset.bw);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,8 @@ const defaultProps = {
|
||||
onToggleNotifications: vi.fn(),
|
||||
onToggleFavorite: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
trackedTelemetryRepeaters: [] as string[],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
function createDeferred<T>() {
|
||||
@@ -677,7 +679,7 @@ describe('RepeaterDashboard', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 samples')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No history yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates history from live status fetch', async () => {
|
||||
|
||||
@@ -62,13 +62,15 @@ const baseSettings: AppSettings = {
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
preferences_migrated: false,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
auto_resend_channel: false,
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSceneNodeLabel } from '../components/visualizer/shared';
|
||||
|
||||
describe('visualizer shared label helpers', () => {
|
||||
it('adds an ambiguity suffix to in-graph labels for ambiguous nodes', () => {
|
||||
expect(
|
||||
getSceneNodeLabel({
|
||||
id: '?32',
|
||||
name: 'Likely Relay',
|
||||
type: 'repeater',
|
||||
isAmbiguous: true,
|
||||
})
|
||||
).toBe('Likely Relay (?)');
|
||||
});
|
||||
|
||||
it('does not add an ambiguity suffix to unambiguous nodes', () => {
|
||||
expect(
|
||||
getSceneNodeLabel({
|
||||
id: 'aaaaaaaaaaaa',
|
||||
name: 'Alice',
|
||||
type: 'client',
|
||||
isAmbiguous: false,
|
||||
})
|
||||
).toBe('Alice');
|
||||
});
|
||||
});
|
||||
+22
-10
@@ -201,6 +201,7 @@ export interface Channel {
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
flood_scope_override?: string | null;
|
||||
path_hash_mode_override?: number | null;
|
||||
last_read_at: number | null;
|
||||
}
|
||||
|
||||
@@ -227,12 +228,23 @@ export interface BulkCreateHashtagChannelsResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PathHashWidthStats {
|
||||
total_packets: number;
|
||||
single_byte: number;
|
||||
double_byte: number;
|
||||
triple_byte: number;
|
||||
single_byte_pct: number;
|
||||
double_byte_pct: number;
|
||||
triple_byte_pct: number;
|
||||
}
|
||||
|
||||
export interface ChannelDetail {
|
||||
channel: Channel;
|
||||
message_counts: ChannelMessageCounts;
|
||||
first_message_at: number | null;
|
||||
unique_sender_count: number;
|
||||
top_senders_24h: ChannelTopSender[];
|
||||
path_hash_width_24h: PathHashWidthStats;
|
||||
}
|
||||
|
||||
/** A single path that a message took to reach us */
|
||||
@@ -243,6 +255,10 @@ export interface MessagePath {
|
||||
received_at: number;
|
||||
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
|
||||
path_len?: number | null;
|
||||
/** Last-hop RSSI in dBm (null if not available, e.g. older data) */
|
||||
rssi?: number | null;
|
||||
/** Last-hop SNR in dB (null if not available, e.g. older data) */
|
||||
snr?: number | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -317,34 +333,30 @@ export interface AppSettings {
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
advert_interval: number;
|
||||
last_advert_time: number;
|
||||
flood_scope: string;
|
||||
blocked_keys: string[];
|
||||
blocked_names: string[];
|
||||
discovery_blocked_types: number[];
|
||||
tracked_telemetry_repeaters: string[];
|
||||
auto_resend_channel: boolean;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
max_radio_contacts?: number;
|
||||
auto_decrypt_dm_on_advert?: boolean;
|
||||
advert_interval?: number;
|
||||
auto_resend_channel?: boolean;
|
||||
flood_scope?: string;
|
||||
blocked_keys?: string[];
|
||||
blocked_names?: string[];
|
||||
discovery_blocked_types?: number[];
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
favorites: Favorite[];
|
||||
sort_order: string;
|
||||
last_message_times: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MigratePreferencesResponse {
|
||||
migrated: boolean;
|
||||
settings: AppSettings;
|
||||
export interface TrackedTelemetryResponse {
|
||||
tracked_telemetry_repeaters: string[];
|
||||
names: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Contact type constants */
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* across devices - see useUnreadCounts hook.
|
||||
*/
|
||||
|
||||
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
|
||||
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
|
||||
const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders';
|
||||
|
||||
@@ -72,30 +71,6 @@ export function getStateKey(type: 'channel' | 'contact', id: string): string {
|
||||
return `${type}-${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load last message times from localStorage (for migration only)
|
||||
*/
|
||||
export function loadLocalStorageLastMessageTimes(): ConversationTimes {
|
||||
try {
|
||||
const stored = localStorage.getItem(LAST_MESSAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sort order from localStorage (for migration only)
|
||||
*/
|
||||
export function loadLocalStorageSortOrder(): SortOrder {
|
||||
try {
|
||||
const stored = localStorage.getItem(SORT_ORDER_KEY);
|
||||
return stored === 'alpha' ? 'alpha' : 'recent';
|
||||
} catch {
|
||||
return 'recent';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the legacy single sidebar sort order from localStorage, if present.
|
||||
*/
|
||||
@@ -149,15 +124,3 @@ export function saveLocalStorageSidebarSectionSortOrders(orders: SidebarSectionS
|
||||
// localStorage might be disabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversation state from localStorage (after migration)
|
||||
*/
|
||||
export function clearLocalStorageConversationState(): void {
|
||||
try {
|
||||
localStorage.removeItem(LAST_MESSAGE_KEY);
|
||||
localStorage.removeItem(SORT_ORDER_KEY);
|
||||
} catch {
|
||||
// localStorage might be disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
/**
|
||||
* Favorites utilities.
|
||||
*
|
||||
* Favorites are now stored server-side in the database.
|
||||
* This file provides helper functions for checking favorites
|
||||
* and loading legacy localStorage data for migration.
|
||||
* Favorites are stored server-side in the database.
|
||||
*/
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
|
||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
||||
|
||||
/**
|
||||
* Check if a conversation is favorited (from provided favorites array)
|
||||
*/
|
||||
@@ -20,26 +16,3 @@ export function isFavorite(
|
||||
): boolean {
|
||||
return favorites.some((f) => f.type === type && f.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load favorites from localStorage (for migration only)
|
||||
*/
|
||||
export function loadLocalStorageFavorites(): Favorite[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear favorites from localStorage (after migration)
|
||||
*/
|
||||
export function clearLocalStorageFavorites(): void {
|
||||
try {
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
} catch {
|
||||
// localStorage might be disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { getRawPacketObservationKey } from './rawPacketIdentity';
|
||||
export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const;
|
||||
export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number];
|
||||
|
||||
export const RAW_PACKET_STATS_WINDOW_SECONDS: Record<
|
||||
Exclude<RawPacketStatsWindow, 'session'>,
|
||||
number
|
||||
> = {
|
||||
const RAW_PACKET_STATS_WINDOW_SECONDS: Record<Exclude<RawPacketStatsWindow, 'session'>, number> = {
|
||||
'1m': 60,
|
||||
'5m': 5 * 60,
|
||||
'10m': 10 * 60,
|
||||
|
||||
@@ -28,7 +28,7 @@ export type ServerLoginAttemptState =
|
||||
at: number;
|
||||
};
|
||||
|
||||
export function getServerLoginMethodLabel(
|
||||
function getServerLoginMethodLabel(
|
||||
method: ServerLoginMethod,
|
||||
blankLabel = 'existing-access'
|
||||
): string {
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface VisualizerSettings {
|
||||
hidePacketFeed: boolean;
|
||||
}
|
||||
|
||||
export const VISUALIZER_DEFAULTS: VisualizerSettings = {
|
||||
const VISUALIZER_DEFAULTS: VisualizerSettings = {
|
||||
showAmbiguousPaths: true,
|
||||
showAmbiguousNodes: false,
|
||||
useAdvertPathHints: true,
|
||||
|
||||
@@ -116,7 +116,7 @@ export interface PathStep {
|
||||
hiddenLabel?: string | null;
|
||||
}
|
||||
|
||||
export function normalizeHopToken(hop: string | null | undefined): string | null {
|
||||
function normalizeHopToken(hop: string | null | undefined): string | null {
|
||||
const normalized = hop?.trim().toLowerCase() ?? '';
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.7.1"
|
||||
version = "3.8.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
# developer perogative ;D
|
||||
if command -v enablenvm >/dev/null 2>&1; then
|
||||
@@ -44,12 +44,21 @@ echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}"
|
||||
|
||||
echo -ne "${BLUE}[pyright]${NC} "
|
||||
cd "$REPO_ROOT"
|
||||
uv run pyright app/ --outputjson 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
pyright_json="$(mktemp)"
|
||||
if uv run pyright app/ --outputjson >"$pyright_json"; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open(sys.argv[1]) as f:
|
||||
d = json.load(f)
|
||||
s = d.get('summary', {})
|
||||
print(f\"{s.get('filesAnalyzed',0)} files, 0 errors\")
|
||||
" 2>/dev/null || { uv run pyright app/; exit 1; }
|
||||
print(f\"{s.get('filesAnalyzed', 0)} files, {s.get('errorCount', 0)} errors\")
|
||||
" "$pyright_json"
|
||||
else
|
||||
uv run pyright app/
|
||||
rm -f "$pyright_json"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$pyright_json"
|
||||
echo -e "${GREEN}Passed!${NC}"
|
||||
|
||||
echo -ne "${BLUE}[pytest]${NC} "
|
||||
@@ -59,7 +68,15 @@ echo -e "${GREEN}Passed!${NC}"
|
||||
|
||||
echo -ne "${BLUE}[vitest]${NC} "
|
||||
cd "$REPO_ROOT/frontend"
|
||||
npx --quiet vitest run --reporter=dot 2>&1 | tail -5
|
||||
vitest_log="$(mktemp)"
|
||||
if npx --quiet vitest run --reporter=dot >"$vitest_log" 2>&1; then
|
||||
tail -5 "$vitest_log"
|
||||
else
|
||||
cat "$vitest_log"
|
||||
rm -f "$vitest_log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$vitest_log"
|
||||
echo -e "${GREEN}Passed!${NC}"
|
||||
|
||||
echo -ne "${BLUE}[build]${NC} "
|
||||
|
||||
@@ -223,7 +223,6 @@ export interface AppSettings {
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
advert_interval: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class TestDMAckTrackingWiring:
|
||||
await _insert_contact(pub_key)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
@@ -115,7 +115,7 @@ class TestDMAckTrackingWiring:
|
||||
await _insert_contact(pub_key)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
@@ -144,7 +144,7 @@ class TestDMAckTrackingWiring:
|
||||
await _insert_contact(pub_key)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
@@ -172,7 +172,7 @@ class TestDMAckTrackingWiring:
|
||||
await _insert_contact(pub_key)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
|
||||
+2
-2
@@ -49,10 +49,10 @@ def _disable_background_dm_retries(monkeypatch):
|
||||
def _patch_require_connected(mc=None, *, detail="Radio not connected"):
|
||||
if mc is None:
|
||||
return patch(
|
||||
"app.dependencies.radio_manager.require_connected",
|
||||
"app.services.radio_runtime.radio_runtime.require_connected",
|
||||
side_effect=HTTPException(status_code=503, detail=detail),
|
||||
)
|
||||
return patch("app.dependencies.radio_manager.require_connected", return_value=mc)
|
||||
return patch("app.services.radio_runtime.radio_runtime.require_connected", return_value=mc)
|
||||
|
||||
|
||||
async def _insert_contact(public_key, name="Alice", **overrides):
|
||||
|
||||
@@ -286,7 +286,7 @@ class TestPathDiscovery:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch("app.routers.contacts.radio_manager.require_connected", return_value=mc),
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
patch("app.websocket.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
@@ -324,7 +324,7 @@ class TestPathDiscovery:
|
||||
mc.wait_for_event = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch("app.routers.contacts.radio_manager.require_connected", return_value=mc),
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_rm.radio_operation = _noop_radio_operation(mc)
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.decoder import (
|
||||
DecryptedDirectMessage,
|
||||
PayloadType,
|
||||
RouteType,
|
||||
_clamp_scalar,
|
||||
decrypt_direct_message,
|
||||
decrypt_group_text,
|
||||
decrypt_path_payload,
|
||||
@@ -27,17 +26,6 @@ from app.decoder import (
|
||||
)
|
||||
|
||||
|
||||
class TestChannelKeyDerivation:
|
||||
"""Test channel key derivation from hashtag names."""
|
||||
|
||||
def test_hashtag_key_derivation(self):
|
||||
"""Hashtag channel keys are derived as SHA256(name)[:16]."""
|
||||
channel_name = "#test"
|
||||
expected_key = hashlib.sha256(channel_name.encode("utf-8")).digest()[:16]
|
||||
|
||||
assert len(expected_key) == 16
|
||||
|
||||
|
||||
class TestPacketParsing:
|
||||
"""Test raw packet header parsing."""
|
||||
|
||||
@@ -687,49 +675,6 @@ class TestAdvertisementParsing:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestScalarClamping:
|
||||
"""Test X25519 scalar clamping for ECDH."""
|
||||
|
||||
def test_clamp_scalar_modifies_first_byte(self):
|
||||
"""Clamping clears the lower 3 bits of the first byte."""
|
||||
# Input with all bits set in first byte
|
||||
scalar = bytes([0xFF]) + bytes(31)
|
||||
|
||||
result = _clamp_scalar(scalar)
|
||||
|
||||
# First byte should have lower 3 bits cleared: 0xFF & 248 = 0xF8
|
||||
assert result[0] == 0xF8
|
||||
|
||||
def test_clamp_scalar_modifies_last_byte(self):
|
||||
"""Clamping modifies the last byte for correct group operations."""
|
||||
# Input with all bits set in last byte
|
||||
scalar = bytes(31) + bytes([0xFF])
|
||||
|
||||
result = _clamp_scalar(scalar)
|
||||
|
||||
# Last byte: (0xFF & 63) | 64 = 0x7F
|
||||
assert result[31] == 0x7F
|
||||
|
||||
def test_clamp_scalar_preserves_middle_bytes(self):
|
||||
"""Clamping preserves the middle bytes unchanged."""
|
||||
# Known middle bytes
|
||||
scalar = bytes([0xAB]) + bytes([0x12, 0x34, 0x56] * 10)[:30] + bytes([0xCD])
|
||||
|
||||
result = _clamp_scalar(scalar)
|
||||
|
||||
# Middle bytes should be unchanged
|
||||
assert result[1:31] == scalar[1:31]
|
||||
|
||||
def test_clamp_scalar_truncates_to_32_bytes(self):
|
||||
"""Clamping uses only first 32 bytes of input."""
|
||||
# 64-byte input (typical Ed25519 private key)
|
||||
scalar = bytes(64)
|
||||
|
||||
result = _clamp_scalar(scalar)
|
||||
|
||||
assert len(result) == 32
|
||||
|
||||
|
||||
class TestPublicKeyDerivation:
|
||||
"""Test deriving Ed25519 public key from MeshCore private key."""
|
||||
|
||||
@@ -766,13 +711,6 @@ class TestPublicKeyDerivation:
|
||||
assert len(result) == 32
|
||||
assert result == self.FACE12_PUB_EXPECTED
|
||||
|
||||
def test_derive_public_key_deterministic(self):
|
||||
"""Same private key always produces same public key."""
|
||||
result1 = derive_public_key(self.FACE12_PRIV)
|
||||
result2 = derive_public_key(self.FACE12_PRIV)
|
||||
|
||||
assert result1 == result2
|
||||
|
||||
|
||||
class TestSharedSecretDerivation:
|
||||
"""Test ECDH shared secret derivation from Ed25519 keys."""
|
||||
@@ -793,13 +731,6 @@ class TestSharedSecretDerivation:
|
||||
|
||||
assert len(result) == 32
|
||||
|
||||
def test_derive_shared_secret_deterministic(self):
|
||||
"""Same inputs always produce same shared secret."""
|
||||
result1 = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
|
||||
result2 = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
|
||||
|
||||
assert result1 == result2
|
||||
|
||||
def test_derive_shared_secret_different_keys_different_result(self):
|
||||
"""Different key pairs produce different shared secrets."""
|
||||
# Use the real FACE12 public key as a second peer key (valid curve point)
|
||||
|
||||
@@ -10,24 +10,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import Settings
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
|
||||
from app.routers.health import build_health_data
|
||||
|
||||
|
||||
class TestDisableBotsConfig:
|
||||
"""Test the disable_bots configuration field."""
|
||||
|
||||
def test_disable_bots_defaults_to_false(self):
|
||||
s = Settings(serial_port="", tcp_host="", ble_address="")
|
||||
assert s.disable_bots is False
|
||||
|
||||
def test_disable_bots_can_be_set_true(self):
|
||||
s = Settings(serial_port="", tcp_host="", ble_address="", disable_bots=True)
|
||||
assert s.disable_bots is True
|
||||
|
||||
|
||||
class TestDisableBotsFanoutEndpoint:
|
||||
"""Test that bot creation via fanout router is rejected when bots are disabled."""
|
||||
|
||||
|
||||
@@ -883,7 +883,7 @@ class TestDirectMessageDirectionDetection:
|
||||
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
||||
assert len(message_broadcasts) == 1
|
||||
assert message_broadcasts[0]["data"]["paths"] == [
|
||||
{"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0}
|
||||
{"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0, "rssi": None, "snr": None}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -89,19 +89,6 @@ class TestSetPrivateKey:
|
||||
assert pub1 != pub2
|
||||
|
||||
|
||||
class TestGettersWhenEmpty:
|
||||
"""Test getter behavior when no key is stored."""
|
||||
|
||||
def test_get_private_key_returns_none(self):
|
||||
assert get_private_key() is None
|
||||
|
||||
def test_get_public_key_returns_none(self):
|
||||
assert get_public_key() is None
|
||||
|
||||
def test_has_private_key_false(self):
|
||||
assert has_private_key() is False
|
||||
|
||||
|
||||
class TestClearKeys:
|
||||
"""Test clearing in-memory key material."""
|
||||
|
||||
|
||||
+16
-41
@@ -8,31 +8,6 @@ import pytest
|
||||
from app.migrations import get_version, run_migrations, set_version
|
||||
|
||||
|
||||
class TestMigrationSystem:
|
||||
"""Test the migration version tracking system."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_version_returns_zero_for_new_db(self):
|
||||
"""New database has user_version=0."""
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
try:
|
||||
version = await get_version(conn)
|
||||
assert version == 0
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_version_updates_pragma(self):
|
||||
"""Setting version updates the user_version pragma."""
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
try:
|
||||
await set_version(conn, 5)
|
||||
version = await get_version(conn)
|
||||
assert version == 5
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigration001:
|
||||
"""Test migration 001: add last_read_at columns."""
|
||||
|
||||
@@ -1249,8 +1224,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1321,8 +1296,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1388,8 +1363,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1441,8 +1416,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 15
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1503,8 +1478,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1556,8 +1531,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1696,8 +1671,8 @@ class TestMigration046:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1790,8 +1765,8 @@ class TestMigration047:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 54
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
|
||||
+11
-11
@@ -295,12 +295,12 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
|
||||
class TestContactFromRadioDictHashMode:
|
||||
"""Test that Contact.from_radio_dict() preserves explicit path hash mode."""
|
||||
"""Test that ContactUpsert.from_radio_dict() preserves explicit path hash mode."""
|
||||
|
||||
def test_preserves_mode_from_radio_payload(self):
|
||||
from app.models import Contact
|
||||
from app.models import ContactUpsert
|
||||
|
||||
d = Contact.from_radio_dict(
|
||||
upsert = ContactUpsert.from_radio_dict(
|
||||
"aa" * 32,
|
||||
{
|
||||
"adv_name": "Alice",
|
||||
@@ -309,14 +309,14 @@ class TestContactFromRadioDictHashMode:
|
||||
"out_path_hash_mode": 1,
|
||||
},
|
||||
)
|
||||
assert d["direct_path"] == "aa00bb00"
|
||||
assert d["direct_path_len"] == 2
|
||||
assert d["direct_path_hash_mode"] == 1
|
||||
assert upsert.direct_path == "aa00bb00"
|
||||
assert upsert.direct_path_len == 2
|
||||
assert upsert.direct_path_hash_mode == 1
|
||||
|
||||
def test_flood_falls_back_to_minus_one(self):
|
||||
from app.models import Contact
|
||||
from app.models import ContactUpsert
|
||||
|
||||
d = Contact.from_radio_dict(
|
||||
upsert = ContactUpsert.from_radio_dict(
|
||||
"bb" * 32,
|
||||
{
|
||||
"adv_name": "Bob",
|
||||
@@ -324,6 +324,6 @@ class TestContactFromRadioDictHashMode:
|
||||
"out_path_len": -1,
|
||||
},
|
||||
)
|
||||
assert d["direct_path"] == ""
|
||||
assert d["direct_path_len"] == -1
|
||||
assert d["direct_path_hash_mode"] == -1
|
||||
assert upsert.direct_path == ""
|
||||
assert upsert.direct_path_len == -1
|
||||
assert upsert.direct_path_hash_mode == -1
|
||||
|
||||
@@ -7,11 +7,6 @@ import pytest
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
from app.radio_sync import is_polling_paused
|
||||
from app.services.radio_runtime import RadioRuntime
|
||||
|
||||
|
||||
def _runtime(manager):
|
||||
return RadioRuntime(lambda: manager)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -183,15 +178,15 @@ class TestRequireConnected:
|
||||
"""HTTPException 503 is raised when radio is connected but setup is still in progress."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
manager = MagicMock()
|
||||
manager.is_connected = True
|
||||
manager.meshcore = MagicMock()
|
||||
manager.is_setup_in_progress = True
|
||||
with patch("app.dependencies.radio_manager", _runtime(manager)):
|
||||
with patch.object(radio_runtime, "_manager_getter", return_value=manager):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_connected()
|
||||
radio_runtime.require_connected()
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "initializing" in exc_info.value.detail.lower()
|
||||
@@ -200,28 +195,28 @@ class TestRequireConnected:
|
||||
"""HTTPException 503 is raised when radio is not connected."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
manager = MagicMock()
|
||||
manager.is_setup_in_progress = False
|
||||
manager.is_connected = False
|
||||
manager.meshcore = None
|
||||
with patch("app.dependencies.radio_manager", _runtime(manager)):
|
||||
with patch.object(radio_runtime, "_manager_getter", return_value=manager):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_connected()
|
||||
radio_runtime.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
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
mock_mc = MagicMock()
|
||||
manager = MagicMock()
|
||||
manager.is_setup_in_progress = False
|
||||
manager.is_connected = True
|
||||
manager.meshcore = mock_mc
|
||||
with patch("app.dependencies.radio_manager", _runtime(manager)):
|
||||
result = require_connected()
|
||||
with patch.object(radio_runtime, "_manager_getter", return_value=manager):
|
||||
result = radio_runtime.require_connected()
|
||||
|
||||
assert result is mock_mc
|
||||
|
||||
+27
-27
@@ -97,7 +97,7 @@ class TestGetRadioConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_maps_self_info_to_response(self):
|
||||
mc = _mock_meshcore_with_info()
|
||||
with patch("app.routers.radio.require_connected", return_value=mc):
|
||||
with patch("app.routers.radio.radio_manager.require_connected", return_value=mc):
|
||||
response = await get_radio_config()
|
||||
|
||||
assert response.public_key == "aa" * 32
|
||||
@@ -114,7 +114,7 @@ class TestGetRadioConfig:
|
||||
mc = _mock_meshcore_with_info()
|
||||
mc.self_info["multi_acks"] = 1
|
||||
|
||||
with patch("app.routers.radio.require_connected", return_value=mc):
|
||||
with patch("app.routers.radio.radio_manager.require_connected", return_value=mc):
|
||||
response = await get_radio_config()
|
||||
|
||||
assert response.multi_acks_enabled is True
|
||||
@@ -124,7 +124,7 @@ class TestGetRadioConfig:
|
||||
mc = _mock_meshcore_with_info()
|
||||
mc.self_info["adv_loc_policy"] = 1
|
||||
|
||||
with patch("app.routers.radio.require_connected", return_value=mc):
|
||||
with patch("app.routers.radio.radio_manager.require_connected", return_value=mc):
|
||||
response = await get_radio_config()
|
||||
|
||||
assert response.advert_location_source == "current"
|
||||
@@ -133,7 +133,7 @@ class TestGetRadioConfig:
|
||||
async def test_returns_503_when_self_info_missing(self):
|
||||
mc = MagicMock()
|
||||
mc.self_info = None
|
||||
with patch("app.routers.radio.require_connected", return_value=mc):
|
||||
with patch("app.routers.radio.radio_manager.require_connected", return_value=mc):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_radio_config()
|
||||
|
||||
@@ -155,7 +155,7 @@ class TestUpdateRadioConfig:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock) as mock_sync_time,
|
||||
patch(
|
||||
@@ -190,7 +190,7 @@ class TestUpdateRadioConfig:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
|
||||
patch(
|
||||
@@ -220,7 +220,7 @@ class TestUpdateRadioConfig:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
|
||||
patch(
|
||||
@@ -252,7 +252,7 @@ class TestUpdateRadioConfig:
|
||||
mc = _mock_meshcore_with_info()
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch.object(radio_manager, "path_hash_mode_supported", False),
|
||||
):
|
||||
@@ -269,7 +269,7 @@ class TestUpdateRadioConfig:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch.object(radio_manager, "path_hash_mode_supported", True),
|
||||
patch.object(radio_manager, "path_hash_mode", 0),
|
||||
@@ -287,7 +287,7 @@ class TestPrivateKeyImport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_hex(self):
|
||||
mc = _mock_meshcore_with_info()
|
||||
with patch("app.routers.radio.require_connected", return_value=mc):
|
||||
with patch("app.routers.radio.radio_manager.require_connected", return_value=mc):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await set_private_key(PrivateKeyUpdate(private_key="not-hex"))
|
||||
|
||||
@@ -300,7 +300,7 @@ class TestPrivateKeyImport:
|
||||
return_value=_radio_result(EventType.ERROR, {"error": "failed"})
|
||||
)
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -367,7 +367,7 @@ class TestDiscoverMesh:
|
||||
mc.commands.send_node_discover_req = AsyncMock(side_effect=_send_node_discover_req)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||
patch(
|
||||
@@ -441,7 +441,7 @@ class TestDiscoverMesh:
|
||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||
patch(
|
||||
@@ -517,7 +517,7 @@ class TestDiscoverMesh:
|
||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||
patch(
|
||||
@@ -591,7 +591,7 @@ class TestTracePath:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
||||
@@ -648,7 +648,7 @@ class TestTracePath:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch(
|
||||
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
||||
) as mock_get,
|
||||
@@ -691,7 +691,7 @@ class TestTracePath:
|
||||
mc.wait_for_event = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
||||
@@ -731,7 +731,7 @@ class TestTracePath:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.radio_manager") as mock_rm,
|
||||
):
|
||||
@@ -775,7 +775,7 @@ class TestTracePath:
|
||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||
patch(
|
||||
@@ -811,7 +811,7 @@ class TestTracePath:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -825,7 +825,7 @@ class TestTracePath:
|
||||
mc = _mock_meshcore_with_info()
|
||||
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.keystore.export_and_store_private_key",
|
||||
@@ -843,7 +843,7 @@ class TestTracePath:
|
||||
mc = _mock_meshcore_with_info()
|
||||
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.keystore.export_and_store_private_key",
|
||||
@@ -864,7 +864,7 @@ class TestTracePath:
|
||||
mc = _mock_meshcore_with_info()
|
||||
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.keystore.export_and_store_private_key",
|
||||
@@ -883,7 +883,7 @@ class TestAdvertise:
|
||||
async def test_raises_when_send_fails(self):
|
||||
radio_manager._meshcore = MagicMock()
|
||||
with (
|
||||
patch("app.routers.radio.require_connected"),
|
||||
patch("app.routers.radio.radio_manager.require_connected"),
|
||||
patch(
|
||||
"app.routers.radio.do_send_advertisement",
|
||||
new_callable=AsyncMock,
|
||||
@@ -899,7 +899,7 @@ class TestAdvertise:
|
||||
async def test_defaults_to_flood_mode(self):
|
||||
radio_manager._meshcore = MagicMock()
|
||||
with (
|
||||
patch("app.routers.radio.require_connected"),
|
||||
patch("app.routers.radio.radio_manager.require_connected"),
|
||||
patch(
|
||||
"app.routers.radio.do_send_advertisement",
|
||||
new_callable=AsyncMock,
|
||||
@@ -917,7 +917,7 @@ class TestAdvertise:
|
||||
async def test_accepts_zero_hop_mode(self):
|
||||
radio_manager._meshcore = MagicMock()
|
||||
with (
|
||||
patch("app.routers.radio.require_connected"),
|
||||
patch("app.routers.radio.radio_manager.require_connected"),
|
||||
patch(
|
||||
"app.routers.radio.do_send_advertisement",
|
||||
new_callable=AsyncMock,
|
||||
@@ -949,7 +949,7 @@ class TestAdvertise:
|
||||
isolated_manager = RadioManager()
|
||||
isolated_manager._meshcore = MagicMock()
|
||||
with (
|
||||
patch("app.routers.radio.require_connected"),
|
||||
patch("app.routers.radio.radio_manager.require_connected"),
|
||||
patch("app.routers.radio.radio_manager", _runtime(isolated_manager)),
|
||||
patch(
|
||||
"app.routers.radio.do_send_advertisement",
|
||||
|
||||
@@ -296,7 +296,7 @@ class TestRepeaterCommandRoute:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -314,7 +314,7 @@ class TestRepeaterCommandRoute:
|
||||
|
||||
# Expire the deadline after a couple of ticks
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
@@ -343,7 +343,7 @@ class TestRepeaterCommandRoute:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -371,7 +371,7 @@ class TestRepeaterCommandRoute:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -397,7 +397,7 @@ class TestRepeaterCommandRoute:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -425,7 +425,7 @@ class TestRepeaterCommandRoute:
|
||||
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -451,7 +451,7 @@ class TestRepeaterCommandRoute:
|
||||
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -474,7 +474,7 @@ class TestRepeaterCommandRoute:
|
||||
mc.commands.get_msg = AsyncMock(side_effect=[no_msgs, expected])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
@@ -495,7 +495,7 @@ class TestTraceRoute:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch("app.routers.contacts.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||
):
|
||||
@@ -517,7 +517,7 @@ class TestTraceRoute:
|
||||
mc.wait_for_event = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch("app.routers.contacts.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||
):
|
||||
@@ -541,7 +541,7 @@ class TestTraceRoute:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch("app.routers.contacts.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||
):
|
||||
@@ -569,7 +569,7 @@ class TestRepeaterLogin:
|
||||
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.routers.repeaters.prepare_repeater_connection",
|
||||
@@ -592,7 +592,7 @@ class TestRepeaterLogin:
|
||||
async def test_404_missing_contact(self, test_db):
|
||||
mc = _mock_mc()
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -604,7 +604,7 @@ class TestRepeaterLogin:
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -625,7 +625,7 @@ class TestRepeaterLogin:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.repeaters.prepare_repeater_connection", side_effect=_prepare_fail),
|
||||
):
|
||||
@@ -726,7 +726,7 @@ class TestRepeaterStatus:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_status(KEY_A)
|
||||
@@ -749,7 +749,7 @@ class TestRepeaterStatus:
|
||||
mc.commands.req_status_sync = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -761,7 +761,7 @@ class TestRepeaterStatus:
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -787,7 +787,7 @@ class TestRepeaterLppTelemetry:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_lpp_telemetry(KEY_A)
|
||||
@@ -809,7 +809,7 @@ class TestRepeaterLppTelemetry:
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_lpp_telemetry(KEY_A)
|
||||
@@ -823,7 +823,7 @@ class TestRepeaterLppTelemetry:
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -835,7 +835,7 @@ class TestRepeaterLppTelemetry:
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -861,7 +861,7 @@ class TestRepeaterNeighbors:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_neighbors(KEY_A)
|
||||
@@ -879,7 +879,7 @@ class TestRepeaterNeighbors:
|
||||
mc.commands.fetch_all_neighbours = AsyncMock(return_value={"neighbours": []})
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_neighbors(KEY_A)
|
||||
@@ -893,7 +893,7 @@ class TestRepeaterNeighbors:
|
||||
mc.commands.fetch_all_neighbours = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_neighbors(KEY_A)
|
||||
@@ -917,7 +917,7 @@ class TestRepeaterAcl:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_acl(KEY_A)
|
||||
@@ -935,7 +935,7 @@ class TestRepeaterAcl:
|
||||
mc.commands.req_acl_sync = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_acl(KEY_A)
|
||||
@@ -949,7 +949,7 @@ class TestRepeaterAcl:
|
||||
mc.commands.req_acl_sync = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await repeater_acl(KEY_A)
|
||||
@@ -982,7 +982,7 @@ class TestRepeaterRadioSettings:
|
||||
mc.commands.get_msg = AsyncMock(side_effect=get_msg_results)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -1015,7 +1015,7 @@ class TestRepeaterRadioSettings:
|
||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
@@ -1031,7 +1031,7 @@ class TestRepeaterRadioSettings:
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -1061,7 +1061,7 @@ class TestRepeaterNodeInfo:
|
||||
mc.commands.get_msg = AsyncMock(side_effect=get_msg_results)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -1090,7 +1090,7 @@ class TestRepeaterNodeInfo:
|
||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
@@ -1122,7 +1122,7 @@ class TestRepeaterAdvertIntervals:
|
||||
mc.commands.get_msg = AsyncMock(side_effect=responses)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -1143,7 +1143,7 @@ class TestRepeaterAdvertIntervals:
|
||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
@@ -1177,7 +1177,7 @@ class TestRepeaterOwnerInfo:
|
||||
mc.commands.get_msg = AsyncMock(side_effect=responses)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
@@ -1198,7 +1198,7 @@ class TestRepeaterOwnerInfo:
|
||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
@@ -1299,7 +1299,7 @@ class TestRepeaterAddContactError:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -1317,7 +1317,7 @@ class TestRepeaterAddContactError:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -1335,7 +1335,7 @@ class TestRepeaterAddContactError:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -1353,7 +1353,7 @@ class TestRepeaterAddContactError:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
|
||||
@@ -623,7 +623,6 @@ class TestAppSettingsRepository:
|
||||
"favorites": "{not-json",
|
||||
"auto_decrypt_dm_on_advert": 1,
|
||||
"last_message_times": "{also-not-json",
|
||||
"preferences_migrated": 0,
|
||||
"advert_interval": None,
|
||||
"last_advert_time": None,
|
||||
"flood_scope": "",
|
||||
@@ -672,39 +671,6 @@ class TestAppSettingsRepository:
|
||||
assert result == existing
|
||||
mock_update.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_preferences_uses_recent_for_invalid_sort_order(self):
|
||||
"""Migration normalizes invalid sort order to 'recent'."""
|
||||
from app.models import AppSettings
|
||||
|
||||
current = AppSettings(preferences_migrated=False)
|
||||
migrated = AppSettings(preferences_migrated=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=current,
|
||||
),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.update",
|
||||
new_callable=AsyncMock,
|
||||
return_value=migrated,
|
||||
) as mock_update,
|
||||
):
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
result, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
|
||||
favorites=[{"type": "contact", "id": "bb" * 32}],
|
||||
sort_order="weird-order",
|
||||
last_message_times={"contact-bbbbbbbbbbbb": 123},
|
||||
)
|
||||
|
||||
assert did_migrate is True
|
||||
assert result.preferences_migrated is True
|
||||
assert "sidebar_sort_order" not in mock_update.call_args.kwargs
|
||||
assert mock_update.call_args.kwargs["preferences_migrated"] is True
|
||||
|
||||
|
||||
class TestMessageRepositoryGetById:
|
||||
"""Test MessageRepository.get_by_id method."""
|
||||
|
||||
@@ -88,7 +88,7 @@ class TestRoomLogin:
|
||||
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch("app.routers.rooms.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
|
||||
@@ -102,7 +102,7 @@ class TestRoomLogin:
|
||||
await _insert_contact(ROOM_KEY, name="Client", contact_type=1)
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch("app.routers.rooms.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -139,7 +139,7 @@ class TestRoomStatus:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch("app.routers.rooms.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await room_status(ROOM_KEY)
|
||||
@@ -156,7 +156,7 @@ class TestRoomStatus:
|
||||
mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": AUTHOR_KEY[:12], "perm": 3}])
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch("app.routers.rooms.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await room_acl(ROOM_KEY)
|
||||
@@ -179,7 +179,7 @@ class TestRoomCommandReuse:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await send_repeater_command(ROOM_KEY, CommandRequest(command="ver"))
|
||||
|
||||
+47
-47
@@ -119,7 +119,7 @@ class TestOutgoingDMBroadcast:
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
@@ -143,7 +143,7 @@ class TestOutgoingDMBroadcast:
|
||||
await _insert_contact("abc123" + "00" * 29, "ContactA")
|
||||
await _insert_contact("abc123" + "ff" * 29, "ContactB")
|
||||
|
||||
with patch("app.routers.messages.require_connected", return_value=mc):
|
||||
with patch("app.routers.messages.radio_manager.require_connected", return_value=mc):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await send_direct_message(
|
||||
SendDirectMessageRequest(destination="abc123", text="Hello")
|
||||
@@ -166,7 +166,7 @@ class TestOutgoingDMBroadcast:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -195,7 +195,7 @@ class TestOutgoingDMBroadcast:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -225,7 +225,7 @@ class TestOutgoingDMBroadcast:
|
||||
assert original_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.routers.messages.time") as mock_time,
|
||||
@@ -267,7 +267,7 @@ class TestOutgoingDMBroadcast:
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event", side_effect=capture_broadcast),
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
@@ -290,7 +290,7 @@ class TestOutgoingDMBroadcast:
|
||||
mc.commands.send_msg = AsyncMock(return_value=_make_radio_result({}))
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task") as mock_create_task,
|
||||
@@ -338,7 +338,7 @@ class TestOutgoingDMBroadcast:
|
||||
with (
|
||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||
patch("app.routers.messages.track_pending_ack", return_value=False),
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||
@@ -386,7 +386,7 @@ class TestOutgoingDMBroadcast:
|
||||
with (
|
||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||
@@ -443,7 +443,7 @@ class TestOutgoingDMBroadcast:
|
||||
with (
|
||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||
@@ -477,7 +477,7 @@ class TestOutgoingChannelBroadcast:
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
@@ -511,7 +511,7 @@ class TestOutgoingChannelBroadcast:
|
||||
assert original_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.routers.messages.time") as mock_time,
|
||||
@@ -537,7 +537,7 @@ class TestOutgoingChannelBroadcast:
|
||||
await ChannelRepository.upsert(key=chan_key, name="#acked")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -564,7 +564,7 @@ class TestOutgoingChannelBroadcast:
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
@@ -594,7 +594,7 @@ class TestOutgoingChannelBroadcast:
|
||||
await AppSettingsRepository.update(flood_scope="Baseline")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -617,7 +617,7 @@ class TestOutgoingChannelBroadcast:
|
||||
await AppSettingsRepository.update(flood_scope="Esperance")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -638,7 +638,7 @@ class TestOutgoingChannelBroadcast:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
@@ -660,7 +660,7 @@ class TestOutgoingChannelBroadcast:
|
||||
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -688,7 +688,7 @@ class TestOutgoingChannelBroadcast:
|
||||
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -729,7 +729,7 @@ class TestOutgoingChannelBroadcast:
|
||||
radio_manager._connection_info = "TCP: 127.0.0.1:4000"
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -753,7 +753,7 @@ class TestOutgoingChannelBroadcast:
|
||||
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.radio.settings.force_channel_slot_reconfigure", True),
|
||||
@@ -781,7 +781,7 @@ class TestOutgoingChannelBroadcast:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
@@ -816,7 +816,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=False)
|
||||
@@ -849,7 +849,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
@@ -877,7 +877,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
@@ -914,7 +914,7 @@ class TestResendChannelMessage:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_error") as mock_broadcast_error,
|
||||
):
|
||||
@@ -943,7 +943,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.routers.messages.time") as mock_time,
|
||||
@@ -989,7 +989,7 @@ class TestResendChannelMessage:
|
||||
mc.commands.send_chan_msg = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
@@ -1022,7 +1022,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
@@ -1048,7 +1048,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
@@ -1062,7 +1062,7 @@ class TestResendChannelMessage:
|
||||
mc = _make_mc(name="MyNode")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(999999, new_timestamp=False)
|
||||
@@ -1088,7 +1088,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
@@ -1115,7 +1115,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -1144,7 +1144,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -1179,7 +1179,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
@@ -1211,7 +1211,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
@@ -1234,7 +1234,7 @@ class TestRadioExceptionMidSend:
|
||||
mc.commands.send_msg = AsyncMock(side_effect=ConnectionError("Serial port disconnected"))
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
@@ -1258,7 +1258,7 @@ class TestRadioExceptionMidSend:
|
||||
mc.commands.send_msg = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
@@ -1286,7 +1286,7 @@ class TestRadioExceptionMidSend:
|
||||
mc.commands.send_chan_msg = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
@@ -1316,7 +1316,7 @@ class TestRadioExceptionMidSend:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
@@ -1341,7 +1341,7 @@ class TestRadioExceptionMidSend:
|
||||
mc.commands.set_channel = AsyncMock(side_effect=TimeoutError("Radio not responding"))
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(TimeoutError):
|
||||
@@ -1377,7 +1377,7 @@ class TestRadioExceptionMidSend:
|
||||
mc.commands.set_channel = AsyncMock(side_effect=TimeoutError("Radio not responding"))
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(TimeoutError):
|
||||
@@ -1407,7 +1407,7 @@ class TestRadioExceptionMidSend:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
@@ -1440,7 +1440,7 @@ class TestConcurrentChannelSends:
|
||||
await ChannelRepository.upsert(key=chan_key_b, name="#bravo")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
@@ -1494,7 +1494,7 @@ class TestConcurrentChannelSends:
|
||||
return original_time() + call_count
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.routers.messages.time") as mock_time,
|
||||
@@ -1537,7 +1537,7 @@ class TestChannelSendLockScope:
|
||||
return await original_create(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch(
|
||||
@@ -1587,7 +1587,7 @@ class TestChannelSendLockScope:
|
||||
mc.commands.send_chan_msg = AsyncMock(side_effect=send_with_self_observation)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
from app.routers.settings import (
|
||||
AppSettingsUpdate,
|
||||
FavoriteRequest,
|
||||
MigratePreferencesRequest,
|
||||
migrate_preferences,
|
||||
TrackedTelemetryRequest,
|
||||
toggle_favorite,
|
||||
toggle_tracked_telemetry,
|
||||
update_settings,
|
||||
)
|
||||
|
||||
@@ -164,41 +165,81 @@ class TestToggleFavorite:
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
class TestMigratePreferences:
|
||||
@pytest.mark.asyncio
|
||||
async def test_maps_frontend_payload_and_returns_migrated_true(self, test_db):
|
||||
request = MigratePreferencesRequest(
|
||||
favorites=[FavoriteRequest(type="contact", id="aa" * 32)],
|
||||
sort_order="alpha",
|
||||
last_message_times={"contact-aaaaaaaaaaaa": 123},
|
||||
class TestToggleTrackedTelemetry:
|
||||
"""Tests for POST /settings/tracked-telemetry/toggle."""
|
||||
|
||||
async def _create_repeater(self, key: str, name: str = "TestRepeater") -> None:
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name=name, type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
|
||||
response = await migrate_preferences(request)
|
||||
|
||||
assert response.migrated is True
|
||||
assert response.settings.preferences_migrated is True
|
||||
assert len(response.settings.favorites) == 1
|
||||
assert response.settings.favorites[0].type == "contact"
|
||||
assert response.settings.favorites[0].id == "aa" * 32
|
||||
assert response.settings.last_message_times == {"contact-aaaaaaaaaaaa": 123}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_migrated_false_when_already_done(self, test_db):
|
||||
# First migration
|
||||
first_request = MigratePreferencesRequest(
|
||||
favorites=[FavoriteRequest(type="contact", id="bb" * 32)],
|
||||
sort_order="recent",
|
||||
last_message_times={},
|
||||
)
|
||||
await migrate_preferences(first_request)
|
||||
async def test_add_repeater_to_tracking(self, test_db):
|
||||
key = "aa" * 32
|
||||
await self._create_repeater(key)
|
||||
|
||||
# Second attempt should be no-op
|
||||
second_request = MigratePreferencesRequest(
|
||||
favorites=[],
|
||||
sort_order="recent",
|
||||
last_message_times={},
|
||||
)
|
||||
response = await migrate_preferences(second_request)
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
|
||||
assert response.migrated is False
|
||||
assert response.settings.preferences_migrated is True
|
||||
assert key in result.tracked_telemetry_repeaters
|
||||
assert result.names[key] == "TestRepeater"
|
||||
|
||||
# Verify persisted
|
||||
settings = await AppSettingsRepository.get()
|
||||
assert key in settings.tracked_telemetry_repeaters
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_repeater_from_tracking(self, test_db):
|
||||
key = "bb" * 32
|
||||
await self._create_repeater(key)
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=[key])
|
||||
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
|
||||
assert key not in result.tracked_telemetry_repeaters
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_non_repeater_contact(self, test_db):
|
||||
key = "cc" * 32
|
||||
await ContactRepository.upsert(ContactUpsert(public_key=key, name="Client", type=1))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_unknown_contact(self, test_db):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key="dd" * 32))
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_when_limit_reached(self, test_db):
|
||||
existing_keys = []
|
||||
for i in range(8):
|
||||
key = f"{i:02x}" * 32
|
||||
await self._create_repeater(key, name=f"Repeater{i}")
|
||||
existing_keys.append(key)
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=existing_keys)
|
||||
|
||||
new_key = "ff" * 32
|
||||
await self._create_repeater(new_key, name="NewRepeater")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=new_key))
|
||||
assert exc_info.value.status_code == 409
|
||||
detail = exc_info.value.detail
|
||||
assert len(detail["tracked_telemetry_repeaters"]) == 8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_still_works_when_limit_reached(self, test_db):
|
||||
"""Toggling OFF an already-tracked repeater should work even at max capacity."""
|
||||
keys = []
|
||||
for i in range(8):
|
||||
key = f"{i:02x}" * 32
|
||||
await self._create_repeater(key)
|
||||
keys.append(key)
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=keys)
|
||||
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=keys[0]))
|
||||
assert keys[0] not in result.tracked_telemetry_repeaters
|
||||
assert len(result.tracked_telemetry_repeaters) == 7
|
||||
|
||||
@@ -386,7 +386,7 @@ class TestPathHashWidthStats:
|
||||
|
||||
with (
|
||||
patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)),
|
||||
patch("app.repository.settings.parse_packet_envelope", side_effect=fake_parse),
|
||||
patch("app.path_utils.parse_packet_envelope", side_effect=fake_parse),
|
||||
):
|
||||
breakdown = await StatisticsRepository._path_hash_width_24h()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user