Compare commits

...

20 Commits

Author SHA1 Message Date
Jack Kingsman c33eb469ac Updating changelog + build for 3.8.0 2026-04-03 19:36:27 -07:00
Jack Kingsman 0fe6584e7a Add packet display to map & add map dark mode 2026-04-03 19:18:22 -07:00
Jack Kingsman 557d79d437 Add packets to general map 2026-04-03 18:57:34 -07:00
Jack Kingsman daff3dcb4a Drop low value tests 2026-04-03 17:55:02 -07:00
Jack Kingsman 77db7287d6 Drop lame imports 2026-04-03 17:51:26 -07:00
Jack Kingsman 67873e8dd9 Drop some duplicated logic and defns 2026-04-03 17:47:44 -07:00
Jack Kingsman e2ddf5f79f Move require connected down into the manager 2026-04-03 17:37:30 -07:00
Jack Kingsman 4a93641f04 Axe some dead code 2026-04-03 17:22:04 -07:00
Jack Kingsman d5922a214b Clear out old migration logic and replace with thin shim for favorites; sort order is lost 2026-04-03 17:15:41 -07:00
Jack Kingsman 7ad1ee26a4 Add RSSI/SNR to received messages. Closes #148. 2026-04-03 15:20:44 -07:00
Jack Kingsman 08238aa464 Add close button to modal. Closes #156 (and modals lol), ish. 2026-04-03 14:54:59 -07:00
Jack Kingsman 1046baf741 Add auto-resend option for not-heard-repeated messages. Closes #154. 2026-04-03 14:43:52 -07:00
Jack Kingsman 42e1b7b5d9 Add canonical style reference. Closes #155. 2026-04-03 14:27:44 -07:00
Jack Kingsman 3ca4f7edf7 Fix missing test failures and patch double declared model 2026-04-03 14:15:19 -07:00
Jack Kingsman 55081d4a2d Add hop width to channel info. Closes #153. 2026-04-03 14:04:35 -07:00
Jack Kingsman be2b2604df Add intervalized repeater metrics collection. Closes #151. 2026-04-03 13:45:39 -07:00
Jack Kingsman 35981d8f8b Be more aggressive about resetting the hop width and warning if that doesn't work. This and the prior work closes #152. 2026-04-03 13:16:43 -07:00
Jack Kingsman 8e998c03ba Add channel path hash width override 2026-04-03 13:05:58 -07:00
Jack Kingsman d802dd4212 Fix table display in primary agents.md 2026-04-02 20:31:54 -07:00
Jack Kingsman 7557eb1fa6 Merge pull request #150 from jkingsman/bugbash-v7
Bugbash v7
2026-04-02 20:20:23 -07:00
99 changed files with 2793 additions and 1057 deletions
+2 -1
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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);
-8
View File
@@ -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()
-13
View File
@@ -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),
+2
View File
@@ -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()
+64
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
+48
View File
@@ -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,
}
+165
View File
@@ -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
View File
@@ -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."""
+29 -2
View File
@@ -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
-9
View File
@@ -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
View File
@@ -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:
+33
View File
@@ -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.
+2 -3
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4 -5
View File
@@ -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
View File
@@ -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 -6
View File
@@ -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:
+17 -5
View File
@@ -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,
+184 -4
View File
@@ -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
+27 -3
View File
@@ -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,
+2
View File
@@ -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:
+12
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.7.1",
"version": "3.8.0",
"type": "module",
"scripts": {
"dev": "vite",
+7
View File
@@ -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
View File
@@ -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: {
+2 -2
View File
@@ -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>
+93 -5
View File
@@ -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 (&lt;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>
);
}
+46 -7
View File
@@ -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>
);
}
+4 -4
View File
@@ -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>
+18 -1
View File
@@ -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}
+643 -65
View File
@@ -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: '&copy; <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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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"
/>{' '}
&lt;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"
/>{' '}
&lt;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"
/>{' '}
&lt;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"
/>{' '}
&lt;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"
/>{' '}
&lt;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"
/>{' '}
&lt;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='&copy; <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>
+22 -6
View File
@@ -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>
+16 -5
View File
@@ -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
+8 -5
View File
@@ -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>
</>
+15 -5
View File
@@ -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>
+4 -4
View File
@@ -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}
/>
) : (
+10 -10
View File
@@ -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}
+1 -1
View File
@@ -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}
+7 -7
View File
@@ -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/&lt;key&gt;/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 &amp; 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 &middot; Primary: bg-primary/10 &middot; 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=&quot;button&quot; +
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">
+1
View File
@@ -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}
+56 -55
View File
@@ -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,
+1 -2
View File
@@ -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: '',
+1 -2
View File
@@ -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,
});
+1 -2
View File
@@ -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,
};
}
-37
View File
@@ -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);
}
});
});
});
+3 -1
View File
@@ -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 () => {
+3 -1
View File
@@ -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
View File
@@ -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 */
-37
View File
@@ -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 -28
View File
@@ -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
}
}
+1 -4
View File
@@ -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,
+1 -1
View File
@@ -28,7 +28,7 @@ export type ServerLoginAttemptState =
at: number;
};
export function getServerLoginMethodLabel(
function getServerLoginMethodLabel(
method: ServerLoginMethod,
blankLabel = 'existing-access'
): string {
+1 -1
View File
@@ -17,7 +17,7 @@ export interface VisualizerSettings {
hidePacketFeed: boolean;
}
export const VISUALIZER_DEFAULTS: VisualizerSettings = {
const VISUALIZER_DEFAULTS: VisualizerSettings = {
showAmbiguousPaths: true,
showAmbiguousNodes: false,
useAdvertPathHints: true,
+1 -1
View File
@@ -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
View File
@@ -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"
+24 -7
View File
@@ -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} "
-1
View File
@@ -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;
}
+4 -4
View File
@@ -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
View File
@@ -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):
+2 -2
View File
@@ -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)
-69
View File
@@ -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)
-13
View File
@@ -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."""
+1 -1
View File
@@ -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
-13
View File
@@ -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
View File
@@ -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
View File
@@ -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
+9 -14
View File
@@ -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
View File
@@ -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",
+41 -41
View File
@@ -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:
-34
View File
@@ -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."""
+5 -5
View File
@@ -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
View File
@@ -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),
):
+78 -37
View File
@@ -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
+1 -1
View File
@@ -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()
Generated
+1 -1
View File
@@ -983,7 +983,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.7.1"
version = "3.8.0"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },