mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 20:36:05 +02:00
Compare commits
20 Commits
bugbash-v7
...
3.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c33eb469ac | |||
| 0fe6584e7a | |||
| 557d79d437 | |||
| daff3dcb4a | |||
| 77db7287d6 | |||
| 67873e8dd9 | |||
| e2ddf5f79f | |||
| 4a93641f04 | |||
| d5922a214b | |||
| 7ad1ee26a4 | |||
| 08238aa464 | |||
| 1046baf741 | |||
| 42e1b7b5d9 | |||
| 3ca4f7edf7 | |||
| 55081d4a2d | |||
| be2b2604df | |||
| 35981d8f8b | |||
| 8e998c03ba | |||
| d802dd4212 | |||
| 7557eb1fa6 |
@@ -347,13 +347,13 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
| POST | `/api/contacts/{public_key}/room/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/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||||
|
|
||||||
| GET | `/api/channels` | List channels |
|
| GET | `/api/channels` | List channels |
|
||||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||||
| POST | `/api/channels` | Create channel |
|
| POST | `/api/channels` | Create channel |
|
||||||
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
| 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}/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 |
|
| 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` | 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) |
|
| 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
|
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||||
- Custom channels: User-provided or generated
|
- 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 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
|
### Message Types
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
## [3.8.0] - 2026-04-03
|
||||||
|
|
||||||
|
* Feature: Per-channel hop width override
|
||||||
|
* Feature: Intervalized repeater telemetry collection
|
||||||
|
* Feature: Auto-resend option for byte-perfect resends on no repeater echo
|
||||||
|
* Feature: Attach RSSI/SNR to received packets
|
||||||
|
* Feature: Add motion packet display to map
|
||||||
|
* Feature: Map dark mode
|
||||||
|
* Bugfix: Make DB indices more useful around capitalization
|
||||||
|
* Misc: Bump required Python to 3.11
|
||||||
|
* Misc: Performance, documentation, and test improvements
|
||||||
|
* Misc: More yields during long radio operations
|
||||||
|
* Misc: Dead code & crufty test removal
|
||||||
|
* Misc: Remove all but stub frontend favorites migration for very very old versions
|
||||||
|
|
||||||
## [3.7.1] - 2026-04-02
|
## [3.7.1] - 2026-04-02
|
||||||
|
|
||||||
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
|
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
|
||||||
|
|||||||
+2
-1
@@ -218,6 +218,7 @@ app/
|
|||||||
- `POST /channels/bulk-hashtag`
|
- `POST /channels/bulk-hashtag`
|
||||||
- `DELETE /channels/{key}`
|
- `DELETE /channels/{key}`
|
||||||
- `POST /channels/{key}/flood-scope-override`
|
- `POST /channels/{key}/flood-scope-override`
|
||||||
|
- `POST /channels/{key}/path-hash-mode-override`
|
||||||
- `POST /channels/{key}/mark-read`
|
- `POST /channels/{key}/mark-read`
|
||||||
|
|
||||||
### Messages
|
### Messages
|
||||||
@@ -280,7 +281,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
|||||||
Main tables:
|
Main tables:
|
||||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||||
- `channels`
|
- `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)
|
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||||
- `raw_packets`
|
- `raw_packets`
|
||||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||||
|
|||||||
+4
-1
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
is_hashtag INTEGER DEFAULT 0,
|
is_hashtag INTEGER DEFAULT 0,
|
||||||
on_radio INTEGER DEFAULT 0,
|
on_radio INTEGER DEFAULT 0,
|
||||||
flood_scope_override TEXT,
|
flood_scope_override TEXT,
|
||||||
|
path_hash_mode_override INTEGER,
|
||||||
last_read_at INTEGER
|
last_read_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -103,7 +104,9 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
flood_scope TEXT DEFAULT '',
|
flood_scope TEXT DEFAULT '',
|
||||||
blocked_keys TEXT DEFAULT '[]',
|
blocked_keys TEXT DEFAULT '[]',
|
||||||
blocked_names 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);
|
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Shared dependencies for FastAPI routers."""
|
|
||||||
|
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
|
||||||
|
|
||||||
|
|
||||||
def require_connected():
|
|
||||||
"""Dependency that ensures radio is connected and returns meshcore instance."""
|
|
||||||
return radio_manager.require_connected()
|
|
||||||
@@ -52,19 +52,6 @@ class ToastPayload(TypedDict):
|
|||||||
details: NotRequired[str]
|
details: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
WsEventPayload = (
|
|
||||||
HealthResponse
|
|
||||||
| Message
|
|
||||||
| Contact
|
|
||||||
| ContactResolvedPayload
|
|
||||||
| Channel
|
|
||||||
| ContactDeletedPayload
|
|
||||||
| ChannelDeletedPayload
|
|
||||||
| RawPacketBroadcast
|
|
||||||
| MessageAckedPayload
|
|
||||||
| ToastPayload
|
|
||||||
)
|
|
||||||
|
|
||||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||||
"health": TypeAdapter(HealthResponse),
|
"health": TypeAdapter(HealthResponse),
|
||||||
"message": TypeAdapter(Message),
|
"message": TypeAdapter(Message),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.radio_sync import (
|
|||||||
stop_message_polling,
|
stop_message_polling,
|
||||||
stop_periodic_advert,
|
stop_periodic_advert,
|
||||||
stop_periodic_sync,
|
stop_periodic_sync,
|
||||||
|
stop_telemetry_collect,
|
||||||
)
|
)
|
||||||
from app.routers import (
|
from app.routers import (
|
||||||
channels,
|
channels,
|
||||||
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await stop_noise_floor_sampling()
|
await stop_noise_floor_sampling()
|
||||||
await stop_periodic_advert()
|
await stop_periodic_advert()
|
||||||
await stop_periodic_sync()
|
await stop_periodic_sync()
|
||||||
|
await stop_telemetry_collect()
|
||||||
if radio_manager.meshcore:
|
if radio_manager.meshcore:
|
||||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||||
await radio_manager.disconnect()
|
await radio_manager.disconnect()
|
||||||
|
|||||||
@@ -395,6 +395,24 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 51)
|
await set_version(conn, 51)
|
||||||
applied += 1
|
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:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"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()
|
await conn.commit()
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add nullable per-channel path hash mode override column."""
|
||||||
|
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
if "channels" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await conn.execute("ALTER TABLE channels ADD COLUMN path_hash_mode_override INTEGER")
|
||||||
|
await conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
if "duplicate column" in str(e).lower():
|
||||||
|
await conn.commit()
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add tracked_telemetry_repeaters JSON list column to app_settings."""
|
||||||
|
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||||
|
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||||
|
if "tracked_telemetry_repeaters" not in columns:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add auto_resend_channel boolean column to app_settings."""
|
||||||
|
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||||
|
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||||
|
if "auto_resend_channel" not in columns:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|||||||
+30
-36
@@ -196,15 +196,6 @@ class Contact(BaseModel):
|
|||||||
"""Convert the stored contact to the repository's write contract."""
|
"""Convert the stored contact to the repository's write contract."""
|
||||||
return ContactUpsert.from_contact(self, **changes)
|
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):
|
class CreateContactRequest(BaseModel):
|
||||||
"""Request to create a new contact."""
|
"""Request to create a new contact."""
|
||||||
@@ -330,6 +321,10 @@ class Channel(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
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
|
last_read_at: int | None = None # Server-side read state tracking
|
||||||
|
|
||||||
|
|
||||||
@@ -351,6 +346,18 @@ class ChannelTopSender(BaseModel):
|
|||||||
message_count: int
|
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):
|
class ChannelDetail(BaseModel):
|
||||||
"""Comprehensive channel profile data."""
|
"""Comprehensive channel profile data."""
|
||||||
|
|
||||||
@@ -359,6 +366,7 @@ class ChannelDetail(BaseModel):
|
|||||||
first_message_at: int | None = None
|
first_message_at: int | None = None
|
||||||
unique_sender_count: int = 0
|
unique_sender_count: int = 0
|
||||||
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
||||||
|
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
|
||||||
|
|
||||||
|
|
||||||
class MessagePath(BaseModel):
|
class MessagePath(BaseModel):
|
||||||
@@ -370,6 +378,8 @@ class MessagePath(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
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):
|
class Message(BaseModel):
|
||||||
@@ -791,10 +801,6 @@ class AppSettings(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Map of conversation state keys to last message timestamps",
|
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(
|
advert_interval: int = Field(
|
||||||
default=0,
|
default=0,
|
||||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
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"
|
"advertisements should not create new contacts; existing contacts are still updated"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
tracked_telemetry_repeaters: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
class FanoutConfig(BaseModel):
|
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||||
"""Configuration for a single fanout integration."""
|
)
|
||||||
|
auto_resend_channel: bool = Field(
|
||||||
id: str
|
default=False,
|
||||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
|
description=(
|
||||||
name: str
|
"When enabled, outgoing channel messages that receive no echo within 2 seconds "
|
||||||
enabled: bool
|
"are automatically byte-perfect resent once (within the 30-second dedup window)"
|
||||||
config: dict
|
),
|
||||||
scope: dict
|
)
|
||||||
sort_order: int = 0
|
|
||||||
created_at: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class BusyChannel(BaseModel):
|
class BusyChannel(BaseModel):
|
||||||
@@ -849,16 +853,6 @@ class ContactActivityCounts(BaseModel):
|
|||||||
last_week: int
|
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):
|
class NoiseFloorSample(BaseModel):
|
||||||
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||||
|
|||||||
+22
-2
@@ -68,6 +68,8 @@ async def create_message_from_decrypted(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
channel_name: str | None = None,
|
channel_name: str | None = None,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@@ -81,6 +83,8 @@ async def create_message_from_decrypted(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
broadcast_fn=broadcast_event,
|
broadcast_fn=broadcast_event,
|
||||||
@@ -95,6 +99,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@@ -107,6 +113,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
broadcast_fn=broadcast_event,
|
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.
|
# 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.
|
# This is more reliable than trying to look up the message via raw packet linking.
|
||||||
if payload_type == PayloadType.GROUP_TEXT:
|
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:
|
if decrypt_result:
|
||||||
result.update(decrypt_result)
|
result.update(decrypt_result)
|
||||||
|
|
||||||
@@ -330,7 +340,9 @@ async def process_raw_packet(
|
|||||||
|
|
||||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||||
# Try to decrypt direct messages using stored private key and known contacts
|
# 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:
|
if decrypt_result:
|
||||||
result.update(decrypt_result)
|
result.update(decrypt_result)
|
||||||
|
|
||||||
@@ -367,6 +379,8 @@ async def _process_group_text(
|
|||||||
packet_id: int,
|
packet_id: int,
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
packet_info: PacketInfo | None,
|
packet_info: PacketInfo | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Process a GroupText (channel message) packet.
|
Process a GroupText (channel message) packet.
|
||||||
@@ -403,6 +417,8 @@ async def _process_group_text(
|
|||||||
received_at=timestamp,
|
received_at=timestamp,
|
||||||
path=packet_info.path.hex() if packet_info else None,
|
path=packet_info.path.hex() if packet_info else None,
|
||||||
path_len=packet_info.path_length if packet_info else None,
|
path_len=packet_info.path_length if packet_info else None,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -544,6 +560,8 @@ async def _process_direct_message(
|
|||||||
packet_id: int,
|
packet_id: int,
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
packet_info: PacketInfo | None,
|
packet_info: PacketInfo | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Process a TEXT_MESSAGE (direct message) packet.
|
Process a TEXT_MESSAGE (direct message) packet.
|
||||||
@@ -644,6 +662,8 @@ async def _process_direct_message(
|
|||||||
received_at=timestamp,
|
received_at=timestamp,
|
||||||
path=packet_info.path.hex() if packet_info else None,
|
path=packet_info.path.hex() if packet_info else None,
|
||||||
path_len=packet_info.path_length if packet_info else None,
|
path_len=packet_info.path_length if packet_info else None,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=is_outgoing,
|
outgoing=is_outgoing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -244,3 +244,51 @@ def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
|||||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||||
|
|
||||||
return "".join(hops), len(hops), hash_size - 1
|
return "".join(hops), len(hops), hash_size - 1
|
||||||
|
|
||||||
|
|
||||||
|
async def bucket_path_hash_widths(cursor, *, batch_size: int = 500) -> dict[str, int | float]:
|
||||||
|
"""Bucket raw packet rows by hop hash width and return counts + percentages.
|
||||||
|
|
||||||
|
*cursor* must be an already-executed async cursor whose rows have a ``data``
|
||||||
|
column containing raw packet bytes.
|
||||||
|
"""
|
||||||
|
single_byte = 0
|
||||||
|
double_byte = 0
|
||||||
|
triple_byte = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rows = await cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||||
|
if envelope is None:
|
||||||
|
continue
|
||||||
|
if envelope.hash_size == 1:
|
||||||
|
single_byte += 1
|
||||||
|
elif envelope.hash_size == 2:
|
||||||
|
double_byte += 1
|
||||||
|
elif envelope.hash_size == 3:
|
||||||
|
triple_byte += 1
|
||||||
|
|
||||||
|
total = single_byte + double_byte + triple_byte
|
||||||
|
if total == 0:
|
||||||
|
return {
|
||||||
|
"total_packets": 0,
|
||||||
|
"single_byte": 0,
|
||||||
|
"double_byte": 0,
|
||||||
|
"triple_byte": 0,
|
||||||
|
"single_byte_pct": 0.0,
|
||||||
|
"double_byte_pct": 0.0,
|
||||||
|
"triple_byte_pct": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_packets": total,
|
||||||
|
"single_byte": single_byte,
|
||||||
|
"double_byte": double_byte,
|
||||||
|
"triple_byte": triple_byte,
|
||||||
|
"single_byte_pct": (single_byte / total) * 100,
|
||||||
|
"double_byte_pct": (double_byte / total) * 100,
|
||||||
|
"triple_byte_pct": (triple_byte / total) * 100,
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from app.repository import (
|
|||||||
AppSettingsRepository,
|
AppSettingsRepository,
|
||||||
ChannelRepository,
|
ChannelRepository,
|
||||||
ContactRepository,
|
ContactRepository,
|
||||||
|
RepeaterTelemetryRepository,
|
||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import (
|
from app.services.contact_reconciliation import (
|
||||||
promote_prefix_contacts_for_contact,
|
promote_prefix_contacts_for_contact,
|
||||||
@@ -155,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
|
|||||||
# more frequently than this.
|
# more frequently than this.
|
||||||
MIN_ADVERT_INTERVAL = 3600
|
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)
|
# Counter to pause polling during repeater operations (supports nested pauses)
|
||||||
_polling_pause_count: int = 0
|
_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:
|
except Exception as e:
|
||||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||||
return {"loaded": 0, "error": str(e)}
|
return {"loaded": 0, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Periodic repeater telemetry collection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||||
|
"""Fetch status telemetry from a single repeater and record it.
|
||||||
|
|
||||||
|
Returns True on success, False on failure (logged, not raised).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await mc.commands.add_contact(contact.to_radio_dict())
|
||||||
|
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Telemetry collect: radio command failed for %s: %s",
|
||||||
|
contact.public_key[:12],
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Map to the same field names as the manual repeater status endpoint
|
||||||
|
data = {
|
||||||
|
"battery_volts": status.get("bat", 0) / 1000.0,
|
||||||
|
"tx_queue_len": status.get("tx_queue_len", 0),
|
||||||
|
"noise_floor_dbm": status.get("noise_floor", 0),
|
||||||
|
"last_rssi_dbm": status.get("last_rssi", 0),
|
||||||
|
"last_snr_db": status.get("last_snr", 0.0),
|
||||||
|
"packets_received": status.get("nb_recv", 0),
|
||||||
|
"packets_sent": status.get("nb_sent", 0),
|
||||||
|
"airtime_seconds": status.get("airtime", 0),
|
||||||
|
"rx_airtime_seconds": status.get("rx_airtime", 0),
|
||||||
|
"uptime_seconds": status.get("uptime", 0),
|
||||||
|
"sent_flood": status.get("sent_flood", 0),
|
||||||
|
"sent_direct": status.get("sent_direct", 0),
|
||||||
|
"recv_flood": status.get("recv_flood", 0),
|
||||||
|
"recv_direct": status.get("recv_direct", 0),
|
||||||
|
"flood_dups": status.get("flood_dups", 0),
|
||||||
|
"direct_dups": status.get("direct_dups", 0),
|
||||||
|
"full_events": status.get("full_evts", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=contact.public_key,
|
||||||
|
timestamp=int(time.time()),
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Telemetry collect: recorded snapshot for %s (%s)",
|
||||||
|
contact.name or contact.public_key[:12],
|
||||||
|
contact.public_key[:12],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Telemetry collect: failed to record for %s: %s",
|
||||||
|
contact.public_key[:12],
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _telemetry_collect_loop() -> None:
|
||||||
|
"""Background task that collects telemetry from tracked repeaters every 8 hours.
|
||||||
|
|
||||||
|
Runs a first cycle after a short initial delay (so newly tracked repeaters
|
||||||
|
get a sample promptly), then sleeps the full interval between subsequent cycles.
|
||||||
|
|
||||||
|
Acquires the radio lock per-repeater (non-blocking) so manual operations can
|
||||||
|
interleave. Failures are logged and skipped.
|
||||||
|
"""
|
||||||
|
first_run = True
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
first_run = False
|
||||||
|
|
||||||
|
if not radio_manager.is_connected:
|
||||||
|
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
||||||
|
continue
|
||||||
|
|
||||||
|
app_settings = await AppSettingsRepository.get()
|
||||||
|
tracked = app_settings.tracked_telemetry_repeaters
|
||||||
|
if not tracked:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
|
||||||
|
collected = 0
|
||||||
|
|
||||||
|
for pub_key in tracked:
|
||||||
|
contact = await ContactRepository.get_by_key(pub_key)
|
||||||
|
if not contact or contact.type != 2:
|
||||||
|
logger.debug(
|
||||||
|
"Telemetry collect: skipping %s (not found or not repeater)",
|
||||||
|
pub_key[:12],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"telemetry_collect",
|
||||||
|
blocking=False,
|
||||||
|
suspend_auto_fetch=True,
|
||||||
|
) as mc:
|
||||||
|
if await _collect_repeater_telemetry(mc, contact):
|
||||||
|
collected += 1
|
||||||
|
except RadioOperationBusyError:
|
||||||
|
logger.debug(
|
||||||
|
"Telemetry collect: radio busy, skipping %s",
|
||||||
|
pub_key[:12],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Telemetry collect: cycle complete, %d/%d successful",
|
||||||
|
collected,
|
||||||
|
len(tracked),
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Telemetry collect task cancelled")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_telemetry_collect() -> None:
|
||||||
|
"""Start the periodic telemetry collection background task."""
|
||||||
|
global _telemetry_collect_task
|
||||||
|
if _telemetry_collect_task is None or _telemetry_collect_task.done():
|
||||||
|
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
|
||||||
|
logger.info(
|
||||||
|
"Started periodic telemetry collection (interval: %ds)",
|
||||||
|
TELEMETRY_COLLECT_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_telemetry_collect() -> None:
|
||||||
|
"""Stop the periodic telemetry collection background task."""
|
||||||
|
global _telemetry_collect_task
|
||||||
|
if _telemetry_collect_task and not _telemetry_collect_task.done():
|
||||||
|
_telemetry_collect_task.cancel()
|
||||||
|
try:
|
||||||
|
await _telemetry_collect_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_telemetry_collect_task = None
|
||||||
|
logger.info("Stopped periodic telemetry collection")
|
||||||
|
|||||||
+14
-26
@@ -26,7 +26,7 @@ class ChannelRepository:
|
|||||||
"""Get a channel by its key (32-char hex string)."""
|
"""Get a channel by its key (32-char hex string)."""
|
||||||
cursor = await db.conn.execute(
|
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
|
FROM channels
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
""",
|
""",
|
||||||
@@ -40,6 +40,7 @@ class ChannelRepository:
|
|||||||
is_hashtag=bool(row["is_hashtag"]),
|
is_hashtag=bool(row["is_hashtag"]),
|
||||||
on_radio=bool(row["on_radio"]),
|
on_radio=bool(row["on_radio"]),
|
||||||
flood_scope_override=row["flood_scope_override"],
|
flood_scope_override=row["flood_scope_override"],
|
||||||
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -48,7 +49,7 @@ class ChannelRepository:
|
|||||||
async def get_all() -> list[Channel]:
|
async def get_all() -> list[Channel]:
|
||||||
cursor = await db.conn.execute(
|
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
|
FROM channels
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""
|
"""
|
||||||
@@ -61,30 +62,7 @@ class ChannelRepository:
|
|||||||
is_hashtag=bool(row["is_hashtag"]),
|
is_hashtag=bool(row["is_hashtag"]),
|
||||||
on_radio=bool(row["on_radio"]),
|
on_radio=bool(row["on_radio"]),
|
||||||
flood_scope_override=row["flood_scope_override"],
|
flood_scope_override=row["flood_scope_override"],
|
||||||
last_read_at=row["last_read_at"],
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
)
|
|
||||||
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"],
|
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
@@ -123,6 +101,16 @@ class ChannelRepository:
|
|||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
return cursor.rowcount > 0
|
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
|
@staticmethod
|
||||||
async def mark_all_read(timestamp: int) -> None:
|
async def mark_all_read(timestamp: int) -> None:
|
||||||
"""Mark all channels as read at the given timestamp."""
|
"""Mark all channels as read at the given timestamp."""
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class MessageRepository:
|
|||||||
sender_timestamp: int | None = None,
|
sender_timestamp: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
txt_type: int = 0,
|
txt_type: int = 0,
|
||||||
signature: str | None = None,
|
signature: str | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
@@ -78,6 +80,10 @@ class MessageRepository:
|
|||||||
entry: dict = {"path": path, "received_at": received_at}
|
entry: dict = {"path": path, "received_at": received_at}
|
||||||
if path_len is not None:
|
if path_len is not None:
|
||||||
entry["path_len"] = path_len
|
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])
|
paths_json = json.dumps([entry])
|
||||||
|
|
||||||
# Normalize sender_key to lowercase so queries can match without LOWER().
|
# Normalize sender_key to lowercase so queries can match without LOWER().
|
||||||
@@ -116,6 +122,8 @@ class MessageRepository:
|
|||||||
path: str,
|
path: str,
|
||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> list[MessagePath]:
|
) -> list[MessagePath]:
|
||||||
"""Add a new path to an existing message.
|
"""Add a new path to an existing message.
|
||||||
|
|
||||||
@@ -129,6 +137,10 @@ class MessageRepository:
|
|||||||
entry: dict = {"path": path, "received_at": ts}
|
entry: dict = {"path": path, "received_at": ts}
|
||||||
if path_len is not None:
|
if path_len is not None:
|
||||||
entry["path_len"] = path_len
|
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)
|
new_entry = json.dumps(entry)
|
||||||
await db.conn.execute(
|
await db.conn.execute(
|
||||||
"""UPDATE messages SET paths = json_insert(
|
"""UPDATE messages SET paths = json_insert(
|
||||||
@@ -786,12 +798,14 @@ class MessageRepository:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_channel_stats(conversation_key: str) -> dict:
|
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
|
import time as _time
|
||||||
|
|
||||||
|
from app.path_utils import bucket_path_hash_widths
|
||||||
|
|
||||||
now = int(_time.time())
|
now = int(_time.time())
|
||||||
t_1h = now - 3600
|
t_1h = now - 3600
|
||||||
t_24h = now - 86400
|
t_24h = now - 86400
|
||||||
@@ -843,11 +857,24 @@ class MessageRepository:
|
|||||||
for r in top_rows
|
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 {
|
return {
|
||||||
"message_counts": message_counts,
|
"message_counts": message_counts,
|
||||||
"first_message_at": row["first_message_at"],
|
"first_message_at": row["first_message_at"],
|
||||||
"unique_sender_count": row["unique_sender_count"] or 0,
|
"unique_sender_count": row["unique_sender_count"] or 0,
|
||||||
"top_senders_24h": top_senders,
|
"top_senders_24h": top_senders,
|
||||||
|
"path_hash_width_24h": path_hash_width_24h,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -172,12 +172,3 @@ class RawPacketRepository:
|
|||||||
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
|
||||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
|
||||||
|
|
||||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
|
||||||
These are direct messages that can be decrypted with contact ECDH keys.
|
|
||||||
"""
|
|
||||||
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
|
|
||||||
|
|||||||
+32
-83
@@ -5,7 +5,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
from app.models import AppSettings, Favorite
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,9 +27,10 @@ class AppSettingsRepository:
|
|||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
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,
|
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
|
FROM app_settings WHERE id = 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -89,18 +90,34 @@ class AppSettingsRepository:
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
discovery_blocked_types = []
|
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(
|
return AppSettings(
|
||||||
max_radio_contacts=row["max_radio_contacts"],
|
max_radio_contacts=row["max_radio_contacts"],
|
||||||
favorites=favorites,
|
favorites=favorites,
|
||||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||||
last_message_times=last_message_times,
|
last_message_times=last_message_times,
|
||||||
preferences_migrated=bool(row["preferences_migrated"]),
|
|
||||||
advert_interval=row["advert_interval"] or 0,
|
advert_interval=row["advert_interval"] or 0,
|
||||||
last_advert_time=row["last_advert_time"] or 0,
|
last_advert_time=row["last_advert_time"] or 0,
|
||||||
flood_scope=row["flood_scope"] or "",
|
flood_scope=row["flood_scope"] or "",
|
||||||
blocked_keys=blocked_keys,
|
blocked_keys=blocked_keys,
|
||||||
blocked_names=blocked_names,
|
blocked_names=blocked_names,
|
||||||
discovery_blocked_types=discovery_blocked_types,
|
discovery_blocked_types=discovery_blocked_types,
|
||||||
|
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||||
|
auto_resend_channel=auto_resend_channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -109,13 +126,14 @@ class AppSettingsRepository:
|
|||||||
favorites: list[Favorite] | None = None,
|
favorites: list[Favorite] | None = None,
|
||||||
auto_decrypt_dm_on_advert: bool | None = None,
|
auto_decrypt_dm_on_advert: bool | None = None,
|
||||||
last_message_times: dict[str, int] | None = None,
|
last_message_times: dict[str, int] | None = None,
|
||||||
preferences_migrated: bool | None = None,
|
|
||||||
advert_interval: int | None = None,
|
advert_interval: int | None = None,
|
||||||
last_advert_time: int | None = None,
|
last_advert_time: int | None = None,
|
||||||
flood_scope: str | None = None,
|
flood_scope: str | None = None,
|
||||||
blocked_keys: list[str] | None = None,
|
blocked_keys: list[str] | None = None,
|
||||||
blocked_names: list[str] | None = None,
|
blocked_names: list[str] | None = None,
|
||||||
discovery_blocked_types: list[int] | None = None,
|
discovery_blocked_types: list[int] | None = None,
|
||||||
|
tracked_telemetry_repeaters: list[str] | None = None,
|
||||||
|
auto_resend_channel: bool | None = None,
|
||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
"""Update app settings. Only provided fields are updated."""
|
"""Update app settings. Only provided fields are updated."""
|
||||||
updates = []
|
updates = []
|
||||||
@@ -138,10 +156,6 @@ class AppSettingsRepository:
|
|||||||
updates.append("last_message_times = ?")
|
updates.append("last_message_times = ?")
|
||||||
params.append(json.dumps(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:
|
if advert_interval is not None:
|
||||||
updates.append("advert_interval = ?")
|
updates.append("advert_interval = ?")
|
||||||
params.append(advert_interval)
|
params.append(advert_interval)
|
||||||
@@ -166,6 +180,14 @@ class AppSettingsRepository:
|
|||||||
updates.append("discovery_blocked_types = ?")
|
updates.append("discovery_blocked_types = ?")
|
||||||
params.append(json.dumps(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:
|
if updates:
|
||||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||||
await db.conn.execute(query, params)
|
await db.conn.execute(query, params)
|
||||||
@@ -215,38 +237,6 @@ class AppSettingsRepository:
|
|||||||
new_names = settings.blocked_names + [name]
|
new_names = settings.blocked_names + [name]
|
||||||
return await AppSettingsRepository.update(blocked_names=new_names)
|
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:
|
class StatisticsRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -334,48 +324,7 @@ class StatisticsRepository:
|
|||||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||||
(now - SECONDS_24H,),
|
(now - SECONDS_24H,),
|
||||||
)
|
)
|
||||||
|
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_all() -> dict:
|
async def get_all() -> dict:
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ class ChannelFloodScopeOverrideRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelPathHashModeOverrideRequest(BaseModel):
|
||||||
|
path_hash_mode_override: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=0,
|
||||||
|
le=2,
|
||||||
|
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _derive_channel_identity(
|
def _derive_channel_identity(
|
||||||
requested_name: str,
|
requested_name: str,
|
||||||
request_key: str | None = None,
|
request_key: str | None = None,
|
||||||
@@ -206,6 +215,7 @@ async def get_channel_detail(key: str) -> ChannelDetail:
|
|||||||
first_message_at=stats["first_message_at"],
|
first_message_at=stats["first_message_at"],
|
||||||
unique_sender_count=stats["unique_sender_count"],
|
unique_sender_count=stats["unique_sender_count"],
|
||||||
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
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
|
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}")
|
@router.delete("/{key}")
|
||||||
async def delete_channel(key: str) -> dict:
|
async def delete_channel(key: str) -> dict:
|
||||||
"""Delete a channel from the database by key.
|
"""Delete a channel from the database by key.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
|||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Contact,
|
Contact,
|
||||||
ContactActiveRoom,
|
ContactActiveRoom,
|
||||||
@@ -428,7 +427,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
|||||||
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||||
than the radio's normal path_hash_mode setting.
|
than the radio's normal path_hash_mode setting.
|
||||||
"""
|
"""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
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)
|
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
|
||||||
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||||
"""Discover the current forward and return paths to a known contact."""
|
"""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)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
pubkey_prefix = contact.public_key[:12]
|
pubkey_prefix = contact.public_key[:12]
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import time
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.event_handlers import track_pending_ack
|
from app.event_handlers import track_pending_ack
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Message,
|
Message,
|
||||||
@@ -89,7 +88,7 @@ async def list_messages(
|
|||||||
@router.post("/direct", response_model=Message)
|
@router.post("/direct", response_model=Message)
|
||||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||||
"""Send a direct message to a contact."""
|
"""Send a direct message to a contact."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
# First check our database for the contact
|
# First check our database for the contact
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
@@ -136,7 +135,7 @@ TEMP_RADIO_SLOT = 0
|
|||||||
@router.post("/channel", response_model=Message)
|
@router.post("/channel", response_model=Message)
|
||||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||||
"""Send a message to a channel."""
|
"""Send a message to a channel."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
# Get channel info from our database
|
# Get channel info from our database
|
||||||
from app.repository import ChannelRepository
|
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
|
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.
|
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
|
from app.repository import ChannelRepository
|
||||||
|
|
||||||
|
|||||||
+7
-11
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
CONTACT_TYPE_REPEATER,
|
CONTACT_TYPE_REPEATER,
|
||||||
ContactUpsert,
|
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 send_advertisement as do_send_advertisement
|
||||||
from app.radio_sync import sync_radio_time
|
from app.radio_sync import sync_radio_time
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
|
from app.routers.server_control import _monotonic
|
||||||
from app.services.contact_reconciliation import (
|
from app.services.contact_reconciliation import (
|
||||||
promote_prefix_contacts_for_contact,
|
promote_prefix_contacts_for_contact,
|
||||||
reconcile_contact_messages,
|
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:
|
def _better_signal(first: float | None, second: float | None) -> float | None:
|
||||||
if first is None:
|
if first is None:
|
||||||
return second
|
return second
|
||||||
@@ -338,7 +334,7 @@ async def _resolve_trace_hops(
|
|||||||
@router.get("/config", response_model=RadioConfigResponse)
|
@router.get("/config", response_model=RadioConfigResponse)
|
||||||
async def get_radio_config() -> RadioConfigResponse:
|
async def get_radio_config() -> RadioConfigResponse:
|
||||||
"""Get the current radio configuration."""
|
"""Get the current radio configuration."""
|
||||||
mc = require_connected()
|
mc = radio_manager.require_connected()
|
||||||
|
|
||||||
info = mc.self_info
|
info = mc.self_info
|
||||||
if not info:
|
if not info:
|
||||||
@@ -370,7 +366,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
|||||||
@router.patch("/config", response_model=RadioConfigResponse)
|
@router.patch("/config", response_model=RadioConfigResponse)
|
||||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||||
"""Update radio configuration. Only provided fields will be updated."""
|
"""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:
|
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||||
try:
|
try:
|
||||||
@@ -392,7 +388,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
|||||||
@router.put("/private-key")
|
@router.put("/private-key")
|
||||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||||
"""Set the radio's private key. This is write-only."""
|
"""Set the radio's private key. This is write-only."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key_bytes = bytes.fromhex(update.private_key)
|
key_bytes = bytes.fromhex(update.private_key)
|
||||||
@@ -426,7 +422,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
|||||||
Returns:
|
Returns:
|
||||||
status: "ok" if sent successfully
|
status: "ok" if sent successfully
|
||||||
"""
|
"""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
||||||
|
|
||||||
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
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)
|
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
||||||
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
||||||
"""Run a short node-discovery sweep from the local radio."""
|
"""Run a short node-discovery sweep from the local radio."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
||||||
tag = random.randint(1, 0xFFFFFFFF)
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
@@ -509,7 +505,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
@router.post("/trace", response_model=RadioTraceResponse)
|
@router.post("/trace", response_model=RadioTraceResponse)
|
||||||
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||||
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
"""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)
|
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
||||||
|
|
||||||
tag = random.randint(1, 0xFFFFFFFF)
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
|
|||||||
+10
-16
@@ -3,7 +3,6 @@ import time
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
CONTACT_TYPE_REPEATER,
|
CONTACT_TYPE_REPEATER,
|
||||||
AclEntry,
|
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.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
extract_response_text,
|
|
||||||
prepare_authenticated_contact_connection,
|
prepare_authenticated_contact_connection,
|
||||||
require_server_capable_contact,
|
require_server_capable_contact,
|
||||||
send_contact_cli_command,
|
send_contact_cli_command,
|
||||||
@@ -48,10 +46,6 @@ router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
|||||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
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:
|
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||||
return await prepare_authenticated_contact_connection(
|
return await prepare_authenticated_contact_connection(
|
||||||
mc,
|
mc,
|
||||||
@@ -80,7 +74,7 @@ def _require_repeater(contact: Contact) -> None:
|
|||||||
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
||||||
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||||
"""Attempt repeater login and report whether auth was confirmed."""
|
"""Attempt repeater login and report whether auth was confirmed."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_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)
|
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
||||||
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||||
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_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)
|
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
"""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)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_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)
|
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
||||||
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||||
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -233,7 +227,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
|||||||
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
||||||
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
||||||
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -274,7 +268,7 @@ async def _batch_cli_fetch(
|
|||||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_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)
|
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||||
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
"""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)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_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:
|
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_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)
|
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||||
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||||
"""Fetch owner info and guest password from a repeater via CLI commands."""
|
"""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)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -354,7 +348,7 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
|||||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||||
"""Send a CLI command to a repeater or room server."""
|
"""Send a CLI command to a repeater or room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
require_server_capable_contact(contact)
|
require_server_capable_contact(contact)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
CONTACT_TYPE_ROOM,
|
CONTACT_TYPE_ROOM,
|
||||||
AclEntry,
|
AclEntry,
|
||||||
@@ -28,7 +27,7 @@ def _require_room(contact) -> None:
|
|||||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||||
"""Attempt room-server login and report whether auth was confirmed."""
|
"""Attempt room-server login and report whether auth was confirmed."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_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)
|
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||||
"""Fetch status telemetry from a room server."""
|
"""Fetch status telemetry from a room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_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)
|
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
"""Fetch CayenneLPP telemetry from a room server."""
|
"""Fetch CayenneLPP telemetry from a room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_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)
|
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||||
"""Fetch ACL entries from a room server."""
|
"""Fetch ACL entries from a room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_require_room(contact)
|
||||||
|
|
||||||
|
|||||||
+68
-50
@@ -2,16 +2,18 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
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.region_scope import normalize_region_scope
|
||||||
from app.repository import AppSettingsRepository
|
from app.repository import AppSettingsRepository, ContactRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsUpdate(BaseModel):
|
class AppSettingsUpdate(BaseModel):
|
||||||
max_radio_contacts: int | None = Field(
|
max_radio_contacts: int | None = Field(
|
||||||
@@ -51,6 +53,10 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
"advertisements should not create new contacts"
|
"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):
|
class BlockKeyRequest(BaseModel):
|
||||||
@@ -66,24 +72,17 @@ class FavoriteRequest(BaseModel):
|
|||||||
id: str = Field(description="Channel key or contact public key")
|
id: str = Field(description="Channel key or contact public key")
|
||||||
|
|
||||||
|
|
||||||
class MigratePreferencesRequest(BaseModel):
|
class TrackedTelemetryRequest(BaseModel):
|
||||||
favorites: list[FavoriteRequest] = Field(
|
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||||
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 MigratePreferencesResponse(BaseModel):
|
class TrackedTelemetryResponse(BaseModel):
|
||||||
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
|
tracked_telemetry_repeaters: list[str] = Field(
|
||||||
settings: AppSettings = Field(description="Current settings after migration attempt")
|
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)
|
@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)]
|
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
|
||||||
kwargs["discovery_blocked_types"] = sorted(set(valid))
|
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
|
||||||
flood_scope_changed = False
|
flood_scope_changed = False
|
||||||
if update.flood_scope is not None:
|
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)
|
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
|
||||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
|
||||||
"""Migrate all preferences from frontend localStorage to database.
|
"""Toggle periodic telemetry collection for a repeater.
|
||||||
|
|
||||||
This is a one-time migration. If preferences have already been migrated,
|
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
|
||||||
this endpoint will not overwrite them and will return migrated=false.
|
the requested repeater is not already tracked.
|
||||||
|
|
||||||
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)
|
|
||||||
"""
|
"""
|
||||||
# Convert to dict format for the repository method
|
key = request.public_key.lower()
|
||||||
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
|
settings = await AppSettingsRepository.get()
|
||||||
|
current = settings.tracked_telemetry_repeaters
|
||||||
|
|
||||||
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
|
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||||
favorites=frontend_favorites,
|
names: dict[str, str] = {}
|
||||||
sort_order=request.sort_order,
|
for k in keys:
|
||||||
last_message_times=request.last_message_times,
|
contact = await ContactRepository.get_by_key(k)
|
||||||
)
|
names[k] = contact.name if contact and contact.name else k[:12]
|
||||||
|
return names
|
||||||
|
|
||||||
if did_migrate:
|
if key in current:
|
||||||
logger.info(
|
# Remove
|
||||||
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
|
new_list = [k for k in current if k != key]
|
||||||
len(frontend_favorites),
|
logger.info("Removing repeater %s from tracked telemetry", key[:12])
|
||||||
request.sort_order,
|
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||||
len(request.last_message_times),
|
return TrackedTelemetryResponse(
|
||||||
|
tracked_telemetry_repeaters=new_list,
|
||||||
|
names=await _resolve_names(new_list),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.debug("Preferences already migrated, skipping")
|
|
||||||
|
|
||||||
return MigratePreferencesResponse(
|
# Validate it's a repeater
|
||||||
migrated=did_migrate,
|
contact = await ContactRepository.get_by_key(key)
|
||||||
settings=settings,
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
if contact.type != CONTACT_TYPE_REPEATER:
|
||||||
|
raise HTTPException(status_code=400, detail="Contact is not a repeater")
|
||||||
|
|
||||||
|
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
|
||||||
|
names = await _resolve_names(current)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
|
||||||
|
"tracked_telemetry_repeaters": current,
|
||||||
|
"names": names,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
new_list = current + [key]
|
||||||
|
logger.info("Adding repeater %s to tracked telemetry", key[:12])
|
||||||
|
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||||
|
return TrackedTelemetryResponse(
|
||||||
|
tracked_telemetry_repeaters=new_list,
|
||||||
|
names=await _resolve_names(new_list),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"""Shared direct-message ACK application logic."""
|
"""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 import dm_ack_tracker
|
||||||
from app.services.messages import increment_ack_and_broadcast
|
from app.services.messages import BroadcastFn, increment_ack_and_broadcast
|
||||||
|
|
||||||
BroadcastFn = Callable[..., Any]
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
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.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||||
from app.repository import (
|
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.contact_reconciliation import claim_prefix_messages_for_contact
|
||||||
from app.services.messages import (
|
from app.services.messages import (
|
||||||
|
BroadcastFn,
|
||||||
broadcast_message,
|
broadcast_message,
|
||||||
build_message_model,
|
build_message_model,
|
||||||
build_message_paths,
|
build_message_paths,
|
||||||
@@ -27,8 +27,6 @@ if TYPE_CHECKING:
|
|||||||
from app.decoder import DecryptedDirectMessage
|
from app.decoder import DecryptedDirectMessage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BroadcastFn = Callable[..., Any]
|
|
||||||
_decrypted_dm_store_lock = asyncio.Lock()
|
_decrypted_dm_store_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +142,8 @@ async def _store_direct_message(
|
|||||||
received_at: int,
|
received_at: int,
|
||||||
path: str | None,
|
path: str | None,
|
||||||
path_len: int | None,
|
path_len: int | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool,
|
outgoing: bool,
|
||||||
txt_type: int,
|
txt_type: int,
|
||||||
signature: str | None,
|
signature: str | None,
|
||||||
@@ -170,6 +170,8 @@ async def _store_direct_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -189,6 +191,8 @@ async def _store_direct_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -201,6 +205,8 @@ async def _store_direct_message(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
txt_type=txt_type,
|
txt_type=txt_type,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
@@ -218,6 +224,8 @@ async def _store_direct_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -232,7 +240,7 @@ async def _store_direct_message(
|
|||||||
text=text,
|
text=text,
|
||||||
sender_timestamp=sender_timestamp,
|
sender_timestamp=sender_timestamp,
|
||||||
received_at=received_at,
|
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,
|
txt_type=txt_type,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
sender_key=sender_key,
|
sender_key=sender_key,
|
||||||
@@ -261,6 +269,8 @@ async def ingest_decrypted_direct_message(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
@@ -311,6 +321,8 @@ async def ingest_decrypted_direct_message(
|
|||||||
received_at=received,
|
received_at=received,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
txt_type=decrypted.txt_type,
|
txt_type=decrypted.txt_type,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time as _time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,10 +10,17 @@ from fastapi import HTTPException
|
|||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
|
||||||
from app.models import ResendChannelMessageResponse
|
from app.models import ResendChannelMessageResponse
|
||||||
|
from app.radio import RadioOperationBusyError
|
||||||
from app.region_scope import normalize_region_scope
|
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 import dm_ack_tracker
|
||||||
from app.services.messages import (
|
from app.services.messages import (
|
||||||
|
BroadcastFn,
|
||||||
broadcast_message,
|
broadcast_message,
|
||||||
build_stored_outgoing_channel_message,
|
build_stored_outgoing_channel_message,
|
||||||
create_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. "
|
"Send command was issued to the radio, but no response was heard back. "
|
||||||
"The message may or may not have sent successfully."
|
"The message may or may not have sent successfully."
|
||||||
)
|
)
|
||||||
|
|
||||||
BroadcastFn = Callable[..., Any]
|
|
||||||
TrackAckFn = Callable[[str, int, int], bool]
|
TrackAckFn = Callable[[str, int, int], bool]
|
||||||
NowFn = Callable[[], float]
|
NowFn = Callable[[], float]
|
||||||
OutgoingReservationKey = tuple[str, str, str]
|
OutgoingReservationKey = tuple[str, str, str]
|
||||||
RetryTaskScheduler = Callable[[Any], Any]
|
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]] = {}
|
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
|
||||||
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -122,7 +137,7 @@ async def send_channel_message_with_effective_scope(
|
|||||||
error_broadcast_fn: BroadcastFn,
|
error_broadcast_fn: BroadcastFn,
|
||||||
app_settings_repository=AppSettingsRepository,
|
app_settings_repository=AppSettingsRepository,
|
||||||
) -> Any:
|
) -> 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)
|
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||||
baseline_scope = ""
|
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:
|
try:
|
||||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||||
channel_key,
|
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:
|
def _extract_expected_ack_code(result: Any) -> str | None:
|
||||||
if result is None or result.type == EventType.ERROR:
|
if result is None or result.type == EventType.ERROR:
|
||||||
@@ -550,6 +635,85 @@ async def send_direct_message_to_contact(
|
|||||||
return message
|
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(
|
async def send_channel_message_to_channel(
|
||||||
*,
|
*,
|
||||||
channel,
|
channel,
|
||||||
@@ -658,6 +822,22 @@ async def send_channel_message_to_channel(
|
|||||||
message_repository=message_repository,
|
message_repository=message_repository,
|
||||||
)
|
)
|
||||||
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
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
|
return outgoing_message
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,16 @@ def build_message_paths(
|
|||||||
path: str | None,
|
path: str | None,
|
||||||
received_at: int,
|
received_at: int,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> list[MessagePath] | None:
|
) -> list[MessagePath] | None:
|
||||||
"""Build the single-path list used by message payloads."""
|
"""Build the single-path list used by message payloads."""
|
||||||
return (
|
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
|
if path is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -166,6 +172,8 @@ async def reconcile_duplicate_message(
|
|||||||
path: str | None,
|
path: str | None,
|
||||||
received_at: int,
|
received_at: int,
|
||||||
path_len: int | None,
|
path_len: int | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -177,7 +185,9 @@ async def reconcile_duplicate_message(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if path is not None:
|
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:
|
else:
|
||||||
paths = existing_msg.paths or []
|
paths = existing_msg.paths or []
|
||||||
|
|
||||||
@@ -214,6 +224,8 @@ async def handle_duplicate_message(
|
|||||||
path: str | None,
|
path: str | None,
|
||||||
received_at: int,
|
received_at: int,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||||
@@ -239,6 +251,8 @@ async def handle_duplicate_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,6 +267,8 @@ async def create_message_from_decrypted(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
channel_name: str | None = None,
|
channel_name: str | None = None,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
@@ -276,6 +292,8 @@ async def create_message_from_decrypted(
|
|||||||
received_at=received,
|
received_at=received,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
sender_name=sender,
|
sender_name=sender,
|
||||||
sender_key=resolved_sender_key,
|
sender_key=resolved_sender_key,
|
||||||
)
|
)
|
||||||
@@ -291,6 +309,8 @@ async def create_message_from_decrypted(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received,
|
received_at=received,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -312,7 +332,7 @@ async def create_message_from_decrypted(
|
|||||||
text=text,
|
text=text,
|
||||||
sender_timestamp=timestamp,
|
sender_timestamp=timestamp,
|
||||||
received_at=received,
|
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_name=sender,
|
||||||
sender_key=resolved_sender_key,
|
sender_key=resolved_sender_key,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
@@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
@@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
|||||||
start_message_polling,
|
start_message_polling,
|
||||||
start_periodic_advert,
|
start_periodic_advert,
|
||||||
start_periodic_sync,
|
start_periodic_sync,
|
||||||
|
start_telemetry_collect,
|
||||||
sync_and_offload_all,
|
sync_and_offload_all,
|
||||||
sync_radio_time,
|
sync_radio_time,
|
||||||
)
|
)
|
||||||
@@ -241,6 +242,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
|||||||
start_periodic_sync()
|
start_periodic_sync()
|
||||||
start_periodic_advert()
|
start_periodic_advert()
|
||||||
start_message_polling()
|
start_message_polling()
|
||||||
|
start_telemetry_collect()
|
||||||
|
|
||||||
radio_manager._setup_complete = True
|
radio_manager._setup_complete = True
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -434,6 +434,18 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
|
|||||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
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.
|
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)
|
## Security Posture (intentional)
|
||||||
|
|
||||||
- No authentication UI.
|
- No authentication UI.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.7.1",
|
"version": "3.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export function App() {
|
|||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
handleToggleBlockedKey,
|
handleToggleBlockedKey,
|
||||||
handleToggleBlockedName,
|
handleToggleBlockedName,
|
||||||
|
handleToggleTrackedTelemetry,
|
||||||
} = useAppSettings();
|
} = useAppSettings();
|
||||||
|
|
||||||
// Keep user's name in ref for mention detection in WebSocket callback
|
// Keep user's name in ref for mention detection in WebSocket callback
|
||||||
@@ -397,6 +398,7 @@ export function App() {
|
|||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
handleResendChannelMessage,
|
handleResendChannelMessage,
|
||||||
handleSetChannelFloodScopeOverride,
|
handleSetChannelFloodScopeOverride,
|
||||||
|
handleSetChannelPathHashModeOverride,
|
||||||
handleSenderClick,
|
handleSenderClick,
|
||||||
handleTrace,
|
handleTrace,
|
||||||
handlePathDiscovery,
|
handlePathDiscovery,
|
||||||
@@ -527,6 +529,7 @@ export function App() {
|
|||||||
onDeleteContact: handleDeleteContact,
|
onDeleteContact: handleDeleteContact,
|
||||||
onDeleteChannel: handleDeleteChannel,
|
onDeleteChannel: handleDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||||
|
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
|
||||||
onOpenContactInfo: handleOpenContactInfo,
|
onOpenContactInfo: handleOpenContactInfo,
|
||||||
onOpenChannelInfo: handleOpenChannelInfo,
|
onOpenChannelInfo: handleOpenChannelInfo,
|
||||||
onSenderClick: handleSenderClick,
|
onSenderClick: handleSenderClick,
|
||||||
@@ -553,6 +556,8 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||||
|
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||||
};
|
};
|
||||||
const searchProps = {
|
const searchProps = {
|
||||||
contacts,
|
contacts,
|
||||||
@@ -586,6 +591,8 @@ export function App() {
|
|||||||
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||||
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||||
},
|
},
|
||||||
|
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||||
|
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||||
};
|
};
|
||||||
const crackerProps = {
|
const crackerProps = {
|
||||||
packets: rawPackets,
|
packets: rawPackets,
|
||||||
|
|||||||
+14
-9
@@ -14,8 +14,6 @@ import type {
|
|||||||
MaintenanceResult,
|
MaintenanceResult,
|
||||||
Message,
|
Message,
|
||||||
MessagesAroundResponse,
|
MessagesAroundResponse,
|
||||||
MigratePreferencesRequest,
|
|
||||||
MigratePreferencesResponse,
|
|
||||||
RawPacket,
|
RawPacket,
|
||||||
RadioAdvertMode,
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
@@ -36,6 +34,7 @@ import type {
|
|||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
TelemetryHistoryEntry,
|
TelemetryHistoryEntry,
|
||||||
|
TrackedTelemetryResponse,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
UnreadCounts,
|
UnreadCounts,
|
||||||
@@ -210,6 +209,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
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
|
// Messages
|
||||||
getMessages: (
|
getMessages: (
|
||||||
params?: {
|
params?: {
|
||||||
@@ -321,6 +326,13 @@ export const api = {
|
|||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Tracked telemetry
|
||||||
|
toggleTrackedTelemetry: (publicKey: string) =>
|
||||||
|
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ public_key: publicKey }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Favorites
|
// Favorites
|
||||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||||
@@ -328,13 +340,6 @@ export const api = {
|
|||||||
body: JSON.stringify({ type, id }),
|
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
|
// Fanout
|
||||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||||
createFanoutConfig: (config: {
|
createFanoutConfig: (config: {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function AppShell({
|
|||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
<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
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@@ -158,7 +158,7 @@ export function AppShell({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
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',
|
!disabled && 'hover:bg-accent',
|
||||||
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,11 +49,13 @@ export function BulkAddChannelResultModal({
|
|||||||
{result && (
|
{result && (
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<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="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 className="mt-1 font-medium">{createdChannels.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
<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
|
Already Present
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 font-medium">{result.existing_count}</div>
|
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
@@ -6,7 +7,7 @@ import { isFavorite } from '../utils/favorites';
|
|||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
|
||||||
|
|
||||||
interface ChannelInfoPaneProps {
|
interface ChannelInfoPaneProps {
|
||||||
channelKey: string | null;
|
channelKey: string | null;
|
||||||
@@ -106,11 +107,11 @@ export function ChannelInfoPane({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<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'}
|
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
|
||||||
</span>
|
</span>
|
||||||
{channel.on_radio && (
|
{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
|
On Radio
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -179,6 +180,14 @@ export function ChannelInfoPane({
|
|||||||
</div>
|
</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 */}
|
{/* Top Senders 24h */}
|
||||||
{detail && detail.top_senders_24h.length > 0 && (
|
{detail && detail.top_senders_24h.length > 0 && (
|
||||||
<div className="px-5 py-3">
|
<div className="px-5 py-3">
|
||||||
@@ -212,7 +221,7 @@ export function ChannelInfoPane({
|
|||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
@@ -226,3 +235,82 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOP_WIDTH_SEGMENTS = [
|
||||||
|
{ key: 'single_byte', label: '1-byte', color: '#22c55e' },
|
||||||
|
{ key: 'double_byte', label: '2-byte', color: '#0ea5e9' },
|
||||||
|
{ key: 'triple_byte', label: '3-byte', color: '#8b5cf6' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function HopWidthChart({ stats }: { stats: PathHashWidthStats }) {
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({
|
||||||
|
name: label,
|
||||||
|
value: stats[key] as number,
|
||||||
|
color,
|
||||||
|
})).filter((d) => d.value > 0),
|
||||||
|
[stats]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={22}
|
||||||
|
outerRadius={40}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="hsl(var(--background))"
|
||||||
|
>
|
||||||
|
{data.map((d) => (
|
||||||
|
<Cell key={d.name} fill={d.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(value: any, name: any) => {
|
||||||
|
const v = typeof value === 'number' ? value : Number(value);
|
||||||
|
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: d.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-[0.6875rem] text-muted-foreground flex-1">{d.name}</span>
|
||||||
|
<span className="text-[0.6875rem] font-medium tabular-nums">
|
||||||
|
{d.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground pt-0.5">
|
||||||
|
{stats.total_packets.toLocaleString()} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
const PATH_HASH_MODE_LABELS: Record<number, string> = {
|
||||||
|
0: '1-byte',
|
||||||
|
1: '2-byte',
|
||||||
|
2: '3-byte',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChannelPathHashModeOverrideModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
channelName: string;
|
||||||
|
currentOverride: number | null;
|
||||||
|
radioDefault: number;
|
||||||
|
onSetOverride: (value: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelPathHashModeOverrideModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
channelName,
|
||||||
|
currentOverride,
|
||||||
|
radioDefault,
|
||||||
|
onSetOverride,
|
||||||
|
}: ChannelPathHashModeOverrideModalProps) {
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelected(currentOverride);
|
||||||
|
}
|
||||||
|
}, [currentOverride, open]);
|
||||||
|
|
||||||
|
const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`;
|
||||||
|
|
||||||
|
const options: { value: number | null; label: string; description: string }[] = [
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
label: `Radio default (${radioDefaultLabel})`,
|
||||||
|
description: 'Use the radio-wide path hash mode setting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
label: '1-byte hop identifiers',
|
||||||
|
description: 'Shortest paths, least repeater disambiguation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: '2-byte hop identifiers',
|
||||||
|
description: 'Better repeater disambiguation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
label: '3-byte hop identifiers',
|
||||||
|
description: 'Best repeater disambiguation, longest paths',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Path Hop Width Override</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Override the path hash mode for this channel. Wider hop identifiers improve repeater
|
||||||
|
disambiguation but extend send time and will prevent users on old (<1.14) firmware
|
||||||
|
from receiving the message.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||||
|
<div className="font-medium">{channelName}</div>
|
||||||
|
<div className="mt-1 text-muted-foreground">
|
||||||
|
Current override:{' '}
|
||||||
|
{currentOverride != null
|
||||||
|
? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`)
|
||||||
|
: `none (using radio default: ${radioDefaultLabel})`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Hop width for this channel</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
type="button"
|
||||||
|
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selected === opt.value
|
||||||
|
? 'border-primary bg-primary/10 text-foreground'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelected(opt.value)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{opt.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{opt.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:block sm:space-x-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
onSetOverride(selected);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected == null
|
||||||
|
? `Use radio default for ${channelName}`
|
||||||
|
: `Use ${PATH_HASH_MODE_LABELS[selected]} hops for ${channelName}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { 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 { toast } from './ui/sonner';
|
||||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||||
|
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||||
import { isFavorite } from '../utils/favorites';
|
import { isFavorite } from '../utils/favorites';
|
||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||||
@@ -36,6 +37,7 @@ interface ChatHeaderProps {
|
|||||||
onToggleNotifications: () => void;
|
onToggleNotifications: () => void;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||||
|
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||||
onDeleteChannel: (key: string) => void;
|
onDeleteChannel: (key: string) => void;
|
||||||
onDeleteContact: (publicKey: string) => void;
|
onDeleteContact: (publicKey: string) => void;
|
||||||
onOpenContactInfo?: (publicKey: string) => void;
|
onOpenContactInfo?: (publicKey: string) => void;
|
||||||
@@ -56,6 +58,7 @@ export function ChatHeader({
|
|||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
|
onSetChannelPathHashModeOverride,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
@@ -64,11 +67,13 @@ export function ChatHeader({
|
|||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||||
|
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowKey(false);
|
setShowKey(false);
|
||||||
setPathDiscoveryOpen(false);
|
setPathDiscoveryOpen(false);
|
||||||
setChannelOverrideOpen(false);
|
setChannelOverrideOpen(false);
|
||||||
|
setPathHashModeOverrideOpen(false);
|
||||||
}, [conversation.id]);
|
}, [conversation.id]);
|
||||||
|
|
||||||
const activeChannel =
|
const activeChannel =
|
||||||
@@ -81,6 +86,12 @@ export function ChatHeader({
|
|||||||
? stripRegionScopePrefix(activeFloodScopeOverride)
|
? stripRegionScopePrefix(activeFloodScopeOverride)
|
||||||
: null;
|
: null;
|
||||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? 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 isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||||
const activeContact =
|
const activeContact =
|
||||||
conversation.type === 'contact'
|
conversation.type === 'contact'
|
||||||
@@ -108,6 +119,11 @@ export function ChatHeader({
|
|||||||
setChannelOverrideOpen(true);
|
setChannelOverrideOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditPathHashModeOverride = () => {
|
||||||
|
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
|
||||||
|
setPathHashModeOverrideOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenConversationInfo = () => {
|
const handleOpenConversationInfo = () => {
|
||||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||||
onOpenContactInfo(conversation.id);
|
onOpenContactInfo(conversation.id);
|
||||||
@@ -182,7 +198,7 @@ export function ChatHeader({
|
|||||||
</h2>
|
</h2>
|
||||||
{isPrivateChannel && !showKey ? (
|
{isPrivateChannel && !showKey ? (
|
||||||
<button
|
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowKey(true);
|
setShowKey(true);
|
||||||
@@ -193,7 +209,7 @@ export function ChatHeader({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
@@ -228,7 +244,7 @@ export function ChatHeader({
|
|||||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||||
aria-hidden="true"
|
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}
|
{activeFloodScopeDisplay}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -237,7 +253,7 @@ export function ChatHeader({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{conversation.type === 'contact' && activeContact && (
|
{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
|
<ContactStatusInfo
|
||||||
contact={activeContact}
|
contact={activeContact}
|
||||||
ourLat={config?.lat ?? null}
|
ourLat={config?.lat ?? null}
|
||||||
@@ -299,7 +315,7 @@ export function ChatHeader({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{notificationsEnabled && (
|
{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
|
Notifications On
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -317,12 +333,25 @@ export function ChatHeader({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{activeFloodScopeDisplay && (
|
{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}
|
{activeFloodScopeDisplay}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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') && (
|
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||||
<button
|
<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"
|
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)}
|
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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export function ContactInfoPane({
|
|||||||
{contact.public_key}
|
{contact.public_key}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<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'}
|
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -568,7 +568,7 @@ export function ContactInfoPane({
|
|||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
@@ -729,7 +729,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
</div>
|
</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
|
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
|
||||||
slots.
|
slots.
|
||||||
{!analytics.includes_direct_messages &&
|
{!analytics.includes_direct_messages &&
|
||||||
@@ -821,7 +821,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
|
|||||||
{legendItems && (
|
{legendItems && (
|
||||||
<Legend
|
<Legend
|
||||||
content={() => (
|
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) => (
|
{legendItems.map((item) => (
|
||||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ function RouteCard({
|
|||||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h4 className="text-sm font-semibold">{label}</h4>
|
<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)}
|
{formatRouteLabel(route.path_len, true)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm">{chain}</p>
|
<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>Raw: {rawPath}</span>
|
||||||
<span>{formatPathHashMode(route.path_hash_mode)}</span>
|
<span>{formatPathHashMode(route.path_hash_mode)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ interface ConversationPaneProps {
|
|||||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||||
onDeleteChannel: (key: string) => Promise<void>;
|
onDeleteChannel: (key: string) => Promise<void>;
|
||||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||||
|
onSetChannelPathHashModeOverride?: (
|
||||||
|
channelKey: string,
|
||||||
|
pathHashModeOverride: number | null
|
||||||
|
) => Promise<void>;
|
||||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
onOpenChannelInfo: (channelKey: string) => void;
|
onOpenChannelInfo: (channelKey: string) => void;
|
||||||
onSenderClick: (sender: string) => void;
|
onSenderClick: (sender: string) => void;
|
||||||
@@ -75,6 +79,8 @@ interface ConversationPaneProps {
|
|||||||
onDismissUnreadMarker: () => void;
|
onDismissUnreadMarker: () => void;
|
||||||
onSendMessage: (text: string) => Promise<void>;
|
onSendMessage: (text: string) => Promise<void>;
|
||||||
onToggleNotifications: () => void;
|
onToggleNotifications: () => void;
|
||||||
|
trackedTelemetryRepeaters: string[];
|
||||||
|
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingPane({ label }: { label: string }) {
|
function LoadingPane({ label }: { label: string }) {
|
||||||
@@ -131,6 +137,7 @@ export function ConversationPane({
|
|||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
|
onSetChannelPathHashModeOverride,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
onOpenChannelInfo,
|
onOpenChannelInfo,
|
||||||
onSenderClick,
|
onSenderClick,
|
||||||
@@ -143,6 +150,8 @@ export function ConversationPane({
|
|||||||
onDismissUnreadMarker,
|
onDismissUnreadMarker,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
|
trackedTelemetryRepeaters,
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
}: ConversationPaneProps) {
|
}: ConversationPaneProps) {
|
||||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||||
const activeContactIsRepeater = useMemo(() => {
|
const activeContactIsRepeater = useMemo(() => {
|
||||||
@@ -182,7 +191,12 @@ export function ConversationPane({
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
||||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
<MapView
|
||||||
|
contacts={contacts}
|
||||||
|
focusedKey={activeConversation.mapFocusKey}
|
||||||
|
rawPackets={rawPackets}
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -236,6 +250,8 @@ export function ConversationPane({
|
|||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDeleteContact={onDeleteContact}
|
onDeleteContact={onDeleteContact}
|
||||||
onOpenContactInfo={onOpenContactInfo}
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
|
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||||
|
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -259,6 +275,7 @@ export function ConversationPane({
|
|||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||||
|
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||||
onDeleteChannel={onDeleteChannel}
|
onDeleteChannel={onDeleteChannel}
|
||||||
onDeleteContact={onDeleteContact}
|
onDeleteContact={onDeleteContact}
|
||||||
onOpenContactInfo={onOpenContactInfo}
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
|
|||||||
@@ -1,16 +1,47 @@
|
|||||||
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
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 type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { Contact } from '../types';
|
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
import { isValidLocation } from '../utils/pathUtils';
|
import { isValidLocation } from '../utils/pathUtils';
|
||||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
|
import {
|
||||||
|
parsePacket,
|
||||||
|
getPacketLabel,
|
||||||
|
PARTICLE_COLOR_MAP,
|
||||||
|
dedupeConsecutive,
|
||||||
|
} from '../utils/visualizerUtils';
|
||||||
|
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
/** Public key of contact to focus on and open popup */
|
/** Public key of contact to focus on and open popup */
|
||||||
focusedKey?: string | null;
|
focusedKey?: string | null;
|
||||||
|
rawPackets?: RawPacket[];
|
||||||
|
config?: RadioConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tile layer presets ---
|
||||||
|
const TILE_LIGHT = {
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
};
|
||||||
|
const TILE_DARK = {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
background: '#0d0d0d',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSavedDarkMap(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP_RECENCY_COLORS = {
|
const MAP_RECENCY_COLORS = {
|
||||||
@@ -22,7 +53,16 @@ const MAP_RECENCY_COLORS = {
|
|||||||
const MAP_MARKER_STROKE = '#0f172a';
|
const MAP_MARKER_STROKE = '#0f172a';
|
||||||
const MAP_REPEATER_RING = '#f8fafc';
|
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 {
|
function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||||
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
@@ -36,7 +76,85 @@ function getMarkerColor(lastSeen: number | null | undefined): string {
|
|||||||
return MAP_RECENCY_COLORS.old;
|
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({
|
function MapBoundsHandler({
|
||||||
contacts,
|
contacts,
|
||||||
focusedContact,
|
focusedContact,
|
||||||
@@ -48,7 +166,6 @@ function MapBoundsHandler({
|
|||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we have a focused contact, center on it immediately (even if already initialized)
|
|
||||||
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
|
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
|
||||||
map.setView([focusedContact.lat, focusedContact.lon], 12);
|
map.setView([focusedContact.lat, focusedContact.lon], 12);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
@@ -59,20 +176,17 @@ function MapBoundsHandler({
|
|||||||
|
|
||||||
const fitToContacts = () => {
|
const fitToContacts = () => {
|
||||||
if (contacts.length === 0) {
|
if (contacts.length === 0) {
|
||||||
// No contacts with location - show world view
|
|
||||||
map.setView([20, 0], 2);
|
map.setView([20, 0], 2);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contacts.length === 1) {
|
if (contacts.length === 1) {
|
||||||
// Single contact - center on it
|
|
||||||
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple contacts - fit bounds
|
|
||||||
const bounds: LatLngBoundsExpression = contacts.map(
|
const bounds: LatLngBoundsExpression = contacts.map(
|
||||||
(c) => [c.lat!, c.lon!] as [number, number]
|
(c) => [c.lat!, c.lon!] as [number, number]
|
||||||
);
|
);
|
||||||
@@ -80,22 +194,18 @@ function MapBoundsHandler({
|
|||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try geolocation first
|
|
||||||
if ('geolocation' in navigator) {
|
if ('geolocation' in navigator) {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
// Success - center on user location with reasonable zoom
|
|
||||||
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// Geolocation denied/failed - fit to contacts
|
|
||||||
fitToContacts();
|
fitToContacts();
|
||||||
},
|
},
|
||||||
{ timeout: 5000, maximumAge: 300000 }
|
{ timeout: 5000, maximumAge: 300000 }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// No geolocation support - fit to contacts
|
|
||||||
fitToContacts();
|
fitToContacts();
|
||||||
}
|
}
|
||||||
}, [map, contacts, hasInitialized, focusedContact]);
|
}, [map, contacts, hasInitialized, focusedContact]);
|
||||||
@@ -103,18 +213,404 @@ function MapBoundsHandler({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({ contacts, focusedKey }: MapViewProps) {
|
// --- Canvas particle overlay ---
|
||||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
|
||||||
|
|
||||||
// Filter to contacts with GPS coordinates, heard within the last 7 days.
|
function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
|
||||||
// Always include the focused contact so "view on map" links work for older nodes.
|
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(() => {
|
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(
|
return contacts.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
isValidLocation(c.lat, c.lon) &&
|
isValidLocation(c.lat, c.lon) &&
|
||||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
|
(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
|
// Find the focused contact by key
|
||||||
const focusedContact = useMemo(() => {
|
const focusedContact = useMemo(() => {
|
||||||
@@ -124,18 +620,17 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
|
|
||||||
const includesFocusedOutsideWindow =
|
const includesFocusedOutsideWindow =
|
||||||
focusedContact != null &&
|
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
|
// Track marker refs to open popup programmatically
|
||||||
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
||||||
|
|
||||||
// Store ref for a marker
|
|
||||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||||
if (ref === null) {
|
if (ref === null) {
|
||||||
delete markerRefs.current[key];
|
delete markerRefs.current[key];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
markerRefs.current[key] = ref;
|
markerRefs.current[key] = ref;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -148,10 +643,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
}
|
}
|
||||||
}, [mappableContacts]);
|
}, [mappableContacts]);
|
||||||
|
|
||||||
// Open popup for focused contact after map is ready
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||||
// Small delay to ensure map has finished rendering
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
markerRefs.current[focusedContact.public_key]?.openPopup();
|
markerRefs.current[focusedContact.public_key]?.openPopup();
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -159,48 +652,104 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
}
|
}
|
||||||
}, [focusedContact]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Info bar */}
|
{/* Info bar */}
|
||||||
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
||||||
<span>
|
<span>{infoLabel}</span>
|
||||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
|
|
||||||
in the last 7 days
|
|
||||||
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1">
|
{!showPackets && (
|
||||||
<span
|
<>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||||
<1h
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<1h
|
||||||
<span
|
</span>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||||
<1d
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<1d
|
||||||
<span
|
</span>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||||
<3d
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<3d
|
||||||
<span
|
</span>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||||
older
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
|
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="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className="w-3 h-3 rounded-full border-2"
|
className="w-3 h-3 rounded-full border-2"
|
||||||
@@ -209,10 +758,30 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
/>{' '}
|
/>{' '}
|
||||||
repeater
|
repeater
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map - z-index constrained to stay below modals/sheets */}
|
{/* Map */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 relative"
|
className="flex-1 relative"
|
||||||
style={{ zIndex: 0 }}
|
style={{ zIndex: 0 }}
|
||||||
@@ -223,14 +792,21 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
center={[20, 0]}
|
center={[20, 0]}
|
||||||
zoom={2}
|
zoom={2}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
style={{ background: '#1a1a2e' }}
|
style={{ background: tile.background }}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} />
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
|
<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) => {
|
{mappableContacts.map((contact) => {
|
||||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||||
const color = getMarkerColor(contact.last_seen);
|
const color = getMarkerColor(contact.last_seen);
|
||||||
@@ -275,6 +851,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{showPackets && <ParticleOverlay particles={particles} />}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -220,8 +220,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
|||||||
|
|
||||||
const className =
|
const className =
|
||||||
variant === 'header'
|
variant === 'header'
|
||||||
? 'font-normal text-muted-foreground ml-1 text-[11px] 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-[10px] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
: 'text-[0.625rem] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -300,6 +300,9 @@ export function MessageList({
|
|||||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
const packetCacheRef = useRef<Map<number, RawPacket>>(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<
|
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||||
| { kind: 'packet'; packet: RawPacket }
|
| { kind: 'packet'; packet: RawPacket }
|
||||||
| { kind: 'loading'; message: string }
|
| { kind: 'loading'; message: string }
|
||||||
@@ -325,6 +328,13 @@ export function MessageList({
|
|||||||
const prevConvKeyRef = useRef<string | null>(null);
|
const prevConvKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
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) {
|
if (message.packet_id == null) {
|
||||||
setPacketInspectorSource({
|
setPacketInspectorSource({
|
||||||
kind: 'unavailable',
|
kind: 'unavailable',
|
||||||
@@ -965,7 +975,7 @@ export function MessageList({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showAvatar && (
|
{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 ? (
|
{canClickSender ? (
|
||||||
<span
|
<span
|
||||||
className="cursor-pointer hover:text-primary transition-colors"
|
className="cursor-pointer hover:text-primary transition-colors"
|
||||||
@@ -980,7 +990,7 @@ export function MessageList({
|
|||||||
) : (
|
) : (
|
||||||
displaySender
|
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)}
|
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||||
</span>
|
</span>
|
||||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||||
@@ -1008,7 +1018,7 @@ export function MessageList({
|
|||||||
))}
|
))}
|
||||||
{!showAvatar && (
|
{!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)}
|
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||||
</span>
|
</span>
|
||||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||||
@@ -1180,12 +1190,18 @@ export function MessageList({
|
|||||||
{packetInspectorSource && (
|
{packetInspectorSource && (
|
||||||
<RawPacketInspectorDialog
|
<RawPacketInspectorDialog
|
||||||
open={packetInspectorSource !== null}
|
open={packetInspectorSource !== null}
|
||||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setPacketInspectorSource(null);
|
||||||
|
packetSignalOverrideRef.current = undefined;
|
||||||
|
}
|
||||||
|
}}
|
||||||
channels={channels}
|
channels={channels}
|
||||||
source={packetInspectorSource}
|
source={packetInspectorSource}
|
||||||
title="Analyze Packet"
|
title="Analyze Packet"
|
||||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||||
notice={ANALYZE_PACKET_NOTICE}
|
notice={ANALYZE_PACKET_NOTICE}
|
||||||
|
signalOverride={packetSignalOverrideRef.current}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,14 +103,25 @@ export function PathModal({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Raw path summary */}
|
{/* Raw path summary */}
|
||||||
<div className="text-sm">
|
<div className="text-sm space-y-1">
|
||||||
{paths.map((p, index) => {
|
{paths.map((p, index) => {
|
||||||
const hops = parsePathHops(p.path, p.path_len);
|
const hops = parsePathHops(p.path, p.path_len);
|
||||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||||
|
const hasSignal = p.rssi != null || p.snr != null;
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
<div>
|
||||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -221,7 +232,7 @@ export function PathModal({
|
|||||||
>
|
>
|
||||||
<span className="flex flex-col items-center leading-tight">
|
<span className="flex flex-col items-center leading-tight">
|
||||||
<span>↻ Resend</span>
|
<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
|
Only repeated by new repeaters
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -237,7 +248,7 @@ export function PathModal({
|
|||||||
>
|
>
|
||||||
<span className="flex flex-col items-center leading-tight">
|
<span className="flex flex-col items-center leading-tight">
|
||||||
<span>↻ Resend as new</span>
|
<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
|
Will appear as duplicate to receivers
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource =
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface SignalOverride {
|
||||||
|
rssi: number | null;
|
||||||
|
snr: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawPacketInspectorDialogProps {
|
interface RawPacketInspectorDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
notice?: ReactNode;
|
notice?: ReactNode;
|
||||||
|
signalOverride?: SignalOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawPacketInspectionPanelProps {
|
interface RawPacketInspectionPanelProps {
|
||||||
packet: RawPacket;
|
packet: RawPacket;
|
||||||
|
signalOverride?: SignalOverride;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSignal(packet: RawPacket): string {
|
function formatSignal(
|
||||||
const parts: string[] = [];
|
packet: RawPacket,
|
||||||
if (packet.rssi !== null) {
|
signalOverride?: SignalOverride
|
||||||
parts.push(`${packet.rssi} dBm RSSI`);
|
): { lines: string[]; label: string } {
|
||||||
}
|
const rssi = signalOverride?.rssi ?? packet.rssi;
|
||||||
if (packet.snr !== null) {
|
const snr = signalOverride?.snr ?? packet.snr;
|
||||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
const lines: string[] = [];
|
||||||
}
|
if (rssi !== null) lines.push(`${rssi} dBm RSSI`);
|
||||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
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 {
|
function formatByteRange(field: PacketByteField): string {
|
||||||
@@ -312,7 +325,7 @@ function CompactMetaCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
<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>
|
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
||||||
{secondary ? (
|
{secondary ? (
|
||||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
<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]);
|
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
||||||
|
|
||||||
return (
|
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) => {
|
{byteRuns.map((run, index) => {
|
||||||
const fieldId = run.fieldId;
|
const fieldId = run.fieldId;
|
||||||
const palette = fieldId ? colorMap.get(fieldId) : null;
|
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="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
<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>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -464,7 +479,7 @@ function FieldBox({
|
|||||||
|
|
||||||
{field.decryptedMessage ? (
|
{field.decryptedMessage ? (
|
||||||
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
<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'}
|
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||||
</div>
|
</div>
|
||||||
<PlaintextContent text={field.decryptedMessage} />
|
<PlaintextContent text={field.decryptedMessage} />
|
||||||
@@ -486,11 +501,13 @@ function FieldBox({
|
|||||||
<div className="text-sm font-medium leading-tight text-foreground">
|
<div className="text-sm font-medium leading-tight text-foreground">
|
||||||
{part.field}
|
{part.field}
|
||||||
</div>
|
</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>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
<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>
|
</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 decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||||
const groupTextCandidates = useMemo(
|
const groupTextCandidates = useMemo(
|
||||||
() => buildGroupTextResolutionCandidates(channels),
|
() => 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">
|
<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="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<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
|
Summary
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||||
@@ -611,7 +632,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
|||||||
</div>
|
</div>
|
||||||
{packetContext ? (
|
{packetContext ? (
|
||||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
<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}
|
{packetContext.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
<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}`}
|
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||||
/>
|
/>
|
||||||
<CompactMetaCard
|
{(() => {
|
||||||
label="Signal"
|
const sig = formatSignal(packet, signalOverride);
|
||||||
primary={formatSignal(packet)}
|
return (
|
||||||
secondary={packetContext ? null : undefined}
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -711,6 +745,7 @@ export function RawPacketInspectorDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
notice,
|
notice,
|
||||||
|
signalOverride,
|
||||||
}: RawPacketInspectorDialogProps) {
|
}: RawPacketInspectorDialogProps) {
|
||||||
const [packetInput, setPacketInput] = useState('');
|
const [packetInput, setPacketInput] = useState('');
|
||||||
|
|
||||||
@@ -735,7 +770,13 @@ export function RawPacketInspectorDialog({
|
|||||||
|
|
||||||
let body: ReactNode;
|
let body: ReactNode;
|
||||||
if (source.kind === 'packet') {
|
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') {
|
} else if (source.kind === 'paste') {
|
||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -211,7 +211,9 @@ function getCoverageMessage(
|
|||||||
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
<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>
|
<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}
|
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +331,7 @@ function NeighborList({
|
|||||||
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||||
</div>
|
</div>
|
||||||
{!isNeighborIdentityResolvable(item, contacts) ? (
|
{!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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{mode !== 'signal' ? (
|
{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">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
<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) => (
|
{typeOrder.map((type, i) => (
|
||||||
<span key={type} className="inline-flex items-center gap-1">
|
<span key={type} className="inline-flex items-center gap-1">
|
||||||
<span
|
<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="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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<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
|
Coverage
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Route type badge */}
|
{/* Route type badge */}
|
||||||
<span
|
<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}
|
title={decoded.routeType}
|
||||||
>
|
>
|
||||||
{getRouteTypeLabel(decoded.routeType)}
|
{getRouteTypeLabel(decoded.routeType)}
|
||||||
@@ -117,26 +117,29 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
|||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<span
|
<span
|
||||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
className={cn(
|
||||||
|
'text-[0.8125rem]',
|
||||||
|
packet.decrypted ? 'text-primary' : 'text-foreground'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{decoded.summary}
|
{decoded.summary}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Time */}
|
{/* 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)}
|
{formatTime(packet.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signal info */}
|
{/* Signal info */}
|
||||||
{(packet.snr !== null || packet.rssi !== null) && (
|
{(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)}
|
{formatSignalInfo(packet)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Raw hex data (always visible) */}
|
{/* 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()}
|
{packet.data.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface RepeaterDashboardProps {
|
|||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
onDeleteContact: (publicKey: string) => void;
|
onDeleteContact: (publicKey: string) => void;
|
||||||
onOpenContactInfo?: (publicKey: string) => void;
|
onOpenContactInfo?: (publicKey: string) => void;
|
||||||
|
trackedTelemetryRepeaters: string[];
|
||||||
|
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterDashboard({
|
export function RepeaterDashboard({
|
||||||
@@ -72,6 +74,8 @@ export function RepeaterDashboard({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
|
trackedTelemetryRepeaters,
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
}: RepeaterDashboardProps) {
|
}: RepeaterDashboardProps) {
|
||||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||||
@@ -177,7 +181,7 @@ export function RepeaterDashboard({
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<span
|
<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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
@@ -193,7 +197,7 @@ export function RepeaterDashboard({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{contact && (
|
{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} />
|
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -204,7 +208,7 @@ export function RepeaterDashboard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={loadAll}
|
onClick={loadAll}
|
||||||
disabled={anyLoading}
|
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'}
|
{anyLoading ? 'Loading...' : 'Load All'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -250,7 +254,7 @@ export function RepeaterDashboard({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{notificationsEnabled && (
|
{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
|
Notifications On
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -396,7 +400,13 @@ export function RepeaterDashboard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Telemetry history chart — full width, below console */}
|
{/* Telemetry history chart — full width, below console */}
|
||||||
<TelemetryHistoryPane entries={telemetryHistory} />
|
<TelemetryHistoryPane
|
||||||
|
entries={telemetryHistory}
|
||||||
|
publicKey={conversation.id}
|
||||||
|
contacts={contacts}
|
||||||
|
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||||
|
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export function SearchView({
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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'
|
result.type === 'CHAN'
|
||||||
? 'bg-primary/20 text-primary'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-secondary text-secondary-foreground'
|
: 'bg-secondary text-secondary-foreground'
|
||||||
@@ -298,12 +298,12 @@ export function SearchView({
|
|||||||
>
|
>
|
||||||
{typeBadge}
|
{typeBadge}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
|
<span className="text-xs font-medium text-foreground truncate">{convName}</span>
|
||||||
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
|
<span className="text-[0.6875rem] text-muted-foreground ml-auto flex-shrink-0">
|
||||||
{formatTime(result.received_at)}
|
{formatTime(result.received_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 && (
|
{result.sender_name && !result.outgoing && (
|
||||||
<span className="text-muted-foreground">{result.sender_name}: </span>
|
<span className="text-muted-foreground">{result.sender_name}: </span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ interface SettingsModalBaseProps {
|
|||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
contacts?: Contact[];
|
contacts?: Contact[];
|
||||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
|
trackedTelemetryRepeaters?: string[];
|
||||||
|
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsModalProps = SettingsModalBaseProps &
|
export type SettingsModalProps = SettingsModalBaseProps &
|
||||||
@@ -85,6 +87,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
contacts,
|
contacts,
|
||||||
onBulkDeleteContacts,
|
onBulkDeleteContacts,
|
||||||
|
trackedTelemetryRepeaters,
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
} = props;
|
} = props;
|
||||||
const externalSidebarNav = props.externalSidebarNav === true;
|
const externalSidebarNav = props.externalSidebarNav === true;
|
||||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||||
@@ -246,6 +250,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
onToggleBlockedName={onToggleBlockedName}
|
onToggleBlockedName={onToggleBlockedName}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||||
|
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||||
|
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||||
className={sectionContentClass}
|
className={sectionContentClass}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -584,7 +584,7 @@ export function Sidebar({
|
|||||||
contactType={row.contact.type}
|
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">
|
<span className="ml-auto flex items-center gap-1">
|
||||||
{row.notificationsEnabled && (
|
{row.notificationsEnabled && (
|
||||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||||
@@ -594,7 +594,7 @@ export function Sidebar({
|
|||||||
{row.unreadCount > 0 && (
|
{row.unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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
|
highlightUnread
|
||||||
? 'bg-badge-mention text-badge-mention-foreground'
|
? 'bg-badge-mention text-badge-mention-foreground'
|
||||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||||
@@ -626,7 +626,7 @@ export function Sidebar({
|
|||||||
key={key}
|
key={key}
|
||||||
data-active={active ? 'true' : undefined}
|
data-active={active ? 'true' : undefined}
|
||||||
className={cn(
|
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'
|
active && 'bg-accent border-l-primary'
|
||||||
)}
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -735,7 +735,7 @@ export function Sidebar({
|
|||||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 text-[11px]',
|
'ml-1 text-[0.6875rem]',
|
||||||
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
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">
|
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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'
|
isSearching && 'cursor-default'
|
||||||
)}
|
)}
|
||||||
aria-expanded={!effectiveCollapsed}
|
aria-expanded={!effectiveCollapsed}
|
||||||
@@ -783,7 +783,7 @@ export function Sidebar({
|
|||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
{sortSection && sectionSortOrder && (
|
{sortSection && sectionSortOrder && (
|
||||||
<button
|
<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)}
|
onClick={() => handleSortToggle(sortSection)}
|
||||||
aria-label={
|
aria-label={
|
||||||
sectionSortOrder === 'alpha'
|
sectionSortOrder === 'alpha'
|
||||||
@@ -802,7 +802,7 @@ export function Sidebar({
|
|||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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
|
highlightUnread
|
||||||
? 'bg-badge-mention text-badge-mention-foreground'
|
? 'bg-badge-mention text-badge-mention-foreground'
|
||||||
: 'bg-secondary text-muted-foreground'
|
: 'bg-secondary text-muted-foreground'
|
||||||
@@ -831,7 +831,7 @@ export function Sidebar({
|
|||||||
onClick={onNewMessage}
|
onClick={onNewMessage}
|
||||||
title="Add channel or contact"
|
title="Add channel or contact"
|
||||||
aria-label="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" />
|
<SquarePen className="h-4 w-4" />
|
||||||
<span>Add Channel/Contact</span>
|
<span>Add Channel/Contact</span>
|
||||||
@@ -848,7 +848,7 @@ export function Sidebar({
|
|||||||
aria-label="Search conversations"
|
aria-label="Search conversations"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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 && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -874,7 +874,7 @@ export function Sidebar({
|
|||||||
{/* Mark All Read */}
|
{/* Mark All Read */}
|
||||||
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
||||||
<div
|
<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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function StatusBar({
|
|||||||
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
||||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
||||||
<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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function TraceNodeRow({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
fixed
|
||||||
? 'border-primary/30 bg-primary/10 text-primary'
|
? 'border-primary/30 bg-primary/10 text-primary'
|
||||||
: 'border-border bg-muted text-muted-foreground'
|
: 'border-border bg-muted text-muted-foreground'
|
||||||
@@ -129,12 +129,12 @@ function TraceNodeRow({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium">{title}</div>
|
<div className="truncate text-sm font-medium">{title}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||||
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
|
{meta ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{meta}</div> : null}
|
||||||
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
|
{note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
{snr ? (
|
{snr ? (
|
||||||
<div className="shrink-0 text-right">
|
<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 className="font-mono text-sm">{snr}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -370,7 +370,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{sortMode === 'distance' && !canSortByDistance ? (
|
{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
|
Distance sorting is using known repeater coordinates, but the local radio does not
|
||||||
currently have a valid location.
|
currently have a valid location.
|
||||||
</p>
|
</p>
|
||||||
@@ -421,12 +421,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
|||||||
{getShortKey(contact.public_key)}
|
{getShortKey(contact.public_key)}
|
||||||
</div>
|
</div>
|
||||||
{sortMode === 'distance' && distanceKm !== null ? (
|
{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
|
{distanceKm.toFixed(1)} km away
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedCount > 0 ? (
|
{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'}
|
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { cn } from '@/lib/utils';
|
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';
|
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`;
|
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 [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];
|
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 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 (
|
return (
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
<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">
|
<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>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
<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>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
|
{/* Explanation + tracking toggle */}
|
||||||
|
<div className="mb-3 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
|
||||||
|
samples, whichever comes first). This telemetry is stored on normal interactive fetches
|
||||||
|
via the repeater pane, API calls to the endpoint (
|
||||||
|
<code className="text-[0.6875rem]">POST /api/contacts/<key>/repeater/status</code>
|
||||||
|
), or when the repeater is opted into interval telemetry polling, in which case the
|
||||||
|
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
|
||||||
|
into this flow in the{' '}
|
||||||
|
<a
|
||||||
|
href="#settings/database"
|
||||||
|
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Database & Messaging
|
||||||
|
</a>{' '}
|
||||||
|
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
|
||||||
|
of keeping mesh congestion reasonable.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isTracked ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={toggling}
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
|
||||||
|
</Button>
|
||||||
|
) : slotsFull ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button variant="outline" disabled>
|
||||||
|
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Disable tracking on another repeater to free a slot:{' '}
|
||||||
|
{trackedNames.map((t) => t.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={toggling}
|
||||||
|
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||||
|
>
|
||||||
|
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="mb-3" />
|
||||||
|
|
||||||
{/* Metric selector */}
|
{/* Metric selector */}
|
||||||
<div className="flex gap-1 mb-2">
|
<div className="flex gap-1 mb-2">
|
||||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||||
@@ -83,7 +179,7 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMetric(m)}
|
onClick={() => setMetric(m)}
|
||||||
className={cn(
|
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
|
metric === m
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: '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}
|
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||||
fillOpacity={0.15}
|
fillOpacity={0.15}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
dot={false}
|
dot={{
|
||||||
activeDot={{
|
|
||||||
r: 4,
|
r: 4,
|
||||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
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,
|
strokeWidth: 2,
|
||||||
stroke: 'hsl(var(--popover))',
|
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="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-medium">{title}</h3>
|
<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 && (
|
{fetchedAt && (
|
||||||
<p
|
<p
|
||||||
className="text-[11px] text-muted-foreground"
|
className="text-[0.6875rem] text-muted-foreground"
|
||||||
title={new Date(fetchedAt).toLocaleString()}
|
title={new Date(fetchedAt).toLocaleString()}
|
||||||
>
|
>
|
||||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function SettingsDatabaseSection({
|
|||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
contacts = [],
|
contacts = [],
|
||||||
onBulkDeleteContacts,
|
onBulkDeleteContacts,
|
||||||
|
trackedTelemetryRepeaters = [],
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
appSettings: AppSettings;
|
appSettings: AppSettings;
|
||||||
@@ -32,6 +34,8 @@ export function SettingsDatabaseSection({
|
|||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
contacts?: Contact[];
|
contacts?: Contact[];
|
||||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
|
trackedTelemetryRepeaters?: string[];
|
||||||
|
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [retentionDays, setRetentionDays] = useState('14');
|
const [retentionDays, setRetentionDays] = useState('14');
|
||||||
@@ -223,6 +227,50 @@ export function SettingsDatabaseSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="text-sm text-destructive" role="alert">
|
<div className="text-sm text-destructive" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ function CreateIntegrationDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sectionedOptions.map((group) => (
|
{sectionedOptions.map((group) => (
|
||||||
<div key={group.section} className="space-y-1.5">
|
<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}
|
{group.section}
|
||||||
</div>
|
</div>
|
||||||
{group.options.map((option) => {
|
{group.options.map((option) => {
|
||||||
@@ -577,7 +577,7 @@ function CreateIntegrationDialog({
|
|||||||
{selectedOption ? (
|
{selectedOption ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1.5">
|
<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}
|
{selectedOption.section}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
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 { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
import { ContactAvatar } from '../ContactAvatar';
|
import { ContactAvatar } from '../ContactAvatar';
|
||||||
import {
|
import {
|
||||||
captureLastViewedConversationFromHash,
|
captureLastViewedConversationFromHash,
|
||||||
@@ -37,6 +39,13 @@ export function SettingsLocalSection({
|
|||||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||||
getReopenLastConversationEnabled
|
getReopenLastConversationEnabled
|
||||||
);
|
);
|
||||||
|
const [darkMap, setDarkMap] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||||
@@ -233,11 +242,31 @@ export function SettingsLocalSection({
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemePreview({ className }: { className?: string }) {
|
function ThemePreview({ className }: { className?: string }) {
|
||||||
|
const [showStyleRef, setShowStyleRef] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
@@ -271,7 +300,7 @@ function ThemePreview({ className }: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
<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">
|
<div className="space-y-1">
|
||||||
<PreviewSidebarRow
|
<PreviewSidebarRow
|
||||||
active
|
active
|
||||||
@@ -289,7 +318,7 @@ function ThemePreview({ className }: { className?: string }) {
|
|||||||
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
||||||
label="Alice"
|
label="Alice"
|
||||||
badge={
|
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
|
3
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -298,13 +327,267 @@ function ThemePreview({ className }: { className?: string }) {
|
|||||||
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
||||||
label="Mesh Ops"
|
label="Mesh Ops"
|
||||||
badge={
|
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
|
@2
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Style Reference (collapsible) ── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowStyleRef((v) => !v)}
|
||||||
|
className="mt-4 flex w-full items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn('h-3.5 w-3.5 transition-transform', showStyleRef && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
Canonical style reference
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showStyleRef && (
|
||||||
|
<>
|
||||||
|
{/* ── Text Hierarchy ── */}
|
||||||
|
<PreviewSection title="Text hierarchy">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-xl font-semibold"
|
||||||
|
label="text-xl font-semibold"
|
||||||
|
desc="Hero / large data"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-lg font-semibold"
|
||||||
|
label="text-lg font-semibold"
|
||||||
|
desc="Sheet / dialog title"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-base font-semibold"
|
||||||
|
label="text-base font-semibold"
|
||||||
|
desc="Section title"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-xs text-muted-foreground"
|
||||||
|
label="text-xs text-muted-foreground"
|
||||||
|
desc="Helper text"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-[0.6875rem] text-muted-foreground"
|
||||||
|
label="text-[0.6875rem] text-muted-foreground"
|
||||||
|
desc="Metadata, timestamps"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Section Label
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
|
||||||
|
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Mono Text ── */}
|
||||||
|
<PreviewSection title="Mono text">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground">
|
||||||
|
a1b2c3d4e5f6...7890abcdef01
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
text-xs font-mono — keys, identifiers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.6875rem] font-mono">1h 23m 45s uptime</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
text-[0.6875rem] font-mono — metadata mono
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-mono">$ req_status_sync 0xA1B2...</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
text-sm font-mono — console / code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Badges ── */}
|
||||||
|
<PreviewSection title="Badges and tags">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||||
|
Hashtag
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||||
|
Repeater
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||||
|
On Radio
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
|
||||||
|
@2
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||||
|
Muted: bg-muted · Primary: bg-primary/10 · Unread/Mention: bg-badge-*
|
||||||
|
</p>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Buttons ── */}
|
||||||
|
<PreviewSection title="Buttons">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||||
|
Standard variants (size sm)
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button size="sm">Default</Button>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
Outline
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
Secondary
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive">
|
||||||
|
Destructive
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
Ghost
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="outline">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="outline">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||||
|
Semantic outline variants
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
Danger
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-warning/50 text-warning hover:bg-warning/10"
|
||||||
|
>
|
||||||
|
Warning
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||||
|
>
|
||||||
|
Success
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||||
|
Metric selector pills
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['Voltage', 'Noise Floor', 'Packets'].map((label, i) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||||
|
i === 0
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Clickable Text ── */}
|
||||||
|
<PreviewSection title="Clickable text">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block"
|
||||||
|
>
|
||||||
|
a1b2c3d4e5f6 (click to copy)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="text-sm cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Underlined navigational link
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||||
|
cursor-pointer hover:text-primary transition-colors — use role="button" +
|
||||||
|
tabIndex
|
||||||
|
</p>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Inline Alerts ── */}
|
||||||
|
<PreviewSection title="Inline alerts">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="rounded-md border border-info/30 bg-info/10 px-3 py-2 text-xs text-info">
|
||||||
|
Info: channel slot cache refreshed from radio.
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
Warning: radio clock skew detected.
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
Error: post-connect setup timed out. Reboot the radio and restart.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||||
|
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">{title}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewTextRow({
|
||||||
|
classes,
|
||||||
|
label,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
classes: string;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className={classes}>Sample text at this size</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
{label} — {desc}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,7 +610,7 @@ function PreviewMessage({
|
|||||||
return (
|
return (
|
||||||
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
||||||
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
|
<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 className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,7 +631,7 @@ function PreviewSidebarRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-active={active ? 'true' : undefined}
|
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'
|
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -702,6 +702,26 @@ export function SettingsRadioSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground',
|
'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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function VisualizerControls({
|
|||||||
{PACKET_LEGEND_ITEMS.map((item) => (
|
{PACKET_LEGEND_ITEMS.map((item) => (
|
||||||
<div key={item.label} className="flex items-center gap-2">
|
<div key={item.label} className="flex items-center gap-2">
|
||||||
<div
|
<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 }}
|
style={{ backgroundColor: item.color }}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -2,17 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { takePrefetchOrFetch } from '../prefetch';
|
import { takePrefetchOrFetch } from '../prefetch';
|
||||||
import { toast } from '../components/ui/sonner';
|
import { toast } from '../components/ui/sonner';
|
||||||
import {
|
import { initLastMessageTimes } from '../utils/conversationState';
|
||||||
initLastMessageTimes,
|
import { isFavorite } from '../utils/favorites';
|
||||||
loadLocalStorageLastMessageTimes,
|
|
||||||
loadLocalStorageSortOrder,
|
|
||||||
clearLocalStorageConversationState,
|
|
||||||
} from '../utils/conversationState';
|
|
||||||
import {
|
|
||||||
isFavorite,
|
|
||||||
loadLocalStorageFavorites,
|
|
||||||
clearLocalStorageFavorites,
|
|
||||||
} from '../utils/favorites';
|
|
||||||
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||||
|
|
||||||
export function useAppSettings() {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!appSettings || hasMigratedRef.current) return;
|
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;
|
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 {
|
try {
|
||||||
const result = await api.migratePreferences({
|
for (const f of localFavorites) {
|
||||||
favorites: localFavorites,
|
await api.toggleFavorite(f.type, f.id);
|
||||||
sort_order: localSortOrder,
|
|
||||||
last_message_times: localLastMessageTimes,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.migrated) {
|
|
||||||
toast.success('Preferences migrated', {
|
|
||||||
description: `Migrated ${localFavorites.length} favorites to server`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem(FAVORITES_KEY);
|
||||||
setAppSettings(result.settings);
|
await fetchAppSettings();
|
||||||
initLastMessageTimes(result.settings.last_message_times ?? {});
|
|
||||||
|
|
||||||
clearLocalStorageFavorites();
|
|
||||||
clearLocalStorageConversationState();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to migrate preferences:', err);
|
console.error('Failed to migrate legacy favorites:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
migrate();
|
||||||
migratePreferences();
|
}, [appSettings, fetchAppSettings]);
|
||||||
}, [appSettings]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appSettings,
|
appSettings,
|
||||||
@@ -182,5 +182,6 @@ export function useAppSettings() {
|
|||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
handleToggleBlockedKey,
|
handleToggleBlockedKey,
|
||||||
handleToggleBlockedName,
|
handleToggleBlockedName,
|
||||||
|
handleToggleTrackedTelemetry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ interface UseConversationActionsResult {
|
|||||||
channelKey: string,
|
channelKey: string,
|
||||||
floodScopeOverride: string
|
floodScopeOverride: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
handleSetChannelPathHashModeOverride: (
|
||||||
|
channelKey: string,
|
||||||
|
pathHashModeOverride: number | null
|
||||||
|
) => Promise<void>;
|
||||||
handleSenderClick: (sender: string) => void;
|
handleSenderClick: (sender: string) => void;
|
||||||
handleTrace: () => Promise<void>;
|
handleTrace: () => Promise<void>;
|
||||||
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||||
@@ -106,6 +110,25 @@ export function useConversationActions({
|
|||||||
[mergeChannelIntoList]
|
[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(
|
const handleSenderClick = useCallback(
|
||||||
(sender: string) => {
|
(sender: string) => {
|
||||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||||
@@ -143,6 +166,7 @@ export function useConversationActions({
|
|||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
handleResendChannelMessage,
|
handleResendChannelMessage,
|
||||||
handleSetChannelFloodScopeOverride,
|
handleSetChannelFloodScopeOverride,
|
||||||
|
handleSetChannelPathHashModeOverride,
|
||||||
handleSenderClick,
|
handleSenderClick,
|
||||||
handleTrace,
|
handleTrace,
|
||||||
handlePathDiscovery,
|
handlePathDiscovery,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
requestTrace: vi.fn(),
|
requestTrace: vi.fn(),
|
||||||
updateRadioConfig: vi.fn(),
|
updateRadioConfig: vi.fn(),
|
||||||
setPrivateKey: vi.fn(),
|
setPrivateKey: vi.fn(),
|
||||||
migratePreferences: vi.fn(),
|
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
@@ -191,7 +190,7 @@ const baseSettings = {
|
|||||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: false,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
flood_scope: '',
|
flood_scope: '',
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getUndecryptedPacketCount: vi.fn(),
|
getUndecryptedPacketCount: vi.fn(),
|
||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
getContacts: vi.fn(),
|
getContacts: vi.fn(),
|
||||||
migratePreferences: vi.fn(),
|
|
||||||
},
|
},
|
||||||
useConversationMessagesCalls: vi.fn(),
|
useConversationMessagesCalls: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -219,7 +218,7 @@ describe('App search jump target handling', () => {
|
|||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: true,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getUndecryptedPacketCount: vi.fn(),
|
getUndecryptedPacketCount: vi.fn(),
|
||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
getContacts: vi.fn(),
|
getContacts: vi.fn(),
|
||||||
migratePreferences: vi.fn(),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -170,7 +169,7 @@ describe('App startup hash resolution', () => {
|
|||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: true,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ function makeDetail(channel: Channel): ChannelDetail {
|
|||||||
first_message_at: null,
|
first_message_at: null,
|
||||||
unique_sender_count: 0,
|
unique_sender_count: 0,
|
||||||
top_senders_24h: [],
|
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(),
|
onDismissUnreadMarker: vi.fn(),
|
||||||
onSendMessage: vi.fn(async () => {}),
|
onSendMessage: vi.fn(async () => {}),
|
||||||
onToggleNotifications: vi.fn(),
|
onToggleNotifications: vi.fn(),
|
||||||
|
trackedTelemetryRepeaters: [],
|
||||||
|
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { RADIO_PRESETS } from '../utils/radioPresets';
|
|
||||||
|
|
||||||
describe('Radio Presets', () => {
|
|
||||||
describe('preset values are valid LoRa parameters', () => {
|
|
||||||
it('all frequencies are in valid ISM bands', () => {
|
|
||||||
for (const preset of RADIO_PRESETS) {
|
|
||||||
// 433 MHz: 433.05-434.79, EU 868: 863-870, US/AU/NZ/VN 900: 902-928
|
|
||||||
const valid433 = preset.freq >= 433 && preset.freq <= 435;
|
|
||||||
const validEU = preset.freq >= 863 && preset.freq <= 870;
|
|
||||||
const valid900 = preset.freq >= 902 && preset.freq <= 928;
|
|
||||||
expect(valid433 || validEU || valid900).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all spreading factors are valid (7-12)', () => {
|
|
||||||
for (const preset of RADIO_PRESETS) {
|
|
||||||
expect(preset.sf).toBeGreaterThanOrEqual(7);
|
|
||||||
expect(preset.sf).toBeLessThanOrEqual(12);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all coding rates are valid (5-8 for 4/5 to 4/8)', () => {
|
|
||||||
for (const preset of RADIO_PRESETS) {
|
|
||||||
expect(preset.cr).toBeGreaterThanOrEqual(5);
|
|
||||||
expect(preset.cr).toBeLessThanOrEqual(8);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all bandwidths are standard LoRa values', () => {
|
|
||||||
const validBandwidths = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500];
|
|
||||||
for (const preset of RADIO_PRESETS) {
|
|
||||||
expect(validBandwidths).toContain(preset.bw);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -124,6 +124,8 @@ const defaultProps = {
|
|||||||
onToggleNotifications: vi.fn(),
|
onToggleNotifications: vi.fn(),
|
||||||
onToggleFavorite: vi.fn(),
|
onToggleFavorite: vi.fn(),
|
||||||
onDeleteContact: vi.fn(),
|
onDeleteContact: vi.fn(),
|
||||||
|
trackedTelemetryRepeaters: [] as string[],
|
||||||
|
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDeferred<T>() {
|
function createDeferred<T>() {
|
||||||
@@ -677,7 +679,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
render(<RepeaterDashboard {...defaultProps} />);
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
|
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 () => {
|
it('updates history from live status fetch', async () => {
|
||||||
|
|||||||
@@ -62,13 +62,15 @@ const baseSettings: AppSettings = {
|
|||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: false,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
flood_scope: '',
|
flood_scope: '',
|
||||||
blocked_keys: [],
|
blocked_keys: [],
|
||||||
blocked_names: [],
|
blocked_names: [],
|
||||||
discovery_blocked_types: [],
|
discovery_blocked_types: [],
|
||||||
|
tracked_telemetry_repeaters: [],
|
||||||
|
auto_resend_channel: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderModal(overrides?: {
|
function renderModal(overrides?: {
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { getSceneNodeLabel } from '../components/visualizer/shared';
|
|
||||||
|
|
||||||
describe('visualizer shared label helpers', () => {
|
|
||||||
it('adds an ambiguity suffix to in-graph labels for ambiguous nodes', () => {
|
|
||||||
expect(
|
|
||||||
getSceneNodeLabel({
|
|
||||||
id: '?32',
|
|
||||||
name: 'Likely Relay',
|
|
||||||
type: 'repeater',
|
|
||||||
isAmbiguous: true,
|
|
||||||
})
|
|
||||||
).toBe('Likely Relay (?)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not add an ambiguity suffix to unambiguous nodes', () => {
|
|
||||||
expect(
|
|
||||||
getSceneNodeLabel({
|
|
||||||
id: 'aaaaaaaaaaaa',
|
|
||||||
name: 'Alice',
|
|
||||||
type: 'client',
|
|
||||||
isAmbiguous: false,
|
|
||||||
})
|
|
||||||
).toBe('Alice');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+22
-10
@@ -201,6 +201,7 @@ export interface Channel {
|
|||||||
is_hashtag: boolean;
|
is_hashtag: boolean;
|
||||||
on_radio: boolean;
|
on_radio: boolean;
|
||||||
flood_scope_override?: string | null;
|
flood_scope_override?: string | null;
|
||||||
|
path_hash_mode_override?: number | null;
|
||||||
last_read_at: number | null;
|
last_read_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,12 +228,23 @@ export interface BulkCreateHashtagChannelsResult {
|
|||||||
message: string;
|
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 {
|
export interface ChannelDetail {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
message_counts: ChannelMessageCounts;
|
message_counts: ChannelMessageCounts;
|
||||||
first_message_at: number | null;
|
first_message_at: number | null;
|
||||||
unique_sender_count: number;
|
unique_sender_count: number;
|
||||||
top_senders_24h: ChannelTopSender[];
|
top_senders_24h: ChannelTopSender[];
|
||||||
|
path_hash_width_24h: PathHashWidthStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single path that a message took to reach us */
|
/** A single path that a message took to reach us */
|
||||||
@@ -243,6 +255,10 @@ export interface MessagePath {
|
|||||||
received_at: number;
|
received_at: number;
|
||||||
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
|
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
|
||||||
path_len?: number | null;
|
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 {
|
export interface Message {
|
||||||
@@ -317,34 +333,30 @@ export interface AppSettings {
|
|||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
auto_decrypt_dm_on_advert: boolean;
|
auto_decrypt_dm_on_advert: boolean;
|
||||||
last_message_times: Record<string, number>;
|
last_message_times: Record<string, number>;
|
||||||
preferences_migrated: boolean;
|
|
||||||
advert_interval: number;
|
advert_interval: number;
|
||||||
last_advert_time: number;
|
last_advert_time: number;
|
||||||
flood_scope: string;
|
flood_scope: string;
|
||||||
blocked_keys: string[];
|
blocked_keys: string[];
|
||||||
blocked_names: string[];
|
blocked_names: string[];
|
||||||
discovery_blocked_types: number[];
|
discovery_blocked_types: number[];
|
||||||
|
tracked_telemetry_repeaters: string[];
|
||||||
|
auto_resend_channel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettingsUpdate {
|
export interface AppSettingsUpdate {
|
||||||
max_radio_contacts?: number;
|
max_radio_contacts?: number;
|
||||||
auto_decrypt_dm_on_advert?: boolean;
|
auto_decrypt_dm_on_advert?: boolean;
|
||||||
advert_interval?: number;
|
advert_interval?: number;
|
||||||
|
auto_resend_channel?: boolean;
|
||||||
flood_scope?: string;
|
flood_scope?: string;
|
||||||
blocked_keys?: string[];
|
blocked_keys?: string[];
|
||||||
blocked_names?: string[];
|
blocked_names?: string[];
|
||||||
discovery_blocked_types?: number[];
|
discovery_blocked_types?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigratePreferencesRequest {
|
export interface TrackedTelemetryResponse {
|
||||||
favorites: Favorite[];
|
tracked_telemetry_repeaters: string[];
|
||||||
sort_order: string;
|
names: Record<string, string>;
|
||||||
last_message_times: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigratePreferencesResponse {
|
|
||||||
migrated: boolean;
|
|
||||||
settings: AppSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contact type constants */
|
/** Contact type constants */
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
* across devices - see useUnreadCounts hook.
|
* across devices - see useUnreadCounts hook.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
|
|
||||||
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
|
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
|
||||||
const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders';
|
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}`;
|
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.
|
* Load the legacy single sidebar sort order from localStorage, if present.
|
||||||
*/
|
*/
|
||||||
@@ -149,15 +124,3 @@ export function saveLocalStorageSidebarSectionSortOrders(orders: SidebarSectionS
|
|||||||
// localStorage might be disabled
|
// localStorage might be disabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear conversation state from localStorage (after migration)
|
|
||||||
*/
|
|
||||||
export function clearLocalStorageConversationState(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(LAST_MESSAGE_KEY);
|
|
||||||
localStorage.removeItem(SORT_ORDER_KEY);
|
|
||||||
} catch {
|
|
||||||
// localStorage might be disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Favorites utilities.
|
* Favorites utilities.
|
||||||
*
|
*
|
||||||
* Favorites are now stored server-side in the database.
|
* Favorites are stored server-side in the database.
|
||||||
* This file provides helper functions for checking favorites
|
|
||||||
* and loading legacy localStorage data for migration.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Favorite } from '../types';
|
import type { Favorite } from '../types';
|
||||||
|
|
||||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a conversation is favorited (from provided favorites array)
|
* Check if a conversation is favorited (from provided favorites array)
|
||||||
*/
|
*/
|
||||||
@@ -20,26 +16,3 @@ export function isFavorite(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return favorites.some((f) => f.type === type && f.id === id);
|
return favorites.some((f) => f.type === type && f.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load favorites from localStorage (for migration only)
|
|
||||||
*/
|
|
||||||
export function loadLocalStorageFavorites(): Favorite[] {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear favorites from localStorage (after migration)
|
|
||||||
*/
|
|
||||||
export function clearLocalStorageFavorites(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(FAVORITES_KEY);
|
|
||||||
} catch {
|
|
||||||
// localStorage might be disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { getRawPacketObservationKey } from './rawPacketIdentity';
|
|||||||
export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const;
|
export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const;
|
||||||
export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number];
|
export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number];
|
||||||
|
|
||||||
export const RAW_PACKET_STATS_WINDOW_SECONDS: Record<
|
const RAW_PACKET_STATS_WINDOW_SECONDS: Record<Exclude<RawPacketStatsWindow, 'session'>, number> = {
|
||||||
Exclude<RawPacketStatsWindow, 'session'>,
|
|
||||||
number
|
|
||||||
> = {
|
|
||||||
'1m': 60,
|
'1m': 60,
|
||||||
'5m': 5 * 60,
|
'5m': 5 * 60,
|
||||||
'10m': 10 * 60,
|
'10m': 10 * 60,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type ServerLoginAttemptState =
|
|||||||
at: number;
|
at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getServerLoginMethodLabel(
|
function getServerLoginMethodLabel(
|
||||||
method: ServerLoginMethod,
|
method: ServerLoginMethod,
|
||||||
blankLabel = 'existing-access'
|
blankLabel = 'existing-access'
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface VisualizerSettings {
|
|||||||
hidePacketFeed: boolean;
|
hidePacketFeed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VISUALIZER_DEFAULTS: VisualizerSettings = {
|
const VISUALIZER_DEFAULTS: VisualizerSettings = {
|
||||||
showAmbiguousPaths: true,
|
showAmbiguousPaths: true,
|
||||||
showAmbiguousNodes: false,
|
showAmbiguousNodes: false,
|
||||||
useAdvertPathHints: true,
|
useAdvertPathHints: true,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export interface PathStep {
|
|||||||
hiddenLabel?: string | null;
|
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() ?? '';
|
const normalized = hop?.trim().toLowerCase() ?? '';
|
||||||
return normalized.length > 0 ? normalized : null;
|
return normalized.length > 0 ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.7.1"
|
version = "3.8.0"
|
||||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
# developer perogative ;D
|
# developer perogative ;D
|
||||||
if command -v enablenvm >/dev/null 2>&1; then
|
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} "
|
echo -ne "${BLUE}[pyright]${NC} "
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
uv run pyright app/ --outputjson 2>/dev/null | python3 -c "
|
pyright_json="$(mktemp)"
|
||||||
import sys, json
|
if uv run pyright app/ --outputjson >"$pyright_json"; then
|
||||||
d = json.load(sys.stdin)
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1]) as f:
|
||||||
|
d = json.load(f)
|
||||||
s = d.get('summary', {})
|
s = d.get('summary', {})
|
||||||
print(f\"{s.get('filesAnalyzed',0)} files, 0 errors\")
|
print(f\"{s.get('filesAnalyzed', 0)} files, {s.get('errorCount', 0)} errors\")
|
||||||
" 2>/dev/null || { uv run pyright app/; exit 1; }
|
" "$pyright_json"
|
||||||
|
else
|
||||||
|
uv run pyright app/
|
||||||
|
rm -f "$pyright_json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$pyright_json"
|
||||||
echo -e "${GREEN}Passed!${NC}"
|
echo -e "${GREEN}Passed!${NC}"
|
||||||
|
|
||||||
echo -ne "${BLUE}[pytest]${NC} "
|
echo -ne "${BLUE}[pytest]${NC} "
|
||||||
@@ -59,7 +68,15 @@ echo -e "${GREEN}Passed!${NC}"
|
|||||||
|
|
||||||
echo -ne "${BLUE}[vitest]${NC} "
|
echo -ne "${BLUE}[vitest]${NC} "
|
||||||
cd "$REPO_ROOT/frontend"
|
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 -e "${GREEN}Passed!${NC}"
|
||||||
|
|
||||||
echo -ne "${BLUE}[build]${NC} "
|
echo -ne "${BLUE}[build]${NC} "
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ export interface AppSettings {
|
|||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
auto_decrypt_dm_on_advert: boolean;
|
auto_decrypt_dm_on_advert: boolean;
|
||||||
last_message_times: Record<string, number>;
|
last_message_times: Record<string, number>;
|
||||||
preferences_migrated: boolean;
|
|
||||||
advert_interval: number;
|
advert_interval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class TestDMAckTrackingWiring:
|
|||||||
await _insert_contact(pub_key)
|
await _insert_contact(pub_key)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
@@ -115,7 +115,7 @@ class TestDMAckTrackingWiring:
|
|||||||
await _insert_contact(pub_key)
|
await _insert_contact(pub_key)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
@@ -144,7 +144,7 @@ class TestDMAckTrackingWiring:
|
|||||||
await _insert_contact(pub_key)
|
await _insert_contact(pub_key)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
@@ -172,7 +172,7 @@ class TestDMAckTrackingWiring:
|
|||||||
await _insert_contact(pub_key)
|
await _insert_contact(pub_key)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.track_pending_ack") as mock_track,
|
patch("app.routers.messages.track_pending_ack") as mock_track,
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
|
|||||||
+2
-2
@@ -49,10 +49,10 @@ def _disable_background_dm_retries(monkeypatch):
|
|||||||
def _patch_require_connected(mc=None, *, detail="Radio not connected"):
|
def _patch_require_connected(mc=None, *, detail="Radio not connected"):
|
||||||
if mc is None:
|
if mc is None:
|
||||||
return patch(
|
return patch(
|
||||||
"app.dependencies.radio_manager.require_connected",
|
"app.services.radio_runtime.radio_runtime.require_connected",
|
||||||
side_effect=HTTPException(status_code=503, detail=detail),
|
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):
|
async def _insert_contact(public_key, name="Alice", **overrides):
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ class TestPathDiscovery:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.routers.contacts.radio_manager") as mock_rm,
|
||||||
patch("app.websocket.broadcast_event") as mock_broadcast,
|
patch("app.websocket.broadcast_event") as mock_broadcast,
|
||||||
):
|
):
|
||||||
@@ -324,7 +324,7 @@ class TestPathDiscovery:
|
|||||||
mc.wait_for_event = AsyncMock(return_value=None)
|
mc.wait_for_event = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.routers.contacts.radio_manager") as mock_rm,
|
||||||
):
|
):
|
||||||
mock_rm.radio_operation = _noop_radio_operation(mc)
|
mock_rm.radio_operation = _noop_radio_operation(mc)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from app.decoder import (
|
|||||||
DecryptedDirectMessage,
|
DecryptedDirectMessage,
|
||||||
PayloadType,
|
PayloadType,
|
||||||
RouteType,
|
RouteType,
|
||||||
_clamp_scalar,
|
|
||||||
decrypt_direct_message,
|
decrypt_direct_message,
|
||||||
decrypt_group_text,
|
decrypt_group_text,
|
||||||
decrypt_path_payload,
|
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:
|
class TestPacketParsing:
|
||||||
"""Test raw packet header parsing."""
|
"""Test raw packet header parsing."""
|
||||||
|
|
||||||
@@ -687,49 +675,6 @@ class TestAdvertisementParsing:
|
|||||||
assert result is None
|
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:
|
class TestPublicKeyDerivation:
|
||||||
"""Test deriving Ed25519 public key from MeshCore private key."""
|
"""Test deriving Ed25519 public key from MeshCore private key."""
|
||||||
|
|
||||||
@@ -766,13 +711,6 @@ class TestPublicKeyDerivation:
|
|||||||
assert len(result) == 32
|
assert len(result) == 32
|
||||||
assert result == self.FACE12_PUB_EXPECTED
|
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:
|
class TestSharedSecretDerivation:
|
||||||
"""Test ECDH shared secret derivation from Ed25519 keys."""
|
"""Test ECDH shared secret derivation from Ed25519 keys."""
|
||||||
@@ -793,13 +731,6 @@ class TestSharedSecretDerivation:
|
|||||||
|
|
||||||
assert len(result) == 32
|
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):
|
def test_derive_shared_secret_different_keys_different_result(self):
|
||||||
"""Different key pairs produce different shared secrets."""
|
"""Different key pairs produce different shared secrets."""
|
||||||
# Use the real FACE12 public key as a second peer key (valid curve point)
|
# Use the real FACE12 public key as a second peer key (valid curve point)
|
||||||
|
|||||||
@@ -10,24 +10,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.config import Settings
|
|
||||||
from app.repository.fanout import FanoutConfigRepository
|
from app.repository.fanout import FanoutConfigRepository
|
||||||
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
|
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
|
||||||
from app.routers.health import build_health_data
|
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:
|
class TestDisableBotsFanoutEndpoint:
|
||||||
"""Test that bot creation via fanout router is rejected when bots are disabled."""
|
"""Test that bot creation via fanout router is rejected when bots are disabled."""
|
||||||
|
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ class TestDirectMessageDirectionDetection:
|
|||||||
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
||||||
assert len(message_broadcasts) == 1
|
assert len(message_broadcasts) == 1
|
||||||
assert message_broadcasts[0]["data"]["paths"] == [
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -89,19 +89,6 @@ class TestSetPrivateKey:
|
|||||||
assert pub1 != pub2
|
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:
|
class TestClearKeys:
|
||||||
"""Test clearing in-memory key material."""
|
"""Test clearing in-memory key material."""
|
||||||
|
|
||||||
|
|||||||
+16
-41
@@ -8,31 +8,6 @@ import pytest
|
|||||||
from app.migrations import get_version, run_migrations, set_version
|
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:
|
class TestMigration001:
|
||||||
"""Test migration 001: add last_read_at columns."""
|
"""Test migration 001: add last_read_at columns."""
|
||||||
|
|
||||||
@@ -1249,8 +1224,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 13
|
assert applied == 16
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1321,8 +1296,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 13
|
assert applied == 16
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1388,8 +1363,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 7
|
assert applied == 10
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1441,8 +1416,8 @@ class TestMigration040:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 12
|
assert applied == 15
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1503,8 +1478,8 @@ class TestMigration041:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 11
|
assert applied == 14
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1556,8 +1531,8 @@ class TestMigration042:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 10
|
assert applied == 13
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1696,8 +1671,8 @@ class TestMigration046:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 6
|
assert applied == 9
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1790,8 +1765,8 @@ class TestMigration047:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 5
|
assert applied == 8
|
||||||
assert await get_version(conn) == 51
|
assert await get_version(conn) == 54
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
+11
-11
@@ -295,12 +295,12 @@ class TestContactToRadioDictHashMode:
|
|||||||
|
|
||||||
|
|
||||||
class TestContactFromRadioDictHashMode:
|
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):
|
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,
|
"aa" * 32,
|
||||||
{
|
{
|
||||||
"adv_name": "Alice",
|
"adv_name": "Alice",
|
||||||
@@ -309,14 +309,14 @@ class TestContactFromRadioDictHashMode:
|
|||||||
"out_path_hash_mode": 1,
|
"out_path_hash_mode": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert d["direct_path"] == "aa00bb00"
|
assert upsert.direct_path == "aa00bb00"
|
||||||
assert d["direct_path_len"] == 2
|
assert upsert.direct_path_len == 2
|
||||||
assert d["direct_path_hash_mode"] == 1
|
assert upsert.direct_path_hash_mode == 1
|
||||||
|
|
||||||
def test_flood_falls_back_to_minus_one(self):
|
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,
|
"bb" * 32,
|
||||||
{
|
{
|
||||||
"adv_name": "Bob",
|
"adv_name": "Bob",
|
||||||
@@ -324,6 +324,6 @@ class TestContactFromRadioDictHashMode:
|
|||||||
"out_path_len": -1,
|
"out_path_len": -1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert d["direct_path"] == ""
|
assert upsert.direct_path == ""
|
||||||
assert d["direct_path_len"] == -1
|
assert upsert.direct_path_len == -1
|
||||||
assert d["direct_path_hash_mode"] == -1
|
assert upsert.direct_path_hash_mode == -1
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ import pytest
|
|||||||
|
|
||||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||||
from app.radio_sync import is_polling_paused
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -183,15 +178,15 @@ class TestRequireConnected:
|
|||||||
"""HTTPException 503 is raised when radio is connected but setup is still in progress."""
|
"""HTTPException 503 is raised when radio is connected but setup is still in progress."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
manager = MagicMock()
|
manager = MagicMock()
|
||||||
manager.is_connected = True
|
manager.is_connected = True
|
||||||
manager.meshcore = MagicMock()
|
manager.meshcore = MagicMock()
|
||||||
manager.is_setup_in_progress = True
|
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:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
require_connected()
|
radio_runtime.require_connected()
|
||||||
|
|
||||||
assert exc_info.value.status_code == 503
|
assert exc_info.value.status_code == 503
|
||||||
assert "initializing" in exc_info.value.detail.lower()
|
assert "initializing" in exc_info.value.detail.lower()
|
||||||
@@ -200,28 +195,28 @@ class TestRequireConnected:
|
|||||||
"""HTTPException 503 is raised when radio is not connected."""
|
"""HTTPException 503 is raised when radio is not connected."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
manager = MagicMock()
|
manager = MagicMock()
|
||||||
manager.is_setup_in_progress = False
|
manager.is_setup_in_progress = False
|
||||||
manager.is_connected = False
|
manager.is_connected = False
|
||||||
manager.meshcore = None
|
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:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
require_connected()
|
radio_runtime.require_connected()
|
||||||
|
|
||||||
assert exc_info.value.status_code == 503
|
assert exc_info.value.status_code == 503
|
||||||
|
|
||||||
def test_returns_meshcore_when_connected_and_setup_complete(self):
|
def test_returns_meshcore_when_connected_and_setup_complete(self):
|
||||||
"""Returns meshcore instance when radio is connected and setup is complete."""
|
"""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()
|
mock_mc = MagicMock()
|
||||||
manager = MagicMock()
|
manager = MagicMock()
|
||||||
manager.is_setup_in_progress = False
|
manager.is_setup_in_progress = False
|
||||||
manager.is_connected = True
|
manager.is_connected = True
|
||||||
manager.meshcore = mock_mc
|
manager.meshcore = mock_mc
|
||||||
with patch("app.dependencies.radio_manager", _runtime(manager)):
|
with patch.object(radio_runtime, "_manager_getter", return_value=manager):
|
||||||
result = require_connected()
|
result = radio_runtime.require_connected()
|
||||||
|
|
||||||
assert result is mock_mc
|
assert result is mock_mc
|
||||||
|
|||||||
+27
-27
@@ -97,7 +97,7 @@ class TestGetRadioConfig:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_maps_self_info_to_response(self):
|
async def test_maps_self_info_to_response(self):
|
||||||
mc = _mock_meshcore_with_info()
|
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()
|
response = await get_radio_config()
|
||||||
|
|
||||||
assert response.public_key == "aa" * 32
|
assert response.public_key == "aa" * 32
|
||||||
@@ -114,7 +114,7 @@ class TestGetRadioConfig:
|
|||||||
mc = _mock_meshcore_with_info()
|
mc = _mock_meshcore_with_info()
|
||||||
mc.self_info["multi_acks"] = 1
|
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()
|
response = await get_radio_config()
|
||||||
|
|
||||||
assert response.multi_acks_enabled is True
|
assert response.multi_acks_enabled is True
|
||||||
@@ -124,7 +124,7 @@ class TestGetRadioConfig:
|
|||||||
mc = _mock_meshcore_with_info()
|
mc = _mock_meshcore_with_info()
|
||||||
mc.self_info["adv_loc_policy"] = 1
|
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()
|
response = await get_radio_config()
|
||||||
|
|
||||||
assert response.advert_location_source == "current"
|
assert response.advert_location_source == "current"
|
||||||
@@ -133,7 +133,7 @@ class TestGetRadioConfig:
|
|||||||
async def test_returns_503_when_self_info_missing(self):
|
async def test_returns_503_when_self_info_missing(self):
|
||||||
mc = MagicMock()
|
mc = MagicMock()
|
||||||
mc.self_info = None
|
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:
|
with pytest.raises(HTTPException) as exc:
|
||||||
await get_radio_config()
|
await get_radio_config()
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ class TestUpdateRadioConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock) as mock_sync_time,
|
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock) as mock_sync_time,
|
||||||
patch(
|
patch(
|
||||||
@@ -190,7 +190,7 @@ class TestUpdateRadioConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
|
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
|
||||||
patch(
|
patch(
|
||||||
@@ -220,7 +220,7 @@ class TestUpdateRadioConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
|
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
|
||||||
patch(
|
patch(
|
||||||
@@ -252,7 +252,7 @@ class TestUpdateRadioConfig:
|
|||||||
mc = _mock_meshcore_with_info()
|
mc = _mock_meshcore_with_info()
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch.object(radio_manager, "path_hash_mode_supported", False),
|
patch.object(radio_manager, "path_hash_mode_supported", False),
|
||||||
):
|
):
|
||||||
@@ -269,7 +269,7 @@ class TestUpdateRadioConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch.object(radio_manager, "path_hash_mode_supported", True),
|
patch.object(radio_manager, "path_hash_mode_supported", True),
|
||||||
patch.object(radio_manager, "path_hash_mode", 0),
|
patch.object(radio_manager, "path_hash_mode", 0),
|
||||||
@@ -287,7 +287,7 @@ class TestPrivateKeyImport:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rejects_invalid_hex(self):
|
async def test_rejects_invalid_hex(self):
|
||||||
mc = _mock_meshcore_with_info()
|
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:
|
with pytest.raises(HTTPException) as exc:
|
||||||
await set_private_key(PrivateKeyUpdate(private_key="not-hex"))
|
await set_private_key(PrivateKeyUpdate(private_key="not-hex"))
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ class TestPrivateKeyImport:
|
|||||||
return_value=_radio_result(EventType.ERROR, {"error": "failed"})
|
return_value=_radio_result(EventType.ERROR, {"error": "failed"})
|
||||||
)
|
)
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
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)
|
mc.commands.send_node_discover_req = AsyncMock(side_effect=_send_node_discover_req)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||||
patch(
|
patch(
|
||||||
@@ -441,7 +441,7 @@ class TestDiscoverMesh:
|
|||||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||||
patch(
|
patch(
|
||||||
@@ -517,7 +517,7 @@ class TestDiscoverMesh:
|
|||||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||||
patch(
|
patch(
|
||||||
@@ -591,7 +591,7 @@ class TestTracePath:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
||||||
@@ -648,7 +648,7 @@ class TestTracePath:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.routers.radio.require_connected", return_value=mc),
|
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
||||||
) as mock_get,
|
) as mock_get,
|
||||||
@@ -691,7 +691,7 @@ class TestTracePath:
|
|||||||
mc.wait_for_event = AsyncMock(return_value=None)
|
mc.wait_for_event = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
|
||||||
@@ -731,7 +731,7 @@ class TestTracePath:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.radio_manager") as mock_rm,
|
patch("app.routers.radio.radio_manager") as mock_rm,
|
||||||
):
|
):
|
||||||
@@ -775,7 +775,7 @@ class TestTracePath:
|
|||||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
|
||||||
patch(
|
patch(
|
||||||
@@ -811,7 +811,7 @@ class TestTracePath:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -825,7 +825,7 @@ class TestTracePath:
|
|||||||
mc = _mock_meshcore_with_info()
|
mc = _mock_meshcore_with_info()
|
||||||
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch(
|
patch(
|
||||||
"app.keystore.export_and_store_private_key",
|
"app.keystore.export_and_store_private_key",
|
||||||
@@ -843,7 +843,7 @@ class TestTracePath:
|
|||||||
mc = _mock_meshcore_with_info()
|
mc = _mock_meshcore_with_info()
|
||||||
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch(
|
patch(
|
||||||
"app.keystore.export_and_store_private_key",
|
"app.keystore.export_and_store_private_key",
|
||||||
@@ -864,7 +864,7 @@ class TestTracePath:
|
|||||||
mc = _mock_meshcore_with_info()
|
mc = _mock_meshcore_with_info()
|
||||||
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
|
||||||
with (
|
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, "_meshcore", mc),
|
||||||
patch(
|
patch(
|
||||||
"app.keystore.export_and_store_private_key",
|
"app.keystore.export_and_store_private_key",
|
||||||
@@ -883,7 +883,7 @@ class TestAdvertise:
|
|||||||
async def test_raises_when_send_fails(self):
|
async def test_raises_when_send_fails(self):
|
||||||
radio_manager._meshcore = MagicMock()
|
radio_manager._meshcore = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch("app.routers.radio.require_connected"),
|
patch("app.routers.radio.radio_manager.require_connected"),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.do_send_advertisement",
|
"app.routers.radio.do_send_advertisement",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
@@ -899,7 +899,7 @@ class TestAdvertise:
|
|||||||
async def test_defaults_to_flood_mode(self):
|
async def test_defaults_to_flood_mode(self):
|
||||||
radio_manager._meshcore = MagicMock()
|
radio_manager._meshcore = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch("app.routers.radio.require_connected"),
|
patch("app.routers.radio.radio_manager.require_connected"),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.do_send_advertisement",
|
"app.routers.radio.do_send_advertisement",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
@@ -917,7 +917,7 @@ class TestAdvertise:
|
|||||||
async def test_accepts_zero_hop_mode(self):
|
async def test_accepts_zero_hop_mode(self):
|
||||||
radio_manager._meshcore = MagicMock()
|
radio_manager._meshcore = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch("app.routers.radio.require_connected"),
|
patch("app.routers.radio.radio_manager.require_connected"),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.do_send_advertisement",
|
"app.routers.radio.do_send_advertisement",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
@@ -949,7 +949,7 @@ class TestAdvertise:
|
|||||||
isolated_manager = RadioManager()
|
isolated_manager = RadioManager()
|
||||||
isolated_manager._meshcore = MagicMock()
|
isolated_manager._meshcore = MagicMock()
|
||||||
with (
|
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.radio_manager", _runtime(isolated_manager)),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.do_send_advertisement",
|
"app.routers.radio.do_send_advertisement",
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ class TestRepeaterCommandRoute:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -314,7 +314,7 @@ class TestRepeaterCommandRoute:
|
|||||||
|
|
||||||
# Expire the deadline after a couple of ticks
|
# Expire the deadline after a couple of ticks
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
|
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
|
||||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||||
@@ -343,7 +343,7 @@ class TestRepeaterCommandRoute:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -371,7 +371,7 @@ class TestRepeaterCommandRoute:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -397,7 +397,7 @@ class TestRepeaterCommandRoute:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -425,7 +425,7 @@ class TestRepeaterCommandRoute:
|
|||||||
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
|
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -451,7 +451,7 @@ class TestRepeaterCommandRoute:
|
|||||||
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
|
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -474,7 +474,7 @@ class TestRepeaterCommandRoute:
|
|||||||
mc.commands.get_msg = AsyncMock(side_effect=[no_msgs, expected])
|
mc.commands.get_msg = AsyncMock(side_effect=[no_msgs, expected])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||||
@@ -495,7 +495,7 @@ class TestTraceRoute:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||||
):
|
):
|
||||||
@@ -517,7 +517,7 @@ class TestTraceRoute:
|
|||||||
mc.wait_for_event = AsyncMock(return_value=None)
|
mc.wait_for_event = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||||
):
|
):
|
||||||
@@ -541,7 +541,7 @@ class TestTraceRoute:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
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)
|
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(
|
patch(
|
||||||
"app.routers.repeaters.prepare_repeater_connection",
|
"app.routers.repeaters.prepare_repeater_connection",
|
||||||
@@ -592,7 +592,7 @@ class TestRepeaterLogin:
|
|||||||
async def test_404_missing_contact(self, test_db):
|
async def test_404_missing_contact(self, test_db):
|
||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -604,7 +604,7 @@ class TestRepeaterLogin:
|
|||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -625,7 +625,7 @@ class TestRepeaterLogin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.repeaters.prepare_repeater_connection", side_effect=_prepare_fail),
|
patch("app.routers.repeaters.prepare_repeater_connection", side_effect=_prepare_fail),
|
||||||
):
|
):
|
||||||
@@ -726,7 +726,7 @@ class TestRepeaterStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_status(KEY_A)
|
response = await repeater_status(KEY_A)
|
||||||
@@ -749,7 +749,7 @@ class TestRepeaterStatus:
|
|||||||
mc.commands.req_status_sync = AsyncMock(return_value=None)
|
mc.commands.req_status_sync = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -761,7 +761,7 @@ class TestRepeaterStatus:
|
|||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -787,7 +787,7 @@ class TestRepeaterLppTelemetry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_lpp_telemetry(KEY_A)
|
response = await repeater_lpp_telemetry(KEY_A)
|
||||||
@@ -809,7 +809,7 @@ class TestRepeaterLppTelemetry:
|
|||||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=[])
|
mc.commands.req_telemetry_sync = AsyncMock(return_value=[])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_lpp_telemetry(KEY_A)
|
response = await repeater_lpp_telemetry(KEY_A)
|
||||||
@@ -823,7 +823,7 @@ class TestRepeaterLppTelemetry:
|
|||||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -835,7 +835,7 @@ class TestRepeaterLppTelemetry:
|
|||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -861,7 +861,7 @@ class TestRepeaterNeighbors:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_neighbors(KEY_A)
|
response = await repeater_neighbors(KEY_A)
|
||||||
@@ -879,7 +879,7 @@ class TestRepeaterNeighbors:
|
|||||||
mc.commands.fetch_all_neighbours = AsyncMock(return_value={"neighbours": []})
|
mc.commands.fetch_all_neighbours = AsyncMock(return_value={"neighbours": []})
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_neighbors(KEY_A)
|
response = await repeater_neighbors(KEY_A)
|
||||||
@@ -893,7 +893,7 @@ class TestRepeaterNeighbors:
|
|||||||
mc.commands.fetch_all_neighbours = AsyncMock(return_value=None)
|
mc.commands.fetch_all_neighbours = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_neighbors(KEY_A)
|
response = await repeater_neighbors(KEY_A)
|
||||||
@@ -917,7 +917,7 @@ class TestRepeaterAcl:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_acl(KEY_A)
|
response = await repeater_acl(KEY_A)
|
||||||
@@ -935,7 +935,7 @@ class TestRepeaterAcl:
|
|||||||
mc.commands.req_acl_sync = AsyncMock(return_value=[])
|
mc.commands.req_acl_sync = AsyncMock(return_value=[])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_acl(KEY_A)
|
response = await repeater_acl(KEY_A)
|
||||||
@@ -949,7 +949,7 @@ class TestRepeaterAcl:
|
|||||||
mc.commands.req_acl_sync = AsyncMock(return_value=None)
|
mc.commands.req_acl_sync = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await repeater_acl(KEY_A)
|
response = await repeater_acl(KEY_A)
|
||||||
@@ -982,7 +982,7 @@ class TestRepeaterRadioSettings:
|
|||||||
mc.commands.get_msg = AsyncMock(side_effect=get_msg_results)
|
mc.commands.get_msg = AsyncMock(side_effect=get_msg_results)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -1015,7 +1015,7 @@ class TestRepeaterRadioSettings:
|
|||||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||||
@@ -1031,7 +1031,7 @@ class TestRepeaterRadioSettings:
|
|||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -1061,7 +1061,7 @@ class TestRepeaterNodeInfo:
|
|||||||
mc.commands.get_msg = AsyncMock(side_effect=get_msg_results)
|
mc.commands.get_msg = AsyncMock(side_effect=get_msg_results)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -1090,7 +1090,7 @@ class TestRepeaterNodeInfo:
|
|||||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||||
@@ -1122,7 +1122,7 @@ class TestRepeaterAdvertIntervals:
|
|||||||
mc.commands.get_msg = AsyncMock(side_effect=responses)
|
mc.commands.get_msg = AsyncMock(side_effect=responses)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -1143,7 +1143,7 @@ class TestRepeaterAdvertIntervals:
|
|||||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||||
@@ -1177,7 +1177,7 @@ class TestRepeaterOwnerInfo:
|
|||||||
mc.commands.get_msg = AsyncMock(side_effect=responses)
|
mc.commands.get_msg = AsyncMock(side_effect=responses)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
):
|
):
|
||||||
@@ -1198,7 +1198,7 @@ class TestRepeaterOwnerInfo:
|
|||||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
patch(_MONOTONIC, side_effect=clock_ticks),
|
||||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||||
@@ -1299,7 +1299,7 @@ class TestRepeaterAddContactError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -1317,7 +1317,7 @@ class TestRepeaterAddContactError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -1335,7 +1335,7 @@ class TestRepeaterAddContactError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -1353,7 +1353,7 @@ class TestRepeaterAddContactError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
|||||||
@@ -623,7 +623,6 @@ class TestAppSettingsRepository:
|
|||||||
"favorites": "{not-json",
|
"favorites": "{not-json",
|
||||||
"auto_decrypt_dm_on_advert": 1,
|
"auto_decrypt_dm_on_advert": 1,
|
||||||
"last_message_times": "{also-not-json",
|
"last_message_times": "{also-not-json",
|
||||||
"preferences_migrated": 0,
|
|
||||||
"advert_interval": None,
|
"advert_interval": None,
|
||||||
"last_advert_time": None,
|
"last_advert_time": None,
|
||||||
"flood_scope": "",
|
"flood_scope": "",
|
||||||
@@ -672,39 +671,6 @@ class TestAppSettingsRepository:
|
|||||||
assert result == existing
|
assert result == existing
|
||||||
mock_update.assert_not_awaited()
|
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:
|
class TestMessageRepositoryGetById:
|
||||||
"""Test MessageRepository.get_by_id method."""
|
"""Test MessageRepository.get_by_id method."""
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class TestRoomLogin:
|
|||||||
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
||||||
|
|
||||||
with (
|
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),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
|
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)
|
await _insert_contact(ROOM_KEY, name="Client", contact_type=1)
|
||||||
|
|
||||||
with (
|
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),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
@@ -139,7 +139,7 @@ class TestRoomStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await room_status(ROOM_KEY)
|
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}])
|
mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": AUTHOR_KEY[:12], "perm": 3}])
|
||||||
|
|
||||||
with (
|
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),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await room_acl(ROOM_KEY)
|
response = await room_acl(ROOM_KEY)
|
||||||
@@ -179,7 +179,7 @@ class TestRoomCommandReuse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
response = await send_repeater_command(ROOM_KEY, CommandRequest(command="ver"))
|
response = await send_repeater_command(ROOM_KEY, CommandRequest(command="ver"))
|
||||||
|
|||||||
+47
-47
@@ -119,7 +119,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
broadcasts.append({"type": event_type, "data": data})
|
broadcasts.append({"type": event_type, "data": data})
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
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" + "00" * 29, "ContactA")
|
||||||
await _insert_contact("abc123" + "ff" * 29, "ContactB")
|
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:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await send_direct_message(
|
await send_direct_message(
|
||||||
SendDirectMessageRequest(destination="abc123", text="Hello")
|
SendDirectMessageRequest(destination="abc123", text="Hello")
|
||||||
@@ -166,7 +166,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -195,7 +195,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -225,7 +225,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
assert original_id is not None
|
assert original_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.routers.messages.time") as mock_time,
|
patch("app.routers.messages.time") as mock_time,
|
||||||
@@ -267,7 +267,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.event_handlers.broadcast_event", side_effect=capture_broadcast),
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
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({}))
|
mc.commands.send_msg = AsyncMock(return_value=_make_radio_result({}))
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.services.message_send.asyncio.create_task") as mock_create_task,
|
patch("app.services.message_send.asyncio.create_task") as mock_create_task,
|
||||||
@@ -338,7 +338,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
with (
|
with (
|
||||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||||
patch("app.routers.messages.track_pending_ack", return_value=False),
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||||
@@ -386,7 +386,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
with (
|
with (
|
||||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||||
patch("app.event_handlers.broadcast_event"),
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||||
@@ -443,7 +443,7 @@ class TestOutgoingDMBroadcast:
|
|||||||
with (
|
with (
|
||||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||||
patch("app.event_handlers.broadcast_event"),
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
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})
|
broadcasts.append({"type": event_type, "data": data})
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||||
):
|
):
|
||||||
@@ -511,7 +511,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
assert original_id is not None
|
assert original_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.routers.messages.time") as mock_time,
|
patch("app.routers.messages.time") as mock_time,
|
||||||
@@ -537,7 +537,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
await ChannelRepository.upsert(key=chan_key, name="#acked")
|
await ChannelRepository.upsert(key=chan_key, name="#acked")
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -564,7 +564,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
broadcasts.append({"type": event_type, "data": data})
|
broadcasts.append({"type": event_type, "data": data})
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||||
):
|
):
|
||||||
@@ -594,7 +594,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
await AppSettingsRepository.update(flood_scope="Baseline")
|
await AppSettingsRepository.update(flood_scope="Baseline")
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -617,7 +617,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
await AppSettingsRepository.update(flood_scope="Esperance")
|
await AppSettingsRepository.update(flood_scope="Esperance")
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -638,7 +638,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
pytest.raises(HTTPException) as exc_info,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
@@ -660,7 +660,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -688,7 +688,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -729,7 +729,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
radio_manager._connection_info = "TCP: 127.0.0.1:4000"
|
radio_manager._connection_info = "TCP: 127.0.0.1:4000"
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -753,7 +753,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
radio_manager._connection_info = "Serial: /dev/ttyUSB0"
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.radio.settings.force_channel_slot_reconfigure", True),
|
patch("app.radio.settings.force_channel_slot_reconfigure", True),
|
||||||
@@ -781,7 +781,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
pytest.raises(HTTPException) as exc_info,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
@@ -816,7 +816,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
result = await resend_channel_message(msg_id, new_timestamp=False)
|
result = await resend_channel_message(msg_id, new_timestamp=False)
|
||||||
@@ -849,7 +849,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
await resend_channel_message(msg_id, new_timestamp=False)
|
await resend_channel_message(msg_id, new_timestamp=False)
|
||||||
@@ -877,7 +877,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
await resend_channel_message(msg_id, new_timestamp=False)
|
await resend_channel_message(msg_id, new_timestamp=False)
|
||||||
@@ -914,7 +914,7 @@ class TestResendChannelMessage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_error") as mock_broadcast_error,
|
patch("app.routers.messages.broadcast_error") as mock_broadcast_error,
|
||||||
):
|
):
|
||||||
@@ -943,7 +943,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.routers.messages.time") as mock_time,
|
patch("app.routers.messages.time") as mock_time,
|
||||||
@@ -989,7 +989,7 @@ class TestResendChannelMessage:
|
|||||||
mc.commands.send_chan_msg = AsyncMock(return_value=None)
|
mc.commands.send_chan_msg = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
pytest.raises(HTTPException) as exc_info,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
@@ -1022,7 +1022,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
await resend_channel_message(msg_id, new_timestamp=False)
|
await resend_channel_message(msg_id, new_timestamp=False)
|
||||||
@@ -1048,7 +1048,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
await resend_channel_message(msg_id, new_timestamp=False)
|
await resend_channel_message(msg_id, new_timestamp=False)
|
||||||
@@ -1062,7 +1062,7 @@ class TestResendChannelMessage:
|
|||||||
mc = _make_mc(name="MyNode")
|
mc = _make_mc(name="MyNode")
|
||||||
|
|
||||||
with (
|
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,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
await resend_channel_message(999999, new_timestamp=False)
|
await resend_channel_message(999999, new_timestamp=False)
|
||||||
@@ -1088,7 +1088,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
await resend_channel_message(msg_id, new_timestamp=False)
|
await resend_channel_message(msg_id, new_timestamp=False)
|
||||||
@@ -1115,7 +1115,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -1144,7 +1144,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -1179,7 +1179,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||||
):
|
):
|
||||||
@@ -1211,7 +1211,7 @@ class TestResendChannelMessage:
|
|||||||
assert msg_id is not None
|
assert msg_id is not None
|
||||||
|
|
||||||
with (
|
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,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
await resend_channel_message(msg_id, new_timestamp=False)
|
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"))
|
mc.commands.send_msg = AsyncMock(side_effect=ConnectionError("Serial port disconnected"))
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
@@ -1258,7 +1258,7 @@ class TestRadioExceptionMidSend:
|
|||||||
mc.commands.send_msg = AsyncMock(return_value=None)
|
mc.commands.send_msg = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
pytest.raises(HTTPException) as exc_info,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
@@ -1286,7 +1286,7 @@ class TestRadioExceptionMidSend:
|
|||||||
mc.commands.send_chan_msg = AsyncMock(return_value=None)
|
mc.commands.send_chan_msg = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
pytest.raises(HTTPException) as exc_info,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
@@ -1316,7 +1316,7 @@ class TestRadioExceptionMidSend:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
@@ -1341,7 +1341,7 @@ class TestRadioExceptionMidSend:
|
|||||||
mc.commands.set_channel = AsyncMock(side_effect=TimeoutError("Radio not responding"))
|
mc.commands.set_channel = AsyncMock(side_effect=TimeoutError("Radio not responding"))
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(TimeoutError):
|
with pytest.raises(TimeoutError):
|
||||||
@@ -1377,7 +1377,7 @@ class TestRadioExceptionMidSend:
|
|||||||
mc.commands.set_channel = AsyncMock(side_effect=TimeoutError("Radio not responding"))
|
mc.commands.set_channel = AsyncMock(side_effect=TimeoutError("Radio not responding"))
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
):
|
):
|
||||||
with pytest.raises(TimeoutError):
|
with pytest.raises(TimeoutError):
|
||||||
@@ -1407,7 +1407,7 @@ class TestRadioExceptionMidSend:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
pytest.raises(HTTPException) as exc_info,
|
pytest.raises(HTTPException) as exc_info,
|
||||||
):
|
):
|
||||||
@@ -1440,7 +1440,7 @@ class TestConcurrentChannelSends:
|
|||||||
await ChannelRepository.upsert(key=chan_key_b, name="#bravo")
|
await ChannelRepository.upsert(key=chan_key_b, name="#bravo")
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
@@ -1494,7 +1494,7 @@ class TestConcurrentChannelSends:
|
|||||||
return original_time() + call_count
|
return original_time() + call_count
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch("app.routers.messages.time") as mock_time,
|
patch("app.routers.messages.time") as mock_time,
|
||||||
@@ -1537,7 +1537,7 @@ class TestChannelSendLockScope:
|
|||||||
return await original_create(*args, **kwargs)
|
return await original_create(*args, **kwargs)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
patch(
|
patch(
|
||||||
@@ -1587,7 +1587,7 @@ class TestChannelSendLockScope:
|
|||||||
mc.commands.send_chan_msg = AsyncMock(side_effect=send_with_self_observation)
|
mc.commands.send_chan_msg = AsyncMock(side_effect=send_with_self_observation)
|
||||||
|
|
||||||
with (
|
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.object(radio_manager, "_meshcore", mc),
|
||||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.models import AppSettings
|
from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert
|
||||||
from app.repository import AppSettingsRepository
|
from app.repository import AppSettingsRepository, ContactRepository
|
||||||
from app.routers.settings import (
|
from app.routers.settings import (
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
FavoriteRequest,
|
FavoriteRequest,
|
||||||
MigratePreferencesRequest,
|
TrackedTelemetryRequest,
|
||||||
migrate_preferences,
|
|
||||||
toggle_favorite,
|
toggle_favorite,
|
||||||
|
toggle_tracked_telemetry,
|
||||||
update_settings,
|
update_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,41 +165,81 @@ class TestToggleFavorite:
|
|||||||
mock_create_task.assert_not_called()
|
mock_create_task.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestMigratePreferences:
|
class TestToggleTrackedTelemetry:
|
||||||
@pytest.mark.asyncio
|
"""Tests for POST /settings/tracked-telemetry/toggle."""
|
||||||
async def test_maps_frontend_payload_and_returns_migrated_true(self, test_db):
|
|
||||||
request = MigratePreferencesRequest(
|
async def _create_repeater(self, key: str, name: str = "TestRepeater") -> None:
|
||||||
favorites=[FavoriteRequest(type="contact", id="aa" * 32)],
|
await ContactRepository.upsert(
|
||||||
sort_order="alpha",
|
ContactUpsert(public_key=key, name=name, type=CONTACT_TYPE_REPEATER)
|
||||||
last_message_times={"contact-aaaaaaaaaaaa": 123},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
@pytest.mark.asyncio
|
||||||
async def test_returns_migrated_false_when_already_done(self, test_db):
|
async def test_add_repeater_to_tracking(self, test_db):
|
||||||
# First migration
|
key = "aa" * 32
|
||||||
first_request = MigratePreferencesRequest(
|
await self._create_repeater(key)
|
||||||
favorites=[FavoriteRequest(type="contact", id="bb" * 32)],
|
|
||||||
sort_order="recent",
|
|
||||||
last_message_times={},
|
|
||||||
)
|
|
||||||
await migrate_preferences(first_request)
|
|
||||||
|
|
||||||
# Second attempt should be no-op
|
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||||
second_request = MigratePreferencesRequest(
|
|
||||||
favorites=[],
|
|
||||||
sort_order="recent",
|
|
||||||
last_message_times={},
|
|
||||||
)
|
|
||||||
response = await migrate_preferences(second_request)
|
|
||||||
|
|
||||||
assert response.migrated is False
|
assert key in result.tracked_telemetry_repeaters
|
||||||
assert response.settings.preferences_migrated is True
|
assert result.names[key] == "TestRepeater"
|
||||||
|
|
||||||
|
# Verify persisted
|
||||||
|
settings = await AppSettingsRepository.get()
|
||||||
|
assert key in settings.tracked_telemetry_repeaters
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_repeater_from_tracking(self, test_db):
|
||||||
|
key = "bb" * 32
|
||||||
|
await self._create_repeater(key)
|
||||||
|
await AppSettingsRepository.update(tracked_telemetry_repeaters=[key])
|
||||||
|
|
||||||
|
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||||
|
|
||||||
|
assert key not in result.tracked_telemetry_repeaters
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_non_repeater_contact(self, test_db):
|
||||||
|
key = "cc" * 32
|
||||||
|
await ContactRepository.upsert(ContactUpsert(public_key=key, name="Client", type=1))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_unknown_contact(self, test_db):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key="dd" * 32))
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_when_limit_reached(self, test_db):
|
||||||
|
existing_keys = []
|
||||||
|
for i in range(8):
|
||||||
|
key = f"{i:02x}" * 32
|
||||||
|
await self._create_repeater(key, name=f"Repeater{i}")
|
||||||
|
existing_keys.append(key)
|
||||||
|
await AppSettingsRepository.update(tracked_telemetry_repeaters=existing_keys)
|
||||||
|
|
||||||
|
new_key = "ff" * 32
|
||||||
|
await self._create_repeater(new_key, name="NewRepeater")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=new_key))
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
detail = exc_info.value.detail
|
||||||
|
assert len(detail["tracked_telemetry_repeaters"]) == 8
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_still_works_when_limit_reached(self, test_db):
|
||||||
|
"""Toggling OFF an already-tracked repeater should work even at max capacity."""
|
||||||
|
keys = []
|
||||||
|
for i in range(8):
|
||||||
|
key = f"{i:02x}" * 32
|
||||||
|
await self._create_repeater(key)
|
||||||
|
keys.append(key)
|
||||||
|
await AppSettingsRepository.update(tracked_telemetry_repeaters=keys)
|
||||||
|
|
||||||
|
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=keys[0]))
|
||||||
|
assert keys[0] not in result.tracked_telemetry_repeaters
|
||||||
|
assert len(result.tracked_telemetry_repeaters) == 7
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ class TestPathHashWidthStats:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)),
|
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()
|
breakdown = await StatisticsRepository._path_hash_width_24h()
|
||||||
|
|
||||||
|
|||||||
@@ -983,7 +983,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.7.1"
|
version = "3.8.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiomqtt" },
|
{ name = "aiomqtt" },
|
||||||
|
|||||||
Reference in New Issue
Block a user